Compare commits

..

1 Commits

Author SHA1 Message Date
Raphael Michel
362566eeb6 Dockerfile: Fix pretix version not known to pip 2021-07-02 10:10:08 +02:00
442 changed files with 109736 additions and 246419 deletions

View File

@@ -1,15 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip"
directory: "/src"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/src/pretix/static/npm_dir"
schedule:
interval: "monthly"

View File

@@ -55,7 +55,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install gettext mariadb-client
run: sudo apt update && sudo apt install gettext mysql-client
- name: Install Python dependencies
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
working-directory: ./src

View File

@@ -1,9 +1,9 @@
FROM python:3.9-bullseye
FROM python:3.8
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libmariadb-dev \
default-libmysqlclient-dev \
gettext \
git \
libffi-dev \
@@ -15,7 +15,8 @@ RUN apt-get update && \
libxslt1-dev \
locales \
nginx \
python3-virtualenv \
python-dev \
python-virtualenv \
python3-dev \
sudo \
supervisor \
@@ -56,7 +57,6 @@ COPY deployment/docker/supervisord /etc/supervisord
COPY deployment/docker/supervisord.all.conf /etc/supervisord.all.conf
COPY deployment/docker/supervisord.web.conf /etc/supervisord.web.conf
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
COPY deployment/docker/nginx-max-body-size.conf /etc/nginx/conf.d/nginx-max-body-size.conf
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY src /pretix/src

View File

@@ -1 +0,0 @@
client_max_body_size 100M;

View File

@@ -16,6 +16,7 @@ http {
charset utf-8;
tcp_nopush on;
tcp_nodelay on;
client_max_body_size 100M;
log_format private '[$time_local] $host "$request" $status $body_bytes_sent';
@@ -65,18 +66,9 @@ http {
access_log off;
expires 365d;
add_header Cache-Control "public";
add_header Access-Control-Allow-Origin "*";
gzip on;
}
location / {
# Very important:
# proxy_pass http://unix:/tmp/pretix.sock:;
# is not the same as
# proxy_pass http://unix:/tmp/pretix.sock:/;
# In the latter case, nginx will apply its URL parsing, in the former it doesn't.
# There are situations in which pretix' API will deal with "file names" containing %2F%2F, which
# nginx will normalize to %2F, which can break ticket validation.
proxy_pass http://unix:/tmp/pretix.sock:;
proxy_pass http://unix:/tmp/pretix.sock:/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}

View File

@@ -434,21 +434,3 @@ pretix can make use of some external tools if they are installed. Currently, the
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
Maximum upload file sizes
-------------------------
You can configure the maximum file size for uploading various files::
[pretix_file_upload]
; Max upload size for images in MiB, defaults to 10 MiB
max_size_image = 12
; Max upload size for favicons in MiB, defaults to 1 MiB
max_size_favicon = 2
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
max_size_email_attachment = 15
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
max_size_email_auto_attachment = 2
; Max upload size for other files in MiB, defaults to 10 MiB
; This includes all file upload type order questions
max_size_other = 100

View File

@@ -1,40 +0,0 @@
.. _`admin-errors`:
Dealing with errors
===================
If you encounter an error in pretix, please follow the following steps to debug it:
* If the error message is shown on a **white page** and the last line of the error includes "nginx", the error is not with pretix
directly but with your nginx webserver. This might mean that pretix is not running, but it could also be something else.
Please first check your nginx error log. The default location is ``/var/log/nginx/error.log``.
* If it turns out pretix is not running, check the output of ``docker logs pretix`` for a docker installation and
``journalctl -u pretix-web.service`` for a manual installation.
* If the error message is an "**Internal Server Error**" in purple pretix design, please check pretix' log file which by default is at
``/var/pretix-data/logs/pretix.log`` if you installed with docker and ``/var/pretix/data/logs/pretix.log`` otherwise. If you don't
know how to interpret it, open a discussion on GitHub with the relevant parts of the log file.
* If the error message includes ``/usr/bin/env: node: No such file or directory``, you forgot to install ``node.js``
* If the error message includes ``OfflineGenerationError``, you might have forgot to run the ``rebuild`` step after a pretix update
or plugin installation.
* If the error message mentions your database server or redis server, make sure these are running and accessible.
* If pretix loads fine but certain actions (creating carts, orders, or exports, downloading tickets, sending emails) **take forever**,
``pretix-worker`` is not running. Check the output of ``docker logs pretix`` for a docker installation and
``journalctl -u pretix-worker.service`` for a manual installation.
* If the page loads but all **styles are missing**, you probably forgot to update your nginx configuration file after an upgrade of your
operating system's python version.
If you are unable to debug the issue any further, please open a **discussion** on GitHub in our `Q&A Forum`_. Do **not** open an issue
right away, since most things turn out not to be a bug in pretix but a mistake in your server configuration. Make sure to include
relevant log excerpts in your question.
If you're a pretix Enterprise customer, you can also reach out to support@pretix.eu with your issue right away.
.. _Q&A Forum: https://github.com/pretix/pretix/discussions/categories/q-a

View File

@@ -9,9 +9,7 @@ This documentation is for everyone who wants to install pretix on a server.
:maxdepth: 2
installation/index
updates
config
maintainance
scaling
errors
indexes

View File

@@ -50,7 +50,7 @@ Here is the currently recommended set of commands::
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_name
ON pretixbase_orderposition
USING gin (upper("attendee_name_cached") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_secret
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_scret
ON pretixbase_orderposition
USING gin (upper("secret") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_email

View File

@@ -39,10 +39,6 @@ Linux and firewalls, we recommend that you start with `ufw`_.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
rules.
On this guide
-------------
@@ -187,7 +183,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
TimeoutStartSec=0
ExecStartPre=-/usr/bin/docker kill %n
ExecStartPre=-/usr/bin/docker rm %n
ExecStart=/usr/bin/docker run --name %n -p 127.0.0.1:8345:80 \
ExecStart=/usr/bin/docker run --name %n -p 8345:80 \
-v /var/pretix-data:/data \
-v /etc/pretix:/etc/pretix \
-v /var/run/redis:/var/run/redis \
@@ -237,7 +233,7 @@ The following snippet is an example on how to configure a nginx proxy for pretix
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8345;
proxy_pass http://localhost:8345/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
@@ -256,8 +252,6 @@ create an event and start selling tickets!
You should probably read :ref:`maintainance` next.
.. _`docker_updates`:
Updates
-------
@@ -273,8 +267,6 @@ Restarting the service can take a few seconds, especially if the update requires
Replace ``stable`` above with a specific version number like ``1.0`` or with ``latest`` for the development
version, if you want to.
Make sure to also read :ref:`update_notes` and the release notes of the version you are updating to.
.. _`docker_plugininstall`:
Install a plugin

View File

@@ -25,7 +25,7 @@ installation guides):
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
* A `redis`_ server
* A `nodejs`_ installation
* A `nodejs_` installation
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
Linux and firewalls, we recommend that you start with `ufw`_.
@@ -72,7 +72,7 @@ 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 \
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
gettext libpq-dev libmariadb-dev libjpeg-dev libopenjp2-7-dev
gettext libpq-dev libmariadbclient-dev libjpeg-dev libopenjp2-7-dev
Config file
-----------
@@ -237,7 +237,7 @@ The following snippet is an example on how to configure a nginx proxy for pretix
add_header X-Content-Type-Options nosniff;
location / {
proxy_pass http://localhost:8345;
proxy_pass http://localhost:8345/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
@@ -280,14 +280,13 @@ create an event and start selling tickets!
You should probably read :ref:`maintainance` next.
.. _`manual_updates`:
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::
To upgrade to a new pretix release, pull the latest code changes and run the following commands (again, replace
``postgres`` with ``mysql`` if necessary)::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
@@ -296,7 +295,6 @@ To upgrade to a new pretix release, pull the latest code changes and run the fol
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
Make sure to also read :ref:`update_notes` and the release notes of the version you are updating to.
.. _`manual_plugininstall`:

View File

@@ -9,8 +9,6 @@ If you host your own pretix instance, you also need to care about the availabili
of your service and the safety of your data yourself. This page gives you some
information that you might need to do so properly.
.. _`backups`:
Backups
-------

View File

@@ -1,51 +0,0 @@
.. _`update_notes`:
Update notes
============
pretix receives regular feature and bugfix updates and we highly encourage you to always update to
the latest version for maximum quality and security. Updates are announces on our `blog`_. There are
usually 10 feature updates in a year, so you can expect a new release almost every month.
Pure bugfix releases are only issued in case of very critical bugs or security vulnerabilities. In these
case, we'll publish bugfix releases for the last three stable release branches.
Compatibility to plugins and in very rare cases API clients may break. For in-depth details on the
API changes of every version, please refer to the release notes published on our blog.
Upgrade steps
-------------
For the actual upgrade, you can usually just follow the steps from the installation guide for :ref:`manual installations <manual_updates>`
or :ref:`docker installations <docker_updates>` respectively.
Generally, it is always strongly recommended to perform a :ref:`backup <backups>` first.
It is possible to skip versions during updates, although we recommend not skipping over major version numbers
(i.e. if you want to go from 2.4 to 4.4, first upgrade to 3.0, then upgrade to 4.0, then to 4.4).
In addition to these standard update steps, the following list issues steps that should be taken when you upgrade
to specific versions for pretix. If you're skipping versions, please read the instructions for every version in
between as well.
Upgrade to 3.17.0 or newer
""""""""""""""""""""""""""
pretix 3.17 introduces a dependency on ``nodejs``, so you should install it on your system::
# apt install nodejs npm
Upgrade to 4.4.0 or newer
"""""""""""""""""""""""""
pretix 4.4 introduces a new data structure to store historical financial data. If you already have existing
data in your database, you will need to back-fill this data or you might get incorrect reports! This is not
done automatically as part of the usual update steps since it can take a while on large databases and you might
want to do it in parallel while the system is already running again. Please execute the following command::
(venv)$ python -m pretix create_order_transactions
Or, with a docker installation::
$ docker exec -it pretix.service pretix create_order_transactions
.. _blog: https://pretix.eu/about/en/blog/

View File

@@ -87,8 +87,7 @@ respectively, or ``null`` if there is no such page. You can use those URLs to re
respective page.
The field ``results`` contains a list of objects representing the first results. For most
objects, every page contains 50 results. You can specify a lower pagination size using the
``page_size`` query parameter, but no more than 50.
objects, every page contains 50 results.
Conditional fetching
--------------------

View File

@@ -97,8 +97,7 @@ For example, if you want users to be redirected to ``https://example.org/order/r
either enter ``https://example.org`` or ``https://example.org/order/``.
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
message, but you should not rely on that and instead make sure you use our API to check if the payment actually
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
worked! Your final URL could look like this::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234

View File

@@ -243,99 +243,6 @@ Cart position endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
or fail individually, so the response code of the response is not the only thing to look at!
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning:: The same limitations as with the regular creation endpoint apply.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/bulk_create/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
[
{
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name_parts": {
"given_name": "Peter",
"family_name": "Miller"
},
"attendee_email": null,
"answers": [
{
"question": 1,
"answer": "23",
"options": []
}
],
"subevent": null
},
{
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name_parts": {
"given_name": "Maria",
"family_name": "Miller"
},
"attendee_email": null,
"answers": [
{
"question": 1,
"answer": "23",
"options": []
}
],
"subevent": null
}
]
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"results": [
{
"success": true,
"errors": null,
"data": {
"id": 1,
...
},
},
{
"success": "false",
"errors": {
"non_field_errors": ["There is not enough quota available on quota \"Tickets\" to perform the operation."]
},
"data": null
}
]
}
:param organizer: The ``slug`` field of the organizer of the event to create positions for
:param event: The ``slug`` field of the event to create positions for
:statuscode 200: See response for success
:statuscode 400: Your input could not be parsed
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
order.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
Deletes a cart position, identified by its internal ID.

View File

@@ -604,8 +604,6 @@ Order position endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position or check-in list does not exist.
.. _`rest-checkin-redeem`:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/redeem/
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
@@ -620,9 +618,8 @@ Order position endpoints
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
returned. Otherwise, canceled orders will return ``unpaid``.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
:<json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
questions that have not been filled. This is usually used to upload offline scans that already happened,
because there's no point in validating them since they happened whether they are valid or not. Defaults to ``false``.
:<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 string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
:<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

View File

@@ -31,6 +31,5 @@ Resources and endpoints
webhooks
seatingplans
exporters
sendmail_rules
billing_invoices
billing_var

View File

@@ -78,12 +78,6 @@ lines list of objects The actual invo
an event series not created by a product (e.g. shipping or
cancellation fees) as well as whenever the respective (sub)event
has no end date set.
├ event_location string Location of the (sub)event this line was created for as it
was set during invoice creation. Can be ``null`` for all invoice
lines created before this was introduced as well as for lines in
an event series not created by a product (e.g. shipping or
cancellation fees) as well as whenever the respective (sub)event
has no location set.
├ attendee_name string Attendee name at time of invoice creation. Can be ``null`` if no
name was set or if names are configured to not be added to invoices.
├ gross_value money (string) Price including taxes
@@ -116,10 +110,6 @@ internal_reference string Customer's refe
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
.. versionchanged:: 4.1
The attribute ``lines.event_location`` has been added.
Endpoints
---------
@@ -189,7 +179,6 @@ Endpoints
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",
"event_date_to": null,
"event_location": "Heidelberg",
"attendee_name": null,
"gross_value": "23.00",
"tax_value": "0.00",
@@ -278,7 +267,6 @@ Endpoints
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",
"event_date_to": null,
"event_location": "Heidelberg",
"attendee_name": null,
"gross_value": "23.00",
"tax_value": "0.00",

View File

@@ -25,21 +25,7 @@ description multi-lingual string A public descri
Markdown syntax or can be ``null``.
position integer An integer, used for sorting
require_membership boolean If ``true``, booking this variation requires an active membership.
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
sales_channels list of strings Sales channels this variation is available on, such as
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the item to be
available.
available_from datetime The first date time at which this variation can be bought
(or ``null``).
available_until datetime The last date time at which this variation can be bought
(or ``null``).
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
===================================== ========================== =======================================================
Endpoints
@@ -77,12 +63,7 @@ Endpoints
},
"active": true,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": {
"en": "Test2"
},
@@ -98,7 +79,6 @@ Endpoints
},
"active": true,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"description": {},
"position": 1,
@@ -148,12 +128,7 @@ Endpoints
"original_price": null,
"active": true,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
}
@@ -184,12 +159,7 @@ Endpoints
"default_price": "10.00",
"active": true,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
}
@@ -210,12 +180,7 @@ Endpoints
"original_price": null,
"active": true,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
}
@@ -267,12 +232,7 @@ Endpoints
"original_price": null,
"active": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
}

View File

@@ -70,8 +70,6 @@ require_approval boolean If ``true``, or
paid.
require_bundling boolean If ``true``, this item is only available as part of bundles.
require_membership boolean If ``true``, booking this item requires an active membership.
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this product will
be hidden from users without a valid membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will
create a membership of the given type.
@@ -107,22 +105,8 @@ variations list of objects A list with one
├ active boolean If ``false``, this variation will not be sold or shown.
├ description multi-lingual string A public description of the variation. May contain
├ require_membership boolean If ``true``, booking this variation requires an active membership.
├ require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
Markdown syntax or can be ``null``.
├ sales_channels list of strings Sales channels this variation is available on, such as
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the item to be
available.
├ available_from datetime The first date time at which this variation can be bought
(or ``null``).
├ available_until datetime The last date time at which this variation can be bought
(or ``null``).
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
└ position integer An integer, used for sorting
addons list of objects Definition of add-ons that can be chosen for this item.
Only writable during creation,
@@ -159,10 +143,6 @@ meta_data object Values set for
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
``grant_membership_duration_days`` and ``grant_membership_duration_months`` have been added.
.. versionchanged:: 4.4
The attributes ``require_membership_hidden`` attribute has been added.
Notes
-----
@@ -250,10 +230,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
},
@@ -265,10 +241,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
}
@@ -365,10 +337,6 @@ Endpoints
"require_membership": false,
"require_membership_types": [],
"description": null,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"position": 0
},
{
@@ -379,10 +347,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
}
@@ -458,10 +422,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
},
@@ -473,10 +433,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
}
@@ -541,10 +497,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
},
@@ -556,10 +508,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
}
@@ -655,10 +603,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
},
@@ -670,10 +614,6 @@ Endpoints
"active": true,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
}

View File

@@ -128,14 +128,6 @@ last_modified datetime Last modificati
The ``custom_followup_at`` attribute has been added.
.. versionchanged:: 4.4
The ``item`` and ``variation`` query parameters have been added.
.. versionchanged:: 4.6
The ``subevent`` query parameters has been added.
.. _order-position-resource:
@@ -423,8 +415,6 @@ List of all orders
: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 string search: Only return orders matching a given search query
:query integer item: Only return orders with a position that contains this item ID. *Warning:* Result will also include orders if they contain mixed items, and it will even return orders where the item is only contained in a canceled position.
:query integer variation: Only return orders with a position that contains this variation ID. *Warning:* Result will also include orders if they contain mixed items and variations, and it will even return orders where the variation is only contained in a canceled position.
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
``require_approval`` will be returned.
@@ -437,7 +427,6 @@ List of all orders
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.
:query integer subevent: Only return orders with a position that contains this subevent ID. *Warning:* Result will also include orders if they contain mixed subevents, and it will even return orders where the subevent is only contained in a canceled position.
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.

View File

@@ -1,281 +0,0 @@
Automated email rules
=====================
Resource description
--------------------
Automated email rules that specify emails that the system will send automatically at a specific point in time, e.g.
the day of the event.
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the rule
enabled boolean If ``false``, the rule is ignored
subject multi-lingual string The subject of the email
template multi-lingual string The body of the email
all_products boolean If ``true``, the email is sent to buyers of all products
limit_products list of integers List of product IDs, if ``all_products`` is not set
include_pending boolean If ``true``, the email is sent to pending orders. If ``false``,
only paid orders are considered.
date_is_absolute boolean If ``true``, the email is set at a specific point in time.
send_date datetime If ``date_is_absolute`` is set: Date and time to send the email.
send_offset_days integer If ``date_is_absolute`` is not set, this is the number of days
before/after the email is sent.
send_offset_time time If ``date_is_absolute`` is not set, this is the time of day the
email is sent on the day specified by ``send_offset_days``.
offset_to_event_end boolean If ``true``, ``send_offset_days`` is relative to the event end
date. Otherwise it is relative to the event start date.
offset_is_after boolean If ``true``, ``send_offset_days`` is the number of days **after**
the event start or end date. Otherwise it is the number of days
**before**.
send_to string Can be ``"orders"`` if the email should be sent to customers
(one email per order),
``"attendees"`` if the email should be sent to every attendee,
or ``"both"``.
date. Otherwise it is relative to the event start date.
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/
Returns a list of all rules configured for an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"enabled": true,
"subject": {"en": "See you tomorrow!"},
"template": {"en": "Don't forget your tickets, download them at {url}"},
"all_products": true,
"limit_products": [],
"include_pending": false,
"send_date": null,
"send_offset_days": 1,
"send_offset_time": "18:00",
"date_is_absolute": false,
"offset_to_event_end": false,
"offset_is_after": false,
"send_to": "orders"
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
Returns information on one rule, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"enabled": true,
"subject": {"en": "See you tomorrow!"},
"template": {"en": "Don't forget your tickets, download them at {url}"},
"all_products": true,
"limit_products": [],
"include_pending": false,
"send_date": null,
"send_offset_days": 1,
"send_offset_time": "18:00",
"date_is_absolute": false,
"offset_to_event_end": false,
"offset_is_after": false,
"send_to": "orders"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the rule to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/
Create a new rule.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
{
"enabled": true,
"subject": {"en": "See you tomorrow!"},
"template": {"en": "Don't forget your tickets, download them at {url}"},
"all_products": true,
"limit_products": [],
"include_pending": false,
"send_date": null,
"send_offset_days": 1,
"send_offset_time": "18:00",
"date_is_absolute": false,
"offset_to_event_end": false,
"offset_is_after": false,
"send_to": "orders"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"enabled": true,
"subject": {"en": "See you tomorrow!"},
"template": {"en": "Don't forget your tickets, download them at {url}"},
"all_products": true,
"limit_products": [],
"include_pending": false,
"send_date": null,
"send_offset_days": 1,
"send_offset_time": "18:00",
"date_is_absolute": false,
"offset_to_event_end": false,
"offset_is_after": false,
"send_to": "orders"
}
:param organizer: The ``slug`` field of the organizer to create a rule for
:param event: The ``slug`` field of the event to create a rule for
:statuscode 201: no error
:statuscode 400: The rule 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 rules.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
Update a rule. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 34
{
"enabled": false,
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 1,
"enabled": false,
"subject": {"en": "See you tomorrow!"},
"template": {"en": "Don't forget your tickets, download them at {url}"},
"all_products": true,
"limit_products": [],
"include_pending": false,
"send_date": null,
"send_offset_days": 1,
"send_offset_time": "18:00",
"date_is_absolute": false,
"offset_to_event_end": false,
"offset_is_after": false,
"send_to": "orders"
}
: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 rule to modify
:statuscode 200: no error
:statuscode 400: The rule could not be modified due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
Delete a rule.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/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 rule to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this rule cannot be deleted since it is currently in use.

View File

@@ -1,28 +0,0 @@
.. spelling: libpretixsync
Check-in algorithms
===================
When a ticket is scanned at the entrance or exit of an event, we follow a series of steps to determine whether
the check-in is allowed or not. To understand some of the terms in the following diagrams, you should also check
out the documentation of the :ref:`ticket redemption API endpoint <rest-checkin-redeem>`.
Server-side
-----------
The following diagram shows the series of checks executed on the server when a ticket is redeemed through the API.
Some simplifications have been made, for example the deduplication mechanism based on the ``nonce`` parameter
to prevent re-uploads of the same scan is not shown.
.. image:: /images/checkin_online.png
Client-side
-----------
The process of verifying tickets offline is a little different. There are two different approaches,
depending on whether we have information about all tickets in the local database. The following diagram shows
the algorithm as currently implemented in recent versions of `libpretixsync`_.
.. image:: /images/checkin_offline.png
.. _libpretixsync: https://github.com/pretix/libpretixsync

View File

@@ -1,13 +0,0 @@
Algorithms
==========
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very important
when working on features close to them, or because they also need to be re-implemented by client-side components like our
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
.. toctree::
:maxdepth: 2
checkin
layouts

View File

@@ -1,15 +0,0 @@
.. spelling: pretixPOS
Ticket layout
=============
When a ticket is exported to PDF, the system needs to decide which of multiple PDF layouts to use. The
following diagram shows the steps of the decision, showing both the implementation in pretix itself as
well as the implementation in `pretixPOS`_.
The process can be influenced by plugins, which is demonstrated with the example of the shipping plugin.
.. image:: /images/ticket_layouts.png
.. _pretixPOS: https://pretix.eu/about/en/pos

View File

@@ -1,119 +0,0 @@
.. highlight:: python
:linenothreshold: 5
.. _`cookieconsent`:
Handling cookie consent
=======================
pretix includes an optional feature to handle cookie consent explicitly to comply with EU regulations.
If your plugin sets non-essential cookies or includes a third-party service that does so, you should
integrate with this feature.
Server-side integration
-----------------------
First, you need to declare that you are using non-essential cookies by responding to the following
signal:
.. automodule:: pretix.presale.signals
:members: register_cookie_providers
You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:
.. class:: pretix.presale.cookies.CookieProvider
.. py:attribute:: CookieProvider.identifier
A short and unique identifier used to distinguish this cookie provider form others (required).
.. py:attribute:: CookieProvider.provider_name
A human-readable name of the entity of feature responsible for setting the cookie (required).
.. py:attribute:: CookieProvider.usage_classes
A list of enum values from the ``pretix.presale.cookies.UsageClass`` enumeration class, such as
``UsageClass.ANALYTICS``, ``UsageClass.MARKETING``, or ``UsageClass.SOCIAL`` (required).
.. py:attribute:: CookieProvider.privacy_url
A link to a privacy policy (optional).
Here is an example of such a receiver:
.. code-block:: python
@receiver(register_cookie_providers)
def recv_cookie_providers(sender, request, **kwargs):
return [
CookieProvider(
identifier='google_analytics',
provider_name='Google Analytics',
usage_classes=[UsageClass.ANALYTICS],
)
]
JavaScript-side integration
---------------------------
The server-side integration only causes the cookie provider to show up in the cookie dialog. You still
need to care about actually enforcing the consent state.
You can access the consent state through the ``window.pretix.cookie_consent`` variable. Whenever the
value changes, a ``pretix:cookie-consent:change`` event is fired on the ``document`` object.
The variable will generally have one of the following states:
.. rst-class:: rest-resource-table
================================================================ =====================================================
State Interpretation
================================================================ =====================================================
``pretix === undefined || pretix.cookie_consent === undefined`` Your JavaScript has loaded before the cookie consent
script. Wait for the event to be fired, then try again,
do not yet set a cookie.
``pretix.cookie_consent === null`` The cookie consent mechanism has not been enabled. This
usually means that you can set cookies however you like.
``pretix.cookie_consent[identifier] === undefined`` The cookie consent mechanism is loaded, but has no data
on your cookie yet, wait for the event to be fired, do not
yet set a cookie.
``pretix.cookie_consent[identifier] === true`` The user has consented to your cookie.
``pretix.cookie_consent[identifier] === false`` The user has actively rejected your cookie.
================================================================ =====================================================
If you are integrating e.g. a tracking provider with native cookie consent support such
as Facebook's Pixel, you can integrate it like this:
.. code-block:: javascript
var consent = (window.pretix || {}).cookie_consent;
if (consent !== null && !(consent || {}).facebook) {
fbq('consent', 'revoke');
}
fbq('init', ...);
document.addEventListener('pretix:cookie-consent:change', function (e) {
fbq('consent', (e.detail || {}).facebook ? 'grant' : 'revoke');
})
If you have a JavaScript function that you only want to load if consent for a specific ``identifier``
is given, you can wrap it like this:
.. code-block:: javascript
var consent_identifier = "youridentifier";
var consent = (window.pretix || {}).cookie_consent;
if (consent === null || (consent || {})[consent_identifier] === true) {
// Cookie consent tool is either disabled or consent is given
addScriptElement(src);
return;
}
// Either cookie consent tool has not loaded yet or consent is not given
document.addEventListener('pretix:cookie-consent:change', function onChange(e) {
var consent = e.detail || {};
if (consent === null || consent[consent_identifier] === true) {
addScriptElement(src);
document.removeEventListener('pretix:cookie-consent:change', onChange);
}
})

View File

@@ -17,7 +17,6 @@ Contents:
shredder
import
customview
cookieconsent
auth
general
quality

View File

@@ -62,8 +62,6 @@ The provider class
.. autoattribute:: public_name
.. autoattribute:: confirm_button_name
.. autoattribute:: is_enabled
.. autoattribute:: priority

View File

@@ -1,11 +1,6 @@
.. spelling:: Rebase rebasing
Coding style and quality
========================
Code
----
* Basically, we want all python code to follow the `PEP 8`_ standard. There are a few exceptions where
we see things differently or just aren't that strict. The ``setup.cfg`` file in the project's source
folder contains definitions that allow `flake8`_ to check for violations automatically. See :ref:`checksandtests`
@@ -25,62 +20,8 @@ Code
test suite are in the style of Python's unit test module. If you extend those files, you might continue in this style,
but please use ``pytest`` style for any new test files.
Commits and Pull Requests
-------------------------
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
individual commits, we use "Rebase and merge" instead. Merge commits should be avoided.
* The commit message should start with a single subject line and can optionally be followed by a commit message body.
* The subject line should be the shortest possible representation of what the commit changes. Someone who reviewed
the commit should able to immediately remember the commit in a couple of weeks based on the subject line and tell
it apart from other commits.
* If there's additional useful information that we should keep, such as reasoning behind the commit, you can
add a longer body, separated from the first line by a blank line.
* The body should explain **what** you changed and more importantly **why** you changed it. There's no need to iterate
**how** you changed something.
* The subject line should be capitalized ("Add new feature" instead of "add new feature") and should not end with a period
("Add new feature" instead of "Add new feature.")
* The subject line should be written in imperative mood, as if you were giving a command what the computer should do if the
commit is applied. This is how generated commit messages by git itself are already written ("Merge branch …", "Revert …")
and makes for short and consistent messages.
* Good: "Fix typo in template"
* Good: "Add Chinese translation"
* Good: "Remove deprecated method"
* Good: "Bump version to 4.4.0"
* Bad: "Fixed bug with …"
* Bad: "Fixes bug with …"
* Bad: "Fixing bug …"
* If all changes in your commit are in context of a single feature or e.g. a bundled plugin, it makes sense to prefix the
subject line with the name of that feature. Examples:
* "API: Add support for PATCH on customers"
* "Docs: Add chapter on alpaca feeding"
* "Stripe: Fix duplicate payments"
* "Order change form: Fix incorrect validation"
* If your commit references a GitHub issue that is fully resolved by your commit, start your subject line with the issue
ID in the form of "Fix #1234 -- Crash in order list". In this case, you can omit the verb "Fix" at the beginning of the
second part of the message to avoid repetition of the word "fix". If your commit only partially resolves the issue, use
"Refs #1234 -- Crash in order list" instead.
* Applies to pretix employees only: If your commit references a sentry issue, please put it in parentheses at the end
of the subject line or inside the body ("Fix crash in order list (PRETIXEU-ABC)"). If your commit references a support
ticket, please put it in parentheses at the end of the subject line with a "Z#" prefix ("Fix crash in order list (Z#12345)").
* If your PR was open for a while and might cause conflicts on merge, please prefer rebasing it (``git rebase -i master``)
over merging ``master`` into your branch unless it is prohibitively complicated.
* Please keep the first line of your commit messages short. When referencing an issue, please phrase it like
``Fix #123 -- Problems with order creation`` or ``Refs #123 -- Fix this part of that bug``.
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/

View File

@@ -92,9 +92,6 @@ Carts and Orders
.. autoclass:: pretix.base.models.OrderRefund
:members:
.. autoclass:: pretix.base.models.Transaction
:members:
.. autoclass:: pretix.base.models.CartPosition
:members:

View File

@@ -8,7 +8,6 @@ Developer documentation
setup
contribution/index
implementation/index
algorithms/index
translation/index
api/index
structure
translation/index

View File

@@ -26,7 +26,7 @@ Your should install the following on your system:
* ``libssl`` (Debian package: ``libssl-dev``)
* ``libxml2`` (Debian package ``libxml2-dev``)
* ``libxslt`` (Debian package ``libxslt1-dev``)
* ``libenchant-2-2`` (Debian package ``libenchant-2-2``)
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
* ``msgfmt`` (Debian package ``gettext``)
* ``git``
@@ -51,12 +51,7 @@ the dependencies might fail::
Working with the code
---------------------
If you do not have a recent installation of ``nodejs``, install it now::
curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
sudo apt install nodejs
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
The first thing you need are all the main application's dependencies::
cd src/
pip3 install -e ".[dev]"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -1,146 +0,0 @@
@startuml
partition "data-based check" {
"Check based on local database" --> "Is the order in status PAID or PENDING\nand is the position not canceled?"
--> if "" then
-right->[no] "Return error CANCELED"
else
-down->[yes] "Is the product part of the check-in list?"
--> if "" then
-right->[no] "Return error PRODUCT"
else
-down->[yes] "Is the subevent part of the check-in list?"
--> if "" then
-right->[no] "Return error INVALID"
note bottom: TODO\ninconsistent\nwith online\ncheck
else
-down->[yes] "Is the order in status PAID?"
--> if "" then
-right->[no] "Does the check-in list include pending orders?"
--> if "" then
-right->[no] "Return error UNPAID "
else
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
--> if "" then
-right->[no] "Return error UNPAID "
else
-down->[yes] "Is this an entry or exit?"
endif
endif
else
-down->[yes] "Is this an entry or exit?"
endif
endif
endif
endif
"Is this an entry or exit?" --> if "" then
-right->[entry] Evaluate custom logic (rules)
--> if "" then
-right->[error] "Return error RULES"
else
-down->[ok] "Are all required questions answered?"
--> if "" then
-right->[no] "Return error INCOMPLETE"
else
-down->[yes] "Does the check-in list allow multi-entry?"
endif
endif
else
-->[exit] "Return OK "
endif
"Does the check-in list allow multi-entry?" --> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Is this the first checkin\nfor this ticket on this list?"
--> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Are all previous checkins\nfor this ticket on this list exits?"
--> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last checkin\nan exit?"
--> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Return error ALREADY_REDEEMED"
endif
endif
endif
endif
}
partition "dataless check" {
"Check based on secret content" --> "Does the secret decode with\nany supported scheme\nand has a valid signature?"
--> if "" then
-down->[yes] "Is the ticket secret on the revocation list?"
--> if "" then
-right->[yes] "Return error REVOKED"
else
-down->[no] "Is the product part of the check-in list? "
--> if "" then
-right->[no] "Return error PRODUCT "
else
-down->[yes] "Is the subevent part of the check-in list? "
--> if "" then
-right->[no] "Return error INVALID "
note bottom: TODO\ninconsistent\nwith online\ncheck
else
--> "Is this an entry or exit? "
endif
endif
endif
else
-right>[no] "Return error INVALID "
endif
"Is this an entry or exit? " --> if "" then
-right->[entry] "Evaluate custom logic (rules) "
--> if "" then
-right->[error] "Return error RULES "
else
-down->[ok] "Are all required questions answered? "
--> if "" then
-right->[no] "Return error INCOMPLETE "
else
-down->[yes] "Does the check-in list allow multi-entry? "
endif
endif
else
-->[exit] " Return OK "
endif
"Does the check-in list allow multi-entry? " --> if "" then
-right->[yes] " Return OK "
else
-down->[no] "Are any locally queued checkins for\nthis ticket of this list known?"
--> if "" then
-right->[no] " Return OK "
else
-down->[yes] "Are all locally queued checkins\nfor this ticket on this list exits? "
--> if "" then
-right->[yes] " Return OK "
else
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last locally\nqueued checkin\nan exit? "
--> if "" then
-right->[yes] " Return OK "
else
-down->[no] "Return error ALREADY_REDEEMED "
endif
endif
endif
endif
}
(*) --> "Check if order position with\nscanned ticket secret exists"
--> if "" then
-down->[yes] "Check based on local database"
else
-->[no] "Check based on secret content"
endif
@enduml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -1,92 +0,0 @@
@startuml
(*) --> "Check if order position with\nscanned ticket secret exists"
--> if "" then
-down->[yes] ===CHECK===
else
-->[no] "Check if secret exists\nin revocation list"
--> if "" then
--> "Is this a forced upload?"
--> if "" then
-->[yes] ===CHECK===
else
-right->[no] "Return error REVOKED"
endif
else
-right->[no] "Return error INVALID"
endif
endif
===CHECK=== -down-> "Is the order in status PAID or PENDING\nand is the position not canceled?"
--> if "" then
-right->[no] "Return error CANCELED"
else
-down->[yes] "Is the product part of the check-in list?"
--> if "" then
-right->[no] "Return error PRODUCT"
else
-down->[yes] "Is the subevent part of the check-in list?"
--> if "" then
-right->[no] "Return error PRODUCT "
else
-down->[yes] "Is the order in status PAID\nor is this a forced upload?"
--> if "" then
-right->[no] "Does the check-in list include pending orders?"
--> if "" then
-right->[no] "Return error UNPAID "
else
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
--> if "" then
-right->[no] "Return error UNPAID "
else
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
endif
endif
else
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
endif
endif
endif
endif
"Is this an entry or exit?\nIs the upload forced?" --> if "" then
-right->[entry && not force] Evaluate custom logic (rules)
--> if "" then
-right->[error] "Return error RULES"
else
-down->[ok] "Are all required questions answered?"
--> if "" then
-right->[no && questions_supported] "Return error INCOMPLETE"
else
-down->[yes || not questions_supported] "Does the check-in list allow multi-entry?"
endif
endif
else
-->[exit || force=true] "Return OK "
endif
"Does the check-in list allow multi-entry?" --> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Is this the first checkin\nfor this ticket on this list?"
--> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Are all previous checkins\nfor this ticket on this list exits?"
--> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last checkin\nan exit?"
--> if "" then
-right->[yes] "Return OK"
else
-down->[no] "Return error ALREADY_REDEEMED"
endif
endif
endif
endif
@enduml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -1,52 +0,0 @@
@startuml
(*) --> "Which implementation?"
--> if "" then
-down->[pretixPOS] "Check for TicketLayoutItem with\nsales_channel=pretixpos [libpretixsync]"
--> if "" then
--> (*)
else
-->[not found] "Check for TicketLayoutItem with\nsales_channel=web [libpretixsync]"
--> if "" then
--> (*)
else
-->[not found] "Use event default [libpretixsync]"
--> (*)
endif
endif
else
-right->[pretix] "Check for TicketLayoutItem with\nsales_channel=order.sales_channel"
--> if "" then
-right-> "Run override_layout plugin signal on result"
else
-down->[not found] "Check for TicketLayoutItem with\nsales_channel=web"
--> if "" then
--> "Run override_layout plugin signal on result"
else
-->[not found] "Use event default"
--> "Run override_layout plugin signal on result"
endif
endif
endif
"Run override_layout plugin signal on result" -> (*)
partition pretix_shipping {
"Run override_layout plugin signal on result" --> "Check for ShippingLayoutItem with\nmethod=order.shipping_method"
--> if "" then
--> (*)
else
-down->[not found] "Check for ShippingMethod.layout"
--> if "" then
--> (*)
else
-down->[not found] "Keep original layout"
--> (*)
endif
endif
}
@enduml

View File

@@ -1,64 +0,0 @@
Certificates of attendance
==========================
The certificates plugin provides a HTTP API that allows you to download the certificate for a specific attendee.
Certificate download
--------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/certificate/
Downloads the certificate for one order position, identified by its internal ID. Download is a two-step
process. You will always get a :http:statuscode:`303` response with a ``Location`` header to a different
URL. In the background, our server starts preparing the PDF file.
If you then do a ``GET`` to the URL you were given, you will either receive a :http:statuscode:`409` response
indicating to retry after a few seconds, or a :http:statuscode:`200` response with the PDF file.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 303 See Other
Location: /api/v1/organizers/democon/events/3vjrh/orderpositions/426/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/pdf
...
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the order position to fetch
:statuscode 200: File ready for download
:statuscode 303: Processing started
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource
**or** downloads are not available for this order position at this time. The response content will
contain more details.
:statuscode 404: The requested order position or download provider does not exist.
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.

View File

@@ -15,6 +15,5 @@ If you want to **create** a plugin, please go to the
ticketoutputpdf
badges
campaigns
certificates
digital
webinar

View File

@@ -17,7 +17,6 @@ bic
BIC
boolean
booleans
bugfix
cancelled
casted
Ceph
@@ -78,7 +77,6 @@ mixin
mixins
multi
multidomain
multiplicator
namespace
namespaced
namespaces

View File

@@ -4,7 +4,8 @@ Embeddable Widget
=================
If you want to show your ticket shop on your event website or blog, you can use our JavaScript widget. This way,
users will not need to leave your site to buy their ticket in most cases.
users will not need to leave your site to buy their ticket in most cases. The widget will still open a new tab
for the checkout if the user is on a mobile device.
To obtain the correct HTML code for embedding your event into your website, we recommend that you go to the "Widget"
tab of your event's settings. You can specify some optional settings there (for example the language of the widget)
@@ -309,10 +310,6 @@ Currently, the following attributes are understood by pretix itself:
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
on this for authentication.
* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
Hosted or pretix Enterprise are active, you can pass the following fields:

View File

@@ -34,7 +34,5 @@ git push
# Unlock Weblate
for c in $COMPONENTS; do
wlc unlock $c;
done
for c in $COMPONENTS; do
wlc pull $c;
done

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.6.0.dev0"
__version__ = "4.1.0"

View File

@@ -70,38 +70,9 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
)
class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
identifier = 'pretixscan_online_kiosk'
verbose_name = _('pretixSCAN (kiosk mode, no order sync, no search)')
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
('GET', 'api-v1:event-list'),
('GET', 'api-v1:event-detail'),
('GET', 'api-v1:subevent-list'),
('GET', 'api-v1:subevent-detail'),
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
('GET', 'api-v1:checkinlist-status'),
('POST', 'api-v1:checkinlist-failed_checkins'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
)
class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
identifier = 'pretixscan_online_noorders'
verbose_name = _('pretixSCAN (online only, no order sync)')
identifier = 'pretixscan_online_kiosk'
verbose_name = _('pretixSCAN (kiosk mode, online only)')
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
@@ -120,7 +91,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlist-list'),
('GET', 'api-v1:checkinlist-status'),
('POST', 'api-v1:checkinlist-failed_checkins'),
('GET', 'api-v1:checkinlistpos-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
@@ -163,7 +133,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:orderrefund-list'),
('POST', 'api-v1:orderrefund-done'),
('POST', 'api-v1:cartposition-list'),
('POST', 'api-v1:cartposition-bulk-create'),
('GET', 'api-v1:checkinlist-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
@@ -191,7 +160,6 @@ DEVICE_SECURITY_PROFILES = {
FullAccessSecurityProfile,
PretixScanSecurityProfile,
PretixScanNoSyncSecurityProfile,
PretixScanNoSyncNoSearchSecurityProfile,
PretixPosSecurityProfile,
)
}

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.2 on 2021-07-05 07:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixapi', '0005_auto_20191028_1541'),
]
operations = [
migrations.AlterField(
model_name='webhook',
name='target_url',
field=models.URLField(max_length=255),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.4 on 2021-09-15 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixapi', '0006_alter_webhook_target_url'),
]
operations = [
migrations.AlterField(
model_name='webhookcall',
name='target_url',
field=models.URLField(max_length=255),
),
]

View File

@@ -95,7 +95,7 @@ class OAuthRefreshToken(AbstractRefreshToken):
class WebHook(models.Model):
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
target_url = models.URLField(verbose_name=_("Target URL"))
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
@@ -120,7 +120,7 @@ class WebHookEventListener(models.Model):
class WebHookCall(models.Model):
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
datetime = models.DateTimeField(auto_now_add=True)
target_url = models.URLField(max_length=255)
target_url = models.URLField()
action_type = models.CharField(max_length=255)
is_retry = models.BooleanField(default=False)
execution_time = models.FloatField(null=True)

View File

@@ -1,27 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from rest_framework.pagination import PageNumberPagination
class Pagination(PageNumberPagination):
page_size_query_param = 'page_size'
max_page_size = 50

View File

@@ -73,61 +73,53 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
if validated_data.get('variation')
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
if len(new_quotas) == 0:
raise ValidationError(
gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(validated_data.get('item'))
)
)
for quota in new_quotas:
avail = quota.availability(_cache=self.context['quota_cache'])
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
with self.context['event'].lock():
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
if validated_data.get('variation')
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
if len(new_quotas) == 0:
raise ValidationError(
gettext_lazy('There is not enough quota available on quota "{}" to perform '
'the operation.').format(
quota.name
gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(validated_data.get('item'))
)
)
for quota in new_quotas:
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
raise ValidationError(
gettext_lazy('There is not enough quota available on quota "{}" to perform '
'the operation.').format(
quota.name
)
)
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
for quota in new_quotas:
oldsize = self.context['quota_cache'][quota.pk][1]
newsize = oldsize - 1 if oldsize is not None else None
self.context['quota_cache'][quota.pk] = (
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
newsize
)
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
except Seat.MultipleObjectsReturned:
raise ValidationError('The specified seat ID is not unique.')
else:
validated_data['seat'] = seat
if not seat.is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
):
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
except Seat.MultipleObjectsReturned:
raise ValidationError('The specified seat ID is not unique.')
else:
validated_data['seat'] = seat
if not seat.is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
):
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
validated_data.pop('sales_channel')
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
validated_data.pop('sales_channel')
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')

View File

@@ -54,7 +54,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
from pretix.base.settings import LazyI18nStringList, validate_event_settings
from pretix.base.settings import validate_event_settings
from pretix.base.signals import api_event_settings_fields
logger = logging.getLogger(__name__)
@@ -704,7 +704,6 @@ class EventSettingsSerializer(SettingsSerializer):
'payment_term_accept_late',
'payment_explanation',
'payment_pending_hidden',
'mail_days_order_expire_warning',
'ticket_download',
'ticket_download_date',
'ticket_download_addons',
@@ -734,7 +733,6 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_numbers_prefix_cancellations',
'invoice_numbers_counter_length',
'invoice_attendee_name',
'invoice_event_location',
'invoice_include_expire_date',
'invoice_address_explanation_text',
'invoice_email_attachment',
@@ -764,7 +762,6 @@ class EventSettingsSerializer(SettingsSerializer):
'cancel_allow_user_paid_refund_as_giftcard',
'cancel_allow_user_paid_require_approval',
'change_allow_user_variation',
'change_allow_user_addons',
'change_allow_user_until',
'change_allow_user_price',
'primary_color',
@@ -792,10 +789,6 @@ class EventSettingsSerializer(SettingsSerializer):
data = super().validate(data)
settings_dict = self.instance.freeze()
settings_dict.update(data)
if data.get('confirm_texts') is not None:
data['confirm_texts'] = LazyI18nStringList(data['confirm_texts'])
validate_event_settings(self.event, settings_dict)
return data

View File

@@ -31,10 +31,9 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import os.path
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import QuerySet
@@ -59,8 +58,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
'require_membership', 'require_membership_types',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -75,8 +73,7 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
'require_membership', 'require_membership_types',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -164,7 +161,7 @@ class ItemSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif'
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
), max_size=10 * 1024 * 1024)
class Meta:
model = Item
@@ -175,7 +172,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
'require_membership', 'require_membership_types', 'grant_membership_type',
'grant_membership_duration_like_event', 'grant_membership_duration_days',
'grant_membership_duration_months')
read_only_fields = ('has_variations',)
@@ -248,13 +245,10 @@ class ItemSerializer(I18nAwareModelSerializer):
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
item = Item.objects.create(**validated_data)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', [])
require_membership_types = variation_data.pop('require_membership_types')
v = ItemVariation.objects.create(item=item, **variation_data)
if require_membership_types:
v.require_membership_types.add(*require_membership_types)
@@ -275,10 +269,7 @@ class ItemSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
item = super().update(instance, validated_data)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
# Meta data
if meta_data is not None:

View File

@@ -26,7 +26,6 @@ from collections import Counter, defaultdict
from decimal import Decimal
import pycountry
from django.conf import settings
from django.core.files import File
from django.db.models import F, Q
from django.utils.timezone import now
@@ -192,7 +191,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
)
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
if cf.file.size > 10 * 1024 * 1024:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
data['options'] = []
@@ -1404,7 +1403,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
state=OrderPayment.PAYMENT_STATE_CREATED
)
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values())
return order
@@ -1428,7 +1426,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
model = InvoiceLine
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
'fee_internal_type', 'event_location')
'fee_internal_type')
class InvoiceSerializer(I18nAwareModelSerializer):

View File

@@ -275,7 +275,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
default_fields = [
'customer_accounts',
'customer_accounts_link_by_email',
'invoice_regenerate_allowed',
'contact_mail',
'imprint_url',
'organizer_info_text',
@@ -295,15 +294,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'theme_color_background',
'theme_round_borders',
'primary_font',
'organizer_logo_image_inherit',
'organizer_logo_image',
'privacy_url',
'cookie_consent',
'cookie_consent_dialog_title',
'cookie_consent_dialog_text',
'cookie_consent_dialog_text_secondary',
'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no',
'organizer_logo_image'
]
def __init__(self, *args, **kwargs):

View File

@@ -21,18 +21,14 @@
#
from django.db import transaction
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from rest_framework.settings import api_settings
from pretix.api.serializers.cart import (
CartPositionCreateSerializer, CartPositionSerializer,
)
from pretix.base.models import CartPosition
from pretix.base.services.locking import NoLockManager
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
@@ -54,61 +50,18 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['quota_cache'] = {}
return ctx
def create(self, request, *args, **kwargs):
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic(), self.request.event.lock():
with transaction.atomic():
self.perform_create(serializer)
cp = serializer.instance
serializer = CartPositionSerializer(cp, context=serializer.context)
cp = serializer.instance
serializer = CartPositionSerializer(cp, context=serializer.context)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@action(detail=False, methods=['POST'])
def bulk_create(self, request, *args, **kwargs):
if not isinstance(request.data, list): # noqa
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
ctx = self.get_serializer_context()
with transaction.atomic():
serializers = [
CartPositionCreateSerializer(data=d, context=ctx)
for d in request.data
]
lockfn = self.request.event.lock
if not any(s.is_valid(raise_exception=False) for s in serializers):
lockfn = NoLockManager
results = []
with lockfn():
for s in serializers:
if s.is_valid(raise_exception=False):
try:
cp = s.save()
except ValidationError as e:
results.append({
'success': False,
'data': None,
'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
})
else:
results.append({
'success': True,
'data': CartPositionSerializer(cp, context=ctx).data,
'errors': None,
})
else:
results.append({
'success': False,
'data': None,
'errors': s.errors,
})
return Response({'results': results}, status=status.HTTP_200_OK)
def perform_create(self, serializer):
serializer.save()

View File

@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import django_filters
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import (
@@ -33,7 +32,6 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from packaging.version import parse
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.fields import DateTimeField
@@ -423,20 +421,13 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
nonce=nonce,
forced=force,
)
raw_barcode_for_checkin = None
try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
if self.kwargs['pk'].isnumeric():
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
# `id`, however, is part of a path where this technically is not allowed. Old versions of our
# scan apps still do it, so we try work around it!
try:
op = queryset.get(secret=self.kwargs['pk'])
except OrderPosition.DoesNotExist:
op = queryset.get(secret=self.kwargs['pk'].replace('+', ' '))
op = queryset.get(secret=self.kwargs['pk'])
except OrderPosition.DoesNotExist:
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
if len(revoked_matches) == 0:
@@ -464,41 +455,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
error_reason=Checkin.REASON_INVALID,
**common_checkin_args,
)
if force and isinstance(self.request.auth, Device):
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
# valid at the time but no longer exists at time of upload, the device would retry to
# upload the same scan over and over again. Since we can't update all devices quickly,
# here's a dirty workaround to make it stop.
try:
brand = self.request.auth.software_brand
ver = parse(self.request.auth.software_version)
legacy_mode = (
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
)
if legacy_mode:
return Response({
'status': 'error',
'reason': Checkin.REASON_ALREADY_REDEEMED,
'reason_explanation': None,
'require_attention': False,
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
}, status=400)
except: # we don't care e.g. about invalid version numbers
pass
return Response({
'detail': 'Not found.', # for backwards compatibility
'status': 'error',
'reason': Checkin.REASON_INVALID,
'reason_explanation': None,
'require_attention': False,
}, status=404)
elif revoked_matches and force:
op = revoked_matches[0].position
raw_barcode_for_checkin = self.kwargs['pk']
raise Http404()
else:
op = revoked_matches[0].position
op.order.log_action('pretix.event.checkin.revoked', data={
@@ -549,8 +506,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
user=self.request.user,
auth=self.request.auth,
type=type,
raw_barcode=raw_barcode_for_checkin,
from_revoked_secret=True,
raw_barcode=None,
)
except RequiredQuestionsError as e:
return Response({
@@ -610,7 +566,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
)
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
if cf.file.size > 10 * 1024 * 1024:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file

View File

@@ -69,7 +69,7 @@ class ExportersMixin:
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
if cf.file:
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
return resp
elif not settings.HAS_CELERY:
return Response(
@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
def exporters(self):
exporters = []
responses = register_data_exporters.send(self.request.event)
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
for ex in sorted([response(self.request.event) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
return exporters
@@ -151,7 +151,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
organizer=self.request.organizer
)
responses = register_multievent_data_exporters.send(self.request.organizer)
for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
for ex in sorted([response(events) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex, events=events)
exporters.append(ex)
return exporters

View File

@@ -92,9 +92,6 @@ with scopes_disabled():
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
subevent_before = django_filters.IsoDateTimeFilter(method='subevent_before_qs')
search = django_filters.CharFilter(method='search_qs')
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id')
class Meta:
model = Order
@@ -217,9 +214,7 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions',
opq.all().prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.all()),
'item', 'variation',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
'seat',
'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
)
)
)
@@ -1456,14 +1451,8 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
if not inv.event.settings.invoice_regenerate_allowed:
raise PermissionDenied('Invoices may not be changed after they are created.')
elif inv.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
elif inv.sent_to_organizer:
raise PermissionDenied('The invoice file has already been exported.')
elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1):
raise PermissionDenied('The invoice file is too old to be regenerated.')
else:
inv = regenerate_invoice(inv)
inv.order.log_action(

View File

@@ -261,7 +261,7 @@ def register_default_webhook_events(sender, **kwargs):
),
ParametrizedEventWebhookEvent(
'pretix.event.deleted',
_('Event deleted'),
_('Event details changed'),
),
ParametrizedSubEventWebhookEvent(
'pretix.subevent.added',

View File

@@ -47,7 +47,6 @@ class PretixBaseConfig(AppConfig):
from . import notifications # NOQA
from . import email # NOQA
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .models import _transactions # NOQA
from django.conf import settings
try:

View File

@@ -82,13 +82,6 @@ class SalesChannel:
"""
return False
@property
def customer_accounts_supported(self) -> bool:
"""
If this property is ``True``, checkout will show the customer login step.
"""
return True
def get_all_sales_channels():
global _ALL_CHANNELS

View File

@@ -25,7 +25,6 @@ from datetime import timedelta
from decimal import Decimal
from itertools import groupby
from smtplib import SMTPResponseException
from typing import TypeVar
import css_inline
from django.conf import settings
@@ -50,23 +49,23 @@ from pretix.base.templatetags.rich_text import markdown_compile_email
logger = logging.getLogger('pretix.base.email')
T = TypeVar("T", bound=EmailBackend)
class CustomSMTPBackend(EmailBackend):
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
try:
backend.open()
backend.connection.ehlo_or_helo_if_needed()
(code, resp) = backend.connection.mail(from_addr, [])
if code != 250:
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
(code, resp) = backend.connection.rcpt('testdummy@pretix.eu')
if (code != 250) and (code != 251):
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
finally:
backend.close()
def test(self, from_addr):
try:
self.open()
self.connection.ehlo_or_helo_if_needed()
(code, resp) = self.connection.mail(from_addr, [])
if code != 250:
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
if (code != 250) and (code != 251):
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
finally:
self.close()
class BaseHTMLMailRenderer:
@@ -463,16 +462,6 @@ def base_placeholders(sender, **kwargs):
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
),
SimpleFunctionalMailTextPlaceholder(
'url_remove', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
event, 'presale:event.waitinglist.remove'
) + '?voucher=' + waiting_list_entry.voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.waitinglist.remove',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalMailTextPlaceholder(
'url', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
@@ -540,22 +529,6 @@ def base_placeholders(sender, **kwargs):
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
),
SimpleFunctionalMailTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_url_list', ['event', 'voucher_list'],
lambda event, voucher_list: ' \n'.join([
build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + c
for c in voucher_list
]),
lambda event: ' \n'.join([
build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + c
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
]),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug,

View File

@@ -47,15 +47,12 @@ from django.db.models import QuerySet
from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, Cell
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES
from pretix.base.models import Event
def excel_safe(val):
if isinstance(val, Cell):
return val
if not isinstance(val, KNOWN_TYPES):
val = str(val)
@@ -73,9 +70,8 @@ class BaseExporter:
This is the base class for all data exporters
"""
def __init__(self, event, organizer, progress_callback=lambda v: None):
def __init__(self, event, progress_callback=lambda v: None):
self.event = event
self.organizer = organizer
self.progress_callback = progress_callback
self.is_multievent = isinstance(event, QuerySet)
if isinstance(event, QuerySet):
@@ -224,13 +220,9 @@ class ListExporter(BaseExporter):
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def prepare_xlsx_sheet(self, ws):
pass
def _render_xlsx(self, form_data, output_file=None):
wb = Workbook(write_only=True)
ws = wb.create_sheet()
self.prepare_xlsx_sheet(ws)
try:
ws.title = str(self.verbose_name)
except:

View File

@@ -324,6 +324,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Tax rate'),
_('Tax name'),
_('Event start date'),
_('Date'),
_('Order code'),
_('E-mail address'),
@@ -347,8 +348,6 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'),
_('Payment providers'),
_('Event end date'),
_('Location'),
]
p_providers = OrderPayment.objects.filter(
@@ -407,9 +406,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
if p and p != 'free'
]),
date_format(l.event_date_to, "SHORT_DATE_FORMAT") if l.event_date_to else "",
l.event_location or "",
])
]
@cached_property

View File

@@ -55,20 +55,16 @@ class JSONExporter(BaseExporter):
'name': str(self.event.organizer.name),
'slug': self.event.organizer.slug
},
'meta_data': self.event.meta_data,
'categories': [
{
'id': category.id,
'name': str(category.name),
'description': str(category.description),
'position': category.position,
'internal_name': category.internal_name
} for category in self.event.categories.all()
],
'items': [
{
'id': item.id,
'position': item.position,
'name': str(item.name),
'internal_name': str(item.internal_name),
'category': item.category_id,
@@ -77,35 +73,13 @@ class JSONExporter(BaseExporter):
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
'admission': item.admission,
'active': item.active,
'sales_channels': item.sales_channels,
'description': str(item.description),
'available_from': item.available_from,
'available_until': item.available_until,
'require_voucher': item.require_voucher,
'hide_without_voucher': item.hide_without_voucher,
'allow_cancel': item.allow_cancel,
'require_bundling': item.require_bundling,
'min_per_order': item.min_per_order,
'max_per_order': item.max_per_order,
'checkin_attention': item.checkin_attention,
'original_price': item.original_price,
'issue_giftcard': item.issue_giftcard,
'meta_data': item.meta_data,
'require_membership': item.require_membership,
'variations': [
{
'id': variation.id,
'active': variation.active,
'price': variation.default_price if variation.default_price is not None else
item.default_price,
'name': str(variation),
'description': str(variation.description),
'position': variation.position,
'require_membership': variation.require_membership,
'sales_channels': variation.sales_channels,
'available_from': variation.available_from,
'available_until': variation.available_until,
'hide_without_voucher': variation.hide_without_voucher,
'name': str(variation)
} for variation in item.variations.all()
]
} for item in self.event.items.select_related('tax_rule').prefetch_related('variations')
@@ -113,13 +87,7 @@ class JSONExporter(BaseExporter):
'questions': [
{
'id': question.id,
'identifier': question.identifier,
'required': question.required,
'question': str(question.question),
'position': question.position,
'hidden': question.hidden,
'ask_during_checkin': question.ask_during_checkin,
'help_text': str(question.help_text),
'type': question.type
} for question in self.event.questions.all()
],
@@ -127,18 +95,7 @@ class JSONExporter(BaseExporter):
{
'code': order.code,
'status': order.status,
'customer': order.customer.identifier if order.customer else None,
'testmode': order.testmode,
'user': order.email,
'email': order.email,
'phone': str(order.phone),
'locale': order.locale,
'comment': order.comment,
'custom_followup_at': order.custom_followup_at,
'require_approval': order.require_approval,
'checkin_attention': order.checkin_attention,
'sales_channel': order.sales_channel,
'expires': order.expires,
'datetime': order.datetime,
'fees': [
{
@@ -151,21 +108,11 @@ class JSONExporter(BaseExporter):
'positions': [
{
'id': position.id,
'positionid': position.positionid,
'item': position.item_id,
'variation': position.variation_id,
'subevent': position.subevent_id,
'seat': position.seat.seat_guid if position.seat else None,
'price': position.price,
'tax_rate': position.tax_rate,
'tax_value': position.tax_value,
'attendee_name': position.attendee_name,
'attendee_email': position.attendee_email,
'company': position.company,
'street': position.street,
'zipcode': position.zipcode,
'country': str(position.country) if position.country else None,
'state': position.state,
'secret': position.secret,
'addon_to': position.addon_to_id,
'answers': [
@@ -177,30 +124,15 @@ class JSONExporter(BaseExporter):
} for position in order.positions.all()
]
} for order in
self.event.orders.all().prefetch_related('positions', 'positions__answers', 'positions__seat', 'customer', 'fees')
self.event.orders.all().prefetch_related('positions', 'positions__answers', 'fees')
],
'quotas': [
{
'id': quota.id,
'size': quota.size,
'subevent': quota.subevent_id,
'items': [item.id for item in quota.items.all()],
'variations': [variation.id for variation in quota.variations.all()],
} for quota in self.event.quotas.all().prefetch_related('items', 'variations')
],
'subevents': [
{
'id': se.id,
'name': str(se.name),
'location': str(se.location),
'date_from': se.date_from,
'date_to': se.date_to,
'date_admission': se.date_admission,
'geo_lat': se.geo_lat,
'geo_lon': se.geo_lon,
'is_public': se.is_public,
'meta_data': se.meta_data,
} for se in self.event.subevents.all()
]
}
}

View File

@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict
from datetime import date, datetime, time
from decimal import Decimal
import dateutil
@@ -43,10 +42,10 @@ from django.db.models import (
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
Q, Subquery, Sum, When,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext as _, gettext_lazy, pgettext
from pretix.base.models import (
@@ -130,7 +129,7 @@ class OrderListExporter(MultiSheetListExporter):
label=_('End event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
'Will also include other dates in case of mixed orders!')
)),
]
@@ -182,43 +181,41 @@ class OrderListExporter(MultiSheetListExporter):
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if not isinstance(date_value, date):
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
filters[f'{rel}datetime__gte'] = datetime_value
annotations['date'] = TruncDate(f'{rel}datetime')
filters['date__gte'] = date_value
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if not isinstance(date_value, date):
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
filters[f'{rel}datetime__lte'] = datetime_value
annotations['date'] = TruncDate(f'{rel}datetime')
filters['date__lte'] = date_value
if form_data.get('event_date_from'):
date_value = form_data.get('event_date_from')
if not isinstance(date_value, date):
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
annotations['event_date_max'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_max__gte'] = datetime_value
filters['event_date_max__gte'] = date_value
if form_data.get('event_date_to'):
date_value = form_data.get('event_date_to')
if not isinstance(date_value, date):
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
annotations['event_date_min'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_min__lte'] = datetime_value
filters['event_date_min__lte'] = date_value
if filters:
return qs.annotate(**annotations).filter(**filters)
@@ -873,78 +870,6 @@ class QuotaListExporter(ListExporter):
return '{}_quotas'.format(self.event.slug)
def generate_GiftCardTransactionListExporter(organizer): # hackhack
class GiftcardTransactionListExporter(ListExporter):
identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions')
@property
def additional_form_fields(self):
d = [
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
]
d = OrderedDict(d)
return d
def iterate_list(self, form_data):
qs = GiftCardTransaction.objects.filter(
card__issuer=organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
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(
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
)
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(
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
)
headers = [
_('Gift card code'),
_('Test mode'),
_('Date'),
_('Amount'),
_('Currency'),
_('Order'),
]
yield headers
for obj in qs:
row = [
obj.card.secret,
_('TEST MODE') if obj.card.testmode else '',
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
obj.value,
obj.card.currency,
obj.order.full_code if obj.order else None,
]
yield row
def get_filename(self):
return '{}_giftcardtransactions'.format(organizer.slug)
return GiftcardTransactionListExporter
class GiftcardRedemptionListExporter(ListExporter):
identifier = 'giftcardredemptionlist'
verbose_name = gettext_lazy('Gift card redemptions')
@@ -1137,8 +1062,3 @@ def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
return generate_GiftCardListExporter(sender)
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardtransactionlist")
def register_multievent_i_giftcardtransactionlist_exporter(sender, **kwargs):
return generate_GiftCardTransactionListExporter(sender)

View File

@@ -118,27 +118,6 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
self.cleaned_data[k] = self.initial[k]
return super().save()
def clean(self):
d = super().clean()
# There is logic in HierarkeyForm.save() to only persist fields that changed. HierarkeyForm determines if
# something changed by comparing `self._s.get(name)` to `value`. This leaves an edge case open for multi-lingual
# text fields. On the very first load, the initial value in `self._s.get(name)` will be a LazyGettextProxy-based
# string. However, only some of the languages are usually visible, so even if the user does not change anything
# at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add
# languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation.
for name, field in self.fields.items():
if isinstance(field, i18nfield.forms.I18nFormField):
value = d.get(name)
if not value:
continue
current = self._s.get(name, as_type=type(value))
if name not in self.changed_data:
d[name] = current
return d
def get_new_filename(self, name: str) -> str:
from pretix.base.models import Event

View File

@@ -37,19 +37,21 @@ import json
import logging
from decimal import Decimal
from io import BytesIO
from urllib.error import HTTPError
import dateutil.parser
import pycountry
import pytz
import vat_moss.errors
import vat_moss.id
from babel import Locale
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms import Select
from django.utils import translation
from django.utils.formats import date_format
from django.utils.html import escape
@@ -73,9 +75,8 @@ from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language,
)
from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
from pretix.base.models.tax import (
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
)
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
@@ -152,9 +153,8 @@ class NamePartsWidget(forms.MultiWidget):
final_attrs,
id='%s_%s' % (id_, i),
title=self.scheme['fields'][i][1],
placeholder=self.scheme['fields'][i][1],
)
if not isinstance(widget, widgets.Select):
these_attrs['placeholder'] = self.scheme['fields'][i][1]
if self.scheme['fields'][i][0] in REQUIRED_NAME_PARTS:
if self.field.required:
these_attrs['required'] = 'required'
@@ -507,7 +507,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
def __init__(self, *args, **kwargs):
kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp"))
kwargs.setdefault('max_size', settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
kwargs.setdefault('max_size', 10 * 1024 * 1024)
super().__init__(*args, **kwargs)
@@ -739,7 +739,7 @@ class BaseQuestionsForm(forms.Form):
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
".bmp", ".tif", ".tiff"
),
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
max_size=10 * 1024 * 1024,
)
elif q.type == Question.TYPE_DATE:
attrs = {}
@@ -869,12 +869,6 @@ class BaseQuestionsForm(forms.Form):
if question_is_required(q) and not answer and answer != 0 and not field.errors:
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
# Strip invisible question from cleaned_data so they don't end up in the database
for q in question_cache.values():
answer = d.get('question_%d' % q.pk)
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
d['question_%d' % q.pk] = None
return d
@@ -906,7 +900,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization',
}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-in-eu': ','.join(EU_COUNTRIES)}),
'internal_reference': forms.TextInput,
}
labels = {
@@ -926,18 +920,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:
del self.fields['vat_id']
elif self.validate_vat_id:
self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but depending on the country you reside in we might need to charge you '
'additional taxes if you do not enter it.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
])
else:
self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
'depending on your and the sellers country of residence.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
])
self.fields['country'].choices = CachedCountries()
@@ -969,7 +951,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['state'].widget.is_required = True
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
if cc and not is_eu_country(cc) and fprefix + 'vat_id' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'vat_id']
@@ -994,7 +976,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Name'),
initial=self.instance.name_parts,
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
)
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
if not event.settings.invoice_name_required:
@@ -1019,7 +1001,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not data.get('is_business'):
data['company'] = ''
data['vat_id'] = ''
if data.get('is_business') and not ask_for_vat_id(data.get('country')):
if data.get('is_business') and not is_eu_country(data.get('country')):
data['vat_id'] = ''
if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'):
@@ -1042,23 +1024,36 @@ class BaseInvoiceAddressForm(forms.ModelForm):
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if data.get('vat_id') and is_eu_country(data.get('country')) and data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
raise ValidationError(_('Your VAT ID does not match the selected country.'))
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'):
try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id_validated = True
self.instance.vat_id = normalized_id
except VATIDFinalError as e:
if self.all_optional:
self.instance.vat_id_validated = False
messages.warning(self.request, e.message)
else:
raise ValidationError(e.message)
except VATIDTemporaryError as e:
result = vat_moss.id.validate(data.get('vat_id'))
if result:
country_code, normalized_id, company_name = result
self.instance.vat_id_validated = True
self.instance.vat_id = normalized_id
except (vat_moss.errors.InvalidError, ValueError):
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
except vat_moss.errors.WebServiceUnavailableError:
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False
if self.request and self.vat_warning:
messages.warning(self.request, e.message)
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.'))
except (vat_moss.errors.WebServiceError, HTTPError):
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False
if self.request and self.vat_warning:
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
'your country returned an incorrect result. We will therefore '
'need to charge VAT on your invoice. Please contact support to '
'resolve this manually.'))
else:
self.instance.vat_id_validated = False

View File

@@ -55,7 +55,6 @@ class UserSettingsForm(forms.ModelForm):
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
old_pw = forms.CharField(max_length=255,
@@ -159,12 +158,6 @@ class UserSettingsForm(forms.ModelForm):
code='pw_current'
)
if password1 and password1 == old_pw:
raise forms.ValidationError(
self.error_messages['pw_equal'],
code='pw_equal'
)
if password1:
self.instance.set_password(password1)

View File

@@ -184,7 +184,7 @@ class BusinessBooleanRadio(forms.RadioSelect):
self.require_business = require_business
if self.require_business:
choices = (
('business', _('Business or institutional customer')),
('business', _('Business customer')),
)
else:
choices = (

View File

@@ -395,13 +395,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return txt
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
tz = self.invoice.event.timezone
show_end_date = (
self.invoice.event.settings.show_date_to and
self.invoice.event.date_to and
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
)
if show_end_date:
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(
@@ -556,10 +550,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
for line in self.invoice.lines.all():
if has_taxes:
tdata.append((
Paragraph(
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self.stylesheet['Normal']
),
Paragraph(line.description, self.stylesheet['Normal']),
"1",
localize(line.tax_rate) + " %",
money_filter(line.net_value, self.invoice.event.currency),
@@ -567,10 +558,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
))
else:
tdata.append((
Paragraph(
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self.stylesheet['Normal']
),
Paragraph(line.description, self.stylesheet['Normal']),
"1",
money_filter(line.gross_value, self.invoice.event.currency),
))
@@ -607,7 +595,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata))
story.append(table)
story.append(Spacer(1, 10 * mm))
story.append(Spacer(1, 15 * mm))
if self.invoice.payment_provider_text:
story.append(Paragraph(
@@ -623,14 +611,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.invoice.additional_text,
self.stylesheet['Normal']
))
story.append(Spacer(1, 5 * mm))
story.append(Spacer(1, 15 * mm))
tstyledata = [
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
('LEFTPADDING', (0, 0), (0, -1), 0),
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 1),
('BOTTOMPADDING', (0, 0), (-1, -1), 1),
('FONTSIZE', (0, 0), (-1, -1), 8),
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
]
@@ -783,55 +769,44 @@ class Modern1Renderer(ClassicInvoiceRenderer):
]
def _draw_metadata(self, canvas):
# Draws the "invoice number -- date" line. This has gotten a little more complicated since we
# encountered some events with very long invoice numbers. In this case, we automatically reduce
# the font size until it fits.
begin_top = 100 * mm
def _draw(label, value, value_size, x, width):
if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6:
return False
textobject = canvas.beginText(x, self.pagesize[1] - begin_top)
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(label)
textobject.textLine(pgettext('invoice', 'Cancellation number'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, value_size)
textobject.textLine(value)
return textobject
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
canvas.drawText(textobject)
value_size = 10
while value_size >= 5:
objects = [
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm)
]
p = Paragraph(
date_format(self.invoice.date, "DATE_FORMAT"),
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
)
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
if self.invoice.is_cancellation:
objects += [
_draw(pgettext('invoice', 'Cancellation number'), self.invoice.number,
value_size, self.left_margin + 50 * mm, 45 * mm),
_draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number,
value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm),
]
else:
objects += [
_draw(pgettext('invoice', 'Invoice number'), self.invoice.number,
value_size, self.left_margin + 70 * mm, date_x - self.left_margin - 70 * mm - 5 * mm),
]
if all(objects):
for o in objects:
canvas.drawText(o)
break
value_size -= 1
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)

View File

@@ -1,67 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
"""
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.db import models
from django.db.migrations.operations import models as modelops
from django_countries.fields import CountryField
def monkeypatch_migrations():
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
IGNORED_ATTRS = [
# (field type, attribute name, banlist of field sub-types)
(models.Field, 'verbose_name', []),
(models.Field, 'help_text', []),
(models.Field, 'validators', []),
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
models.TimeField]),
(models.CharField, 'choices', [CountryField])
]
original_deconstruct = models.Field.deconstruct
def new_deconstruct(self):
name, path, args, kwargs = original_deconstruct(self)
for ftype, attr, banlist in IGNORED_ATTRS:
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
kwargs.pop(attr, None)
return name, path, args, kwargs
models.Field.deconstruct = new_deconstruct

View File

@@ -1,82 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
from django.core.management.base import BaseCommand
from django.db import models
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
from django.db.models.functions import Coalesce
from django_scopes import scopes_disabled
from pretix.base.models import Order, OrderFee, OrderPosition
from pretix.base.models.orders import Transaction
class Command(BaseCommand):
help = "Check order for consistency with their transactions"
@scopes_disabled()
def handle(self, *args, **options):
qs = Order.objects.annotate(
position_total=Coalesce(
Subquery(
OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Sum('price')).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
fee_total=Coalesce(
Subquery(
OrderFee.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Sum('value')).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
tx_total=Coalesce(
Subquery(
Transaction.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Sum(F('price') * F('count'))).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
).annotate(
correct_total=Case(
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
then=Value(0)),
default=F('position_total') + F('fee_total'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
).exclude(
total=F('position_total') + F('fee_total'),
tx_total=F('correct_total')
).select_related('event')
for o in qs:
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001'):
# Ignore SQLite which treats Decimals like floats…
continue
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}")
self.stderr.write(self.style.SUCCESS(f'Check completed.'))

View File

@@ -1,95 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import time
from django.core.management.base import BaseCommand
from django.db.models import F, Max, Q
from django.utils.timezone import now
from django_scopes import scopes_disabled
from tqdm import tqdm
from pretix.base.models import Order
class Command(BaseCommand):
help = "Create missing order transactions"
def add_arguments(self, parser):
parser.add_argument(
"--slowdown",
dest="interval",
type=int,
default=0,
help="Interval for staggered execution. If set to a value different then zero, we will "
"wait this many milliseconds between every order we process.",
)
@scopes_disabled()
def handle(self, *args, **options):
t = 0
qs = Order.objects.annotate(
last_transaction=Max('transactions__created')
).filter(
Q(last_transaction__isnull=True) | Q(last_modified__gt=F('last_transaction')),
require_approval=False,
).prefetch_related(
'all_positions', 'all_fees'
).order_by(
'pk'
)
last_pk = 0
with tqdm(total=qs.count()) as pbar:
while True:
batch = list(qs.filter(pk__gt=last_pk)[:5000])
if not batch:
break
for o in batch:
if o.last_transaction is None:
tn = o.create_transactions(
positions=o.all_positions.all(),
fees=o.all_fees.all(),
dt_now=o.datetime,
migrated=True,
is_new=True,
_backfill_before_cancellation=True,
)
o.create_transactions(
positions=o.all_positions.all(),
fees=o.all_fees.all(),
dt_now=o.cancellation_date or (o.expires if o.status == Order.STATUS_EXPIRED else o.datetime),
migrated=True,
)
else:
tn = o.create_transactions(
positions=o.all_positions.all(),
fees=o.all_fees.all(),
dt_now=now(),
migrated=True,
)
if tn:
t += 1
time.sleep(0)
pbar.update(1)
last_pk = batch[-1].pk
self.stderr.write(self.style.SUCCESS(f'Created transactions for {t} orders.'))

View File

@@ -103,7 +103,7 @@ class Command(BaseCommand):
with language(locale), override(timezone):
for receiver, response in signal_result:
ex = response(e, o, report_status)
ex = response(e, report_status)
if ex.identifier == options['export_provider']:
params = json.loads(options.get('parameters') or '{}')
with open(options['output_file'], 'wb') as f:

View File

@@ -32,11 +32,53 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
"""
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
from ._migrations import monkeypatch_migrations
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
IGNORED_ATTRS = [
# (field type, attribute name, banlist of field sub-types)
(models.Field, 'verbose_name', []),
(models.Field, 'help_text', []),
(models.Field, 'validators', []),
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
models.TimeField]),
(models.CharField, 'choices', [CountryField])
]
monkeypatch_migrations()
original_deconstruct = models.Field.deconstruct
def new_deconstruct(self):
name, path, args, kwargs = original_deconstruct(self)
for ftype, attr, banlist in IGNORED_ATTRS:
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
kwargs.pop(attr, None)
return name, path, args, kwargs
models.Field.deconstruct = new_deconstruct
class Command(Parent):

View File

@@ -32,6 +32,12 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
"""
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
run when there are things we have no migrations for. Usually, this is intended, and running
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
users from doing that by going really dirty and filtering it from the output.
"""
import sys
from django.core.management.base import OutputWrapper
@@ -39,15 +45,9 @@ from django.core.management.commands.migrate import Command as Parent
class OutputFilter(OutputWrapper):
"""
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
run when there are things we have no migrations for. Usually, this is intended, and running
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
users from doing that by going really dirty and filtering it from the output.
"""
banlist = (
"have changes that are not yet reflected",
"re-run 'manage.py migrate'"
"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):

View File

@@ -208,7 +208,7 @@ def _parse_csp(header):
def _render_csp(h):
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items())
def _merge_csp(a, b):

View File

@@ -1,38 +0,0 @@
# Generated by Django 3.2.2 on 2021-05-23 13:22
import django.db.models.deletion
from django.db import migrations, models
import pretix.helpers.countries
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0195_auto_20210622_1457'),
]
operations = [
migrations.AddField(
model_name='invoiceaddress',
name='customer',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_addresses', to='pretixbase.customer'),
),
migrations.CreateModel(
name='AttendeeProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('attendee_name_cached', models.CharField(max_length=255, null=True)),
('attendee_name_parts', models.JSONField(default=dict)),
('attendee_email', models.EmailField(max_length=254, null=True)),
('company', models.CharField(max_length=255, null=True)),
('street', models.TextField(null=True)),
('zipcode', models.CharField(max_length=30, null=True)),
('city', models.CharField(max_length=255, null=True)),
('country', pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True)),
('state', models.CharField(max_length=255, null=True)),
('answers', models.JSONField(default=list)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendee_profiles', to='pretixbase.customer')),
],
),
]

View File

@@ -1,36 +0,0 @@
# Generated by Django 3.2.4 on 2021-09-14 08:14
from django.db import migrations, models
import pretix.base.models.fields
import pretix.base.models.items
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0196_auto_20210523_1322'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='available_from',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='itemvariation',
name='available_until',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='itemvariation',
name='hide_without_voucher',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='itemvariation',
name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=pretix.base.models.items._all_sales_channels_identifiers),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 3.2.4 on 2021-09-30 10:25
from datetime import datetime
from django.db import migrations, models
from pytz import UTC
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0197_auto_20210914_0814'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='sent_to_customer',
field=models.DateTimeField(blank=True, null=True, default=UTC.localize(datetime(1970, 1, 1, 0, 0, 0, 0))),
preserve_default=False,
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.2.4 on 2021-10-05 10:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0198_invoice_sent_to_customer'),
]
operations = [
migrations.AddField(
model_name='item',
name='require_membership_hidden',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='itemvariation',
name='require_membership_hidden',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,38 +0,0 @@
# Generated by Django 3.2.4 on 2021-10-18 10:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0199_auto_20211005_1050'),
]
operations = [
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
('datetime', models.DateTimeField(db_index=True)),
('migrated', models.BooleanField(default=False)),
('positionid', models.PositiveIntegerField(default=1, null=True)),
('count', models.IntegerField(default=1)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('tax_rate', models.DecimalField(decimal_places=2, max_digits=7)),
('tax_value', models.DecimalField(decimal_places=2, max_digits=10)),
('fee_type', models.CharField(max_length=100, null=True)),
('internal_type', models.CharField(max_length=255, null=True)),
('item', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.item')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='pretixbase.order')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.subevent')),
('tax_rule', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.taxrule')),
('variation', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.itemvariation')),
],
options={
'ordering': ('datetime', 'pk'),
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.4 on 2021-11-03 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0200_transaction'),
]
operations = [
migrations.AddField(
model_name='invoiceline',
name='event_location',
field=models.TextField(null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.9 on 2021-11-04 13:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0201_invoiceline_event_location'),
]
operations = [
migrations.AddField(
model_name='user',
name='needs_password_change',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 3.2.2 on 2021-11-08 07:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0202_user_needs_password_change'),
]
operations = [
migrations.AddField(
model_name='orderposition',
name='is_bundled',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,46 +0,0 @@
# Generated by Django 3.2.2 on 2021-11-08 07:51
from django.db import migrations, models
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
def fill_is_bundled(apps, schema_editor):
# We cannot really know if a position was bundled or an add-on, but we can at least guess
ItemBundle = apps.get_model("pretixbase", "ItemBundle")
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
for ib in ItemBundle.objects.iterator():
OrderPosition.all.alias(
pos_earlier=Coalesce(Subquery(
OrderPosition.all.filter(
canceled=False,
addon_to=OuterRef('addon_to'),
item=ib.bundled_item,
variation=ib.bundled_variation,
positionid__lt=OuterRef('positionid'),
).values('addon_to').order_by().annotate(c=Count('*')).values('c'),
output_field=models.IntegerField()
), 0)
).filter(
canceled=False,
addon_to__item=ib.base_item,
item=ib.bundled_item,
variation=ib.bundled_variation,
pos_earlier__lt=ib.count,
).update(
is_bundled=True
)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0203_orderposition_is_bundled'),
]
operations = [
migrations.RunPython(
fill_is_bundled,
migrations.RunPython.noop,
),
]

View File

@@ -42,9 +42,8 @@ from .notifications import NotificationSetting
from .orders import (
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
QuestionAnswer, RevokedTicketSecret, Transaction,
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
generate_secret,
QuestionAnswer, RevokedTicketSecret, cachedcombinedticket_name,
cachedticket_name, generate_position_secret, generate_secret,
)
from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,

View File

@@ -1,113 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
"""
This module contains helper functions that are supposed to call out code paths missing calls to
``Order.create_transaction()`` by actively breaking them. Read the docstring of the ``Transaction`` class for a
detailed reasoning why this exists.
"""
import inspect
import logging
import os
import threading
from django.conf import settings
from django.db import transaction
dirty_transactions = threading.local()
logger = logging.getLogger(__name__)
fail_loudly = os.getenv('PRETIX_DIRTY_TRANSACTIONS_QUIET', 'false' if settings.DEBUG else 'true') not in ('true', 'True', 'on', '1')
class DirtyTransactionsForOrderException(Exception):
pass
def _fail(message):
if fail_loudly:
raise DirtyTransactionsForOrderException(message)
else:
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_message(message, "fatal")
logger.warning(message, stack_info=True)
def _check_for_dirty_orders():
if getattr(dirty_transactions, 'order_ids', None) is None:
dirty_transactions.order_ids = set()
try:
if dirty_transactions.order_ids and dirty_transactions.order_ids != {None}:
_fail(
f"In the transaction that just ended, you created or modified an Order, OrderPosition, or OrderFee "
f"object in a way that you should have called `order.create_transactions()` afterwards. The transaction "
f"still went through and your data can be fixed with the `create_order_transactions` management command "
f"but you should update your code to prevent this from happening. Affected order IDs: {dirty_transactions.order_ids}"
)
finally:
dirty_transactions.order_ids.clear()
def _transactions_mark_order_dirty(order_id, using=None):
if "PYTEST_CURRENT_TEST" in os.environ:
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
# or not.
for frame in inspect.stack():
if 'pretix/base/models/orders' in frame.filename:
continue
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
return
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
# This went through non-test code, let's consider it non-test
break
if order_id is None:
return
conn = transaction.get_connection(using)
if not conn.in_atomic_block:
_fail(
"You modified an Order, OrderPosition, or OrderFee object in a way that should create "
"a new Transaction object within the same database transaction, however you are not "
"doing it inside a database transaction!"
)
if getattr(dirty_transactions, 'order_ids', None) is None:
dirty_transactions.order_ids = set()
if _check_for_dirty_orders not in [func for savepoint_id, func in conn.run_on_commit]:
transaction.on_commit(_check_for_dirty_orders, using)
dirty_transactions.order_ids.clear() # This is necessary to clean up after old threads with rollbacked transactions
dirty_transactions.order_ids.add(order_id)
def _transactions_mark_order_clean(order_id):
if getattr(dirty_transactions, 'order_ids', None) is None:
dirty_transactions.order_ids = set()
try:
dirty_transactions.order_ids.remove(order_id)
except KeyError:
pass

View File

@@ -113,8 +113,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:type date_joined: datetime
:param locale: The user's preferred locale code.
:type locale: str
:param needs_password_change: Whether this user's password needs to be changed.
:type needs_password_change: bool
:param timezone: The user's preferred timezone.
:type timezone: str
"""
@@ -132,8 +130,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
verbose_name=_('Is site admin'))
date_joined = models.DateTimeField(auto_now_add=True,
verbose_name=_('Date joined'))
needs_password_change = models.BooleanField(default=False,
verbose_name=_('Force user to select a new password'))
locale = models.CharField(max_length=50,
choices=settings.LANGUAGES,
default=settings.LANGUAGE_CODE,

View File

@@ -19,22 +19,19 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import pycountry
from django.conf import settings
from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password,
)
from django.db import models
from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField
class Customer(LoggedModel):
@@ -91,8 +88,6 @@ class Customer(LoggedModel):
self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None)
self.memberships.all().update(attendee_name_parts=None)
self.attendee_profiles.all().delete()
self.invoice_addresses.all().delete()
@scopes_disabled()
def assign_identifier(self):
@@ -179,94 +174,3 @@ class Customer(LoggedModel):
continue
ctx['name_%s' % f] = self.name_parts.get(f, '')
return ctx
@property
def stored_addresses(self):
return self.invoice_addresses(manager='profiles')
def usable_memberships(self, for_event, testmode=False):
return self.memberships.active(for_event).with_usages().filter(
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
testmode=testmode,
)
class AttendeeProfile(models.Model):
customer = models.ForeignKey(
Customer,
related_name='attendee_profiles',
on_delete=models.CASCADE
)
attendee_name_cached = models.CharField(
max_length=255,
verbose_name=_("Attendee name"),
blank=True, null=True,
)
attendee_name_parts = models.JSONField(
blank=True, default=dict
)
attendee_email = models.EmailField(
verbose_name=_("Attendee email"),
blank=True, null=True,
)
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
answers = models.JSONField(default=list)
objects = ScopedManager(organizer='customer__organizer')
@property
def attendee_name(self):
if not self.attendee_name_parts:
return None
if '_legacy' in self.attendee_name_parts:
return self.attendee_name_parts['_legacy']
if '_scheme' in self.attendee_name_parts:
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
else:
scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
return scheme['concatenation'](self.attendee_name_parts).strip()
@property
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return sd.name
return self.state
@property
def state_for_address(self):
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return ""
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
return self.state_name
return self.state
def describe(self):
from .items import Question
from .orders import QuestionAnswer
parts = [
self.attendee_name,
self.attendee_email,
self.company,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name,
]
for a in self.answers:
value = a.get('value')
try:
value = ", ".join(value.values())
except AttributeError:
value = str(value)
answer = QuestionAnswer(question=Question(type=a.get('question_type')), answer=value)
val = str(answer)
parts.append(f'{a["field_label"]}: {val}')
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])

View File

@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import logging
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
@@ -57,7 +56,6 @@ from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext, gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
@@ -75,8 +73,6 @@ from pretix.helpers.thumb import get_thumbnail
from ..settings import settings_hierarkey
from .organizer import Organizer, Team
logger = logging.getLogger(__name__)
class EventMixin:
def clean(self):
@@ -146,7 +142,7 @@ class EventMixin:
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
)
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False) -> str:
def get_date_range_display(self, tz=None, force_show_end=False) -> str:
"""
Returns a formatted string containing the start date and the end date
of the event with respect to the current locale and to the ``show_date_to``
@@ -154,40 +150,8 @@ class EventMixin:
"""
tz = tz or self.timezone
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
if as_html:
return format_html(
"<time datetime=\"{}\">{}</time>",
_date(self.date_from.astimezone(tz), "Y-m-d"),
_date(self.date_from.astimezone(tz), "DATE_FORMAT"),
)
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz), as_html)
def get_date_range_display_as_html(self, tz=None, force_show_end=False) -> str:
return self.get_date_range_display(tz, force_show_end, as_html=True)
def get_time_range_display(self, tz=None, force_show_end=False) -> str:
"""
Returns a formatted string containing the start time and sometimes the end time
of the event with respect to the current locale and to the ``show_date_to``
setting. Dates are not shown. This is usually used in combination with get_date_range_display
"""
tz = tz or self.timezone
show_date_to = self.date_to and (self.settings.show_date_to or force_show_end) and (
# Show date to if start and end are on the same day ("08:00-10:00")
self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() or
# Show date to if start and end are on consecutive days and less than 24h ("23:00-03:00")
(self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() + timedelta(days=1) and
self.date_to.astimezone(tz).time() < self.date_from.astimezone(tz).time())
# Do not show end time if this is a 5-day event because there's no way to make it understandable
)
if show_date_to:
return '{} {}'.format(
_date(self.date_from.astimezone(tz), "TIME_FORMAT"),
_date(self.date_to.astimezone(tz), "TIME_FORMAT"),
)
return _date(self.date_from.astimezone(tz), "TIME_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
@property
def timezone(self):
@@ -278,10 +242,6 @@ class EventMixin:
).values('items')
sq_active_variation = ItemVariation.objects.filter(
Q(active=True)
& Q(sales_channels__contains=channel)
& Q(hide_without_voucher=False)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(item__active=True)
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
@@ -532,7 +492,7 @@ class Event(EventMixin, LoggedModel):
default=False
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events', verbose_name=_('Seating plan'))
related_name='events')
last_modified = models.DateTimeField(
auto_now=True, db_index=True
@@ -565,8 +525,6 @@ class Event(EventMixin, LoggedModel):
self.settings.ticketoutput_pdf__enabled = True
self.settings.ticketoutput_passbook__enabled = True
self.settings.event_list_type = 'calendar'
self.settings.invoice_email_attachment = True
self.settings.name_scheme = 'given_family'
@property
def social_image(self):
@@ -576,17 +534,9 @@ class Event(EventMixin, LoggedModel):
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
og_file = self.settings.get('og_image', as_type=str, default='')[7:]
if og_file:
try:
img = get_thumbnail(og_file, '1200').thumb.url
except:
logger.exception(f'Failed to create thumbnail of {og_file}')
img = default_storage.url(og_file)
img = get_thumbnail(og_file, '1200').thumb.url
elif logo_file:
try:
img = get_thumbnail(logo_file, '5000x1200').thumb.url
except:
logger.exception(f'Failed to create thumbnail of {logo_file}')
img = default_storage.url(logo_file)
img = get_thumbnail(logo_file, '5000x120').thumb.url
if img:
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
@@ -670,17 +620,16 @@ class Event(EventMixin, LoggedModel):
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the event's settings.
"""
from pretix.base.email import CustomSMTPBackend
if self.settings.smtp_use_custom or force_custom:
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False,
timeout=timeout)
return CustomSMTPBackend(host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False, timeout=timeout)
else:
return get_connection(fail_silently=False)
@@ -792,9 +741,7 @@ class Event(EventMixin, LoggedModel):
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save()
quota_map = {}
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
quota_map[q.pk] = q
items = list(q.items.all())
vars = list(q.variations.all())
oldid = q.pk
@@ -918,7 +865,7 @@ class Event(EventMixin, LoggedModel):
event_copy_data.send(
sender=self, other=other,
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
question_map=question_map, checkin_list_map=checkin_list_map, quota_map=quota_map,
question_map=question_map, checkin_list_map=checkin_list_map
)
if has_custom_style:
@@ -1057,11 +1004,6 @@ class Event(EventMixin, LoggedModel):
| Q(date_to__gte=now() - timedelta(hours=24))
)
) # order_by doesn't make sense with I18nField
if ordering in ("date_ascending", "date_descending"):
# if primary order is by date, then order in database
# this allows to limit/slice results
return subevs.order_by(*orderfields)
for f in reversed(orderfields):
if f.startswith('-'):
subevs = sorted(subevs, key=attrgetter(f[1:]), reverse=True)
@@ -1171,18 +1113,15 @@ class Event(EventMixin, LoggedModel):
self.items.all().delete()
self.subevents.all().delete()
def get_available_plugins(self):
def set_active_plugins(self, modules, allow_restricted=False):
from pretix.base.plugins import get_all_plugins
return {
plugins_active = self.get_plugins()
plugins_available = {
p.module: p for p in get_all_plugins(self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
def set_active_plugins(self, modules, allow_restricted=False):
plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
@@ -1211,10 +1150,6 @@ class Event(EventMixin, LoggedModel):
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
if hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
regenerate_css.apply_async(args=(self.pk,))
@staticmethod
@@ -1324,7 +1259,7 @@ class SubEvent(EventMixin, LoggedModel):
verbose_name=_("Frontpage text")
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents', verbose_name=_('Seating plan'))
related_name='subevents')
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
@@ -1434,7 +1369,7 @@ class SubEvent(EventMixin, LoggedModel):
return self.event.currency
def allow_delete(self):
return not self.orderposition_set.exists() and not self.transaction_set.exists()
return not self.orderposition_set.exists()
def delete(self, *args, **kwargs):
clear_cache = kwargs.pop('clear_cache', False)

View File

@@ -159,8 +159,6 @@ class Invoice(models.Model):
# False: The invoice wasn't sent and never will, because sending was not configured at the time of the check.
sent_to_organizer = models.BooleanField(null=True, blank=True)
sent_to_customer = models.DateTimeField(null=True, blank=True)
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
objects = ScopedManager(organizer='event__organizer')
@@ -237,7 +235,7 @@ class Invoice(models.Model):
def _get_invoice_number_from_order(self):
return '{order}-{count}'.format(
order=self.order.code,
count=Invoice.objects.filter(event=self.event, prefix=self.prefix, invoice_no__startswith=f"{self.order.code}-", order=self.order).count() + 1,
count=Invoice.objects.filter(event=self.event, order=self.order).count() + 1,
)
def save(self, *args, **kwargs):
@@ -264,7 +262,6 @@ class Invoice(models.Model):
self.invoice_no = self._get_invoice_number_from_order()
try:
with transaction.atomic():
self.full_invoice_no = self.prefix + self.invoice_no
return super().save(*args, **kwargs)
except DatabaseError:
# Suppress duplicate key errors and try again
@@ -303,9 +300,6 @@ class Invoice(models.Model):
def __repr__(self):
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
def __str__(self):
return self.full_invoice_no
class InvoiceLine(models.Model):
"""
@@ -329,8 +323,6 @@ class InvoiceLine(models.Model):
:type event_date_from: datetime
:param event_date_to: Event end date of the (sub)event at the time the invoice was created
:type event_date_to: datetime
:param event_location: Event location of the (sub)event at the time the invoice was created
:type event_location: str
:param item: The item this line refers to
:type item: Item
:param variation: The variation this line refers to
@@ -348,7 +340,6 @@ class InvoiceLine(models.Model):
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
event_date_from = models.DateTimeField(null=True)
event_date_to = models.DateTimeField(null=True)
event_location = models.TextField(null=True, blank=True)
item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT)
variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT)
attendee_name = models.TextField(null=True, blank=True)

View File

@@ -523,12 +523,6 @@ class Item(LoggedModel):
verbose_name=_('Allowed membership types'),
blank=True,
)
require_membership_hidden = models.BooleanField(
verbose_name=_('Hide without a valid membership'),
help_text=_('Do not show this unless the customer is logged in and has a valid membership. Be aware that '
'this means it will never be visible in the widget.'),
default=False,
)
grant_membership_type = models.ForeignKey(
'MembershipType',
null=True, blank=True,
@@ -693,9 +687,9 @@ class Item(LoggedModel):
return res
def allow_delete(self):
from pretix.base.models.orders import OrderPosition, Transaction
from pretix.base.models.orders import OrderPosition
return not Transaction.objects.filter(item=self).exists() and not OrderPosition.all.filter(item=self).exists()
return not OrderPosition.all.filter(item=self).exists()
@property
def includes_mixed_tax_rate(self):
@@ -742,11 +736,6 @@ class Item(LoggedModel):
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
def _all_sales_channels_identifiers():
from pretix.base.channels import get_all_sales_channels
return list(get_all_sales_channels().keys())
class ItemVariation(models.Model):
"""
A variation of a product. For example, if your item is 'T-Shirt'
@@ -772,7 +761,7 @@ class ItemVariation(models.Model):
)
value = I18nCharField(
max_length=255,
verbose_name=_('Variation')
verbose_name=_('Description')
)
active = models.BooleanField(
default=True,
@@ -808,35 +797,6 @@ class ItemVariation(models.Model):
verbose_name=_('Membership types'),
blank=True,
)
require_membership_hidden = models.BooleanField(
verbose_name=_('Hide without a valid membership'),
help_text=_('Do not show this unless the customer is logged in and has a valid membership. Be aware that '
'this means it will never be visible in the widget.'),
default=False,
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
null=True, blank=True,
help_text=_('This variation will not be sold before the given date.')
)
available_until = models.DateTimeField(
verbose_name=_("Available until"),
null=True, blank=True,
help_text=_('This variation will not be sold after the given date.')
)
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=_all_sales_channels_identifiers,
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
blank=True,
)
hide_without_voucher = models.BooleanField(
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
default=False,
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
'that unlocks this variation.')
)
objects = ScopedManager(organizer='item__event__organizer')
@@ -958,37 +918,16 @@ class ItemVariation(models.Model):
return self.position < other.position
def allow_delete(self):
from pretix.base.models.orders import (
CartPosition, OrderPosition, Transaction,
)
from pretix.base.models.orders import CartPosition, OrderPosition
return (
not Transaction.objects.filter(variation=self).exists()
and not OrderPosition.objects.filter(variation=self).exists()
not OrderPosition.objects.filter(variation=self).exists()
and not CartPosition.objects.filter(variation=self).exists()
)
def is_only_variation(self):
return ItemVariation.objects.filter(item=self.item).count() == 1
def is_available_by_time(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
return False
return True
def is_available(self, now_dt: datetime=None) -> bool:
"""
Returns whether this item is available according to its ``active`` flag
and its ``available_from`` and ``available_until`` fields
"""
now_dt = now_dt or now()
if not self.active or not self.is_available_by_time(now_dt):
return False
return True
class ItemAddOn(models.Model):
"""
@@ -1112,7 +1051,7 @@ class ItemBundle(models.Model):
)
count = models.PositiveIntegerField(
default=1,
verbose_name=_('Quantity')
verbose_name=_('Number')
)
designated_price = models.DecimalField(
default=Decimal('0.00'), blank=True,
@@ -1681,8 +1620,6 @@ class Quota(LoggedModel):
@staticmethod
def clean_items(event, items, variations):
if not items:
return
for item in items:
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))

View File

@@ -95,7 +95,6 @@ class MembershipQuerySet(models.QuerySet):
def active(self, ev):
return self.filter(
canceled=False,
date_start__lte=ev.date_from,
date_end__gte=ev.date_from
)
@@ -176,7 +175,7 @@ class Membership(models.Model):
else:
dt = now()
return not self.canceled and dt >= self.date_start and dt <= self.date_end
return dt >= self.date_start and dt <= self.date_end
def allow_delete(self):
return self.testmode and not self.orderposition_set.exists()

View File

@@ -75,14 +75,11 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import Customer, User
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.locking import LOCK_TIMEOUT, NoLockManager
from pretix.base.services.locking import NoLockManager
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete
from ...helpers.countries import CachedCountries, FastCountryField
from ._transactions import (
_fail, _transactions_mark_order_clean, _transactions_mark_order_dirty,
)
from .base import LockModel, LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -265,11 +262,6 @@ class Order(LockModel, LoggedModel):
def __str__(self):
return self.full_code
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'require_approval' not in self.get_deferred_fields() and 'status' not in self.get_deferred_fields():
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
def gracefully_delete(self, user=None, auth=None):
from . import GiftCard, GiftCardTransaction, Membership, Voucher
@@ -297,7 +289,6 @@ class Order(LockModel, LoggedModel):
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete()
Transaction.objects.filter(order=self).delete()
self.refunds.all().delete()
self.payments.all().delete()
self.event.cache.delete('complain_testmode_orders')
@@ -428,8 +419,6 @@ class Order(LockModel, LoggedModel):
is_underpaid=Case(
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
then=Value(1)),
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__gt=1e-8),
then=Value(1)),
default=Value(0),
output_field=models.IntegerField()
)
@@ -453,27 +442,7 @@ class Order(LockModel, LoggedModel):
self.datetime = now()
if not self.expires:
self.set_expires()
is_new = not self.pk
update_fields = kwargs.get('update_fields', [])
if 'require_approval' not in self.get_deferred_fields() and 'status' not in self.get_deferred_fields():
status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
if status_paid_or_pending != self.__initial_status_paid_or_pending:
_transactions_mark_order_dirty(self.pk, using=kwargs.get('using', None))
elif (
not kwargs.get('force_save_with_deferred_fields', None) and
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
):
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.")
r = super().save(**kwargs)
if is_new:
_transactions_mark_order_dirty(self.pk, using=kwargs.get('using', None))
return r
super().save(**kwargs)
def touch(self):
self.save(update_fields=['last_modified'])
@@ -572,7 +541,7 @@ class Order(LockModel, LoggedModel):
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
return round_decimal(min(fee, self.total), self.event.currency)
return round_decimal(fee, self.event.currency)
@property
@scopes_disabled()
@@ -581,7 +550,6 @@ class Order(LockModel, LoggedModel):
Returns whether or not this order can be canceled by the user.
"""
from .checkin import Checkin
from .items import ItemAddOn
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID) or not self.count_positions:
return False
@@ -607,10 +575,7 @@ class Order(LockModel, LoggedModel):
if self.user_change_deadline and now() > self.user_change_deadline:
return False
return (
(self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])) or
(self.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists())
)
return self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])
@property
@scopes_disabled()
@@ -818,7 +783,6 @@ class Order(LockModel, LoggedModel):
def ticket_download_available(self):
return self.event.settings.ticket_download and (
self.event.settings.ticket_download_date is None
or self.ticket_download_date is None
or now() > self.ticket_download_date
) and (
self.status == Order.STATUS_PAID
@@ -950,7 +914,7 @@ class Order(LockModel, LoggedModel):
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
attach_ical=False, attach_other_files: list=None):
attach_ical=False):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
@@ -994,8 +958,7 @@ class Order(LockModel, LoggedModel):
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files,
position=position, auto_email=auto_email, attach_ical=attach_ical
)
except SendMailException:
raise
@@ -1033,59 +996,6 @@ class Order(LockModel, LoggedModel):
continue
yield op
def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False,
_backfill_before_cancellation=False, save=True):
dt_now = dt_now or now()
# Count the transactions we already have
current_transaction_count = Counter()
if not is_new:
for t in Transaction.objects.filter(order=self): # do not use related manager, we want to avoid cached data
current_transaction_count[Transaction.key(t)] += t.count
# Count the transactions we'd actually need
target_transaction_count = Counter()
if (_backfill_before_cancellation or self.status in (Order.STATUS_PENDING, Order.STATUS_PAID)) and not self.require_approval:
positions = self.positions.all() if positions is None else positions
for p in positions:
if p.canceled and not _backfill_before_cancellation:
continue
target_transaction_count[Transaction.key(p)] += 1
fees = self.fees.all() if fees is None else fees
for f in fees:
if f.canceled and not _backfill_before_cancellation:
continue
target_transaction_count[Transaction.key(f)] += 1
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = []
for k in keys:
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype = k
d = target_transaction_count[k] - current_transaction_count[k]
if d:
create.append(Transaction(
order=self,
datetime=dt_now,
migrated=migrated,
positionid=positionid,
count=d,
item_id=itemid,
variation_id=variationid,
subevent_id=subeventid,
price=price,
tax_rate=taxrate,
tax_rule_id=taxruleid,
tax_value=taxvalue,
fee_type=feetype,
internal_type=internaltype,
))
create.sort(key=lambda t: (0 if t.count < 0 else 1, t.positionid or 0))
if save:
Transaction.objects.bulk_create(create)
_transactions_mark_order_clean(self.pk)
return create
def answerfile_name(instance, filename: str) -> str:
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
@@ -1311,7 +1221,6 @@ class AbstractPosition(models.Model):
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
is_bundled = models.BooleanField(default=False)
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
@@ -1548,7 +1457,7 @@ class OrderPayment(models.Model):
return self.order.event.get_payment_providers(cached=True).get(self.provider)
@transaction.atomic()
def _mark_paid_inner(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
if can_be_paid is not True:
@@ -1556,7 +1465,6 @@ class OrderPayment(models.Model):
'message': can_be_paid
}, user=user, auth=auth)
raise Quota.QuotaExceededException(can_be_paid)
status_change = self.order.status != Order.STATUS_PENDING
self.order.status = Order.STATUS_PAID
self.order.save(update_fields=['status'])
@@ -1570,8 +1478,6 @@ class OrderPayment(models.Model):
if overpaid:
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
if status_change:
self.order.create_transactions()
def fail(self, info=None, user=None, auth=None):
"""
@@ -1624,6 +1530,10 @@ class OrderPayment(models.Model):
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
"""
from pretix.base.services.invoices import (
generate_invoice, invoice_qualified,
)
with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
@@ -1667,15 +1577,7 @@ class OrderPayment(models.Model):
))
return
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum)
def _mark_order_paid(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_refund_sum=0):
from pretix.base.services.invoices import (
generate_invoice, invoice_qualified,
)
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(seconds=LOCK_TIMEOUT * 2)) or not lock:
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
# database transaction is more than enough.
lockfn = NoLockManager
@@ -1683,8 +1585,8 @@ class OrderPayment(models.Model):
lockfn = self.order.event.lock
with lockfn():
self._mark_paid_inner(force, count_waitinglist, user, auth, overpaid=payment_refund_sum > self.order.total,
ignore_date=ignore_date)
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total,
ignore_date=ignore_date)
invoice = None
if invoice_qualified(self.order):
@@ -2003,7 +1905,6 @@ class OrderFee(models.Model):
FEE_TYPE_SHIPPING = "shipping"
FEE_TYPE_SERVICE = "service"
FEE_TYPE_CANCELLATION = "cancellation"
FEE_TYPE_INSURANCE = "insurance"
FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = (
@@ -2011,7 +1912,6 @@ class OrderFee(models.Model):
(FEE_TYPE_SHIPPING, _("Shipping fee")),
(FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
(FEE_TYPE_INSURANCE, _("Insurance fee")),
(FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")),
)
@@ -2053,12 +1953,6 @@ class OrderFee(models.Model):
def net_value(self):
return self.value - self.tax_value
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.get_deferred_fields():
self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled
def __str__(self):
if self.description:
return '{} - {}'.format(self.get_fee_type_display(), self.description)
@@ -2097,15 +1991,6 @@ class OrderFee(models.Model):
if self.tax_rate is None:
self._calculate_tax()
self.order.touch()
if not self.get_deferred_fields():
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
elif not kwargs.get('force_save_with_deferred_fields', None):
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.")
return super().save(*args, **kwargs)
def delete(self, **kwargs):
@@ -2120,7 +2005,7 @@ class OrderPosition(AbstractPosition):
AbstractPosition.
The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If
you want all objects, you need to use ``OrderPosition.all`` instead.
you ant all objects, you need to use ``OrderPosition.all`` instead.
:param order: The order this position is a part of
:type order: Order
@@ -2171,12 +2056,6 @@ class OrderPosition(AbstractPosition):
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.get_deferred_fields():
self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled
class Meta:
verbose_name = _("Order position")
verbose_name_plural = _("Order positions")
@@ -2220,7 +2099,6 @@ class OrderPosition(AbstractPosition):
op._calculate_tax()
op.positionid = i + 1
op.save()
ops.append(op)
cp_mapping[cartpos.pk] = op
for answ in cartpos.answers.all():
answ.orderposition = op
@@ -2286,14 +2164,6 @@ class OrderPosition(AbstractPosition):
if not self.pseudonymization_id:
self.assign_pseudonymization_id()
if not self.get_deferred_fields():
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
elif not kwargs.get('force_save_with_deferred_fields', None):
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.")
return super().save(*args, **kwargs)
@scopes_disabled()
@@ -2317,7 +2187,7 @@ class OrderPosition(AbstractPosition):
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
auth=None, attach_tickets=False, attach_ical=False):
"""
Sends an email to the attendee. Basically, this method does two things:
@@ -2337,7 +2207,7 @@ class OrderPosition(AbstractPosition):
:param attach_ical: Attach relevant ICS files
"""
from pretix.base.services.mail import (
SendMailException, TolerantDict, mail, render_mail,
SendMailException, mail, render_mail,
)
if not self.attendee_email:
@@ -2350,7 +2220,6 @@ class OrderPosition(AbstractPosition):
recipient = self.attendee_email
try:
email_content = render_mail(template, context)
subject = str(subject).format_map(TolerantDict(context))
mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
@@ -2358,7 +2227,6 @@ class OrderPosition(AbstractPosition):
invoices=invoices,
attach_tickets=attach_tickets,
attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
except SendMailException:
raise
@@ -2390,151 +2258,6 @@ class OrderPosition(AbstractPosition):
)
class Transaction(models.Model):
"""
Transactions are a data structure that is redundant on the first sight but makes it possible to create good
financial reporting.
To understand this, think of "orders" as something like a contractual relationship between the organizer and the
customer which requires to customer to pay some money and the organizer to provide a ticket.
The ``Order``, ``OrderPosition``, and ``OrderFee`` models combined give a representation of the current contractual
status of this relationship, i.e. how much and what is owed. The ``OrderPayment`` and ``OrderRefund`` models indicate
the "other side" of the relationship, i.e. how much of the financial obligation has been met so far.
However, while ``OrderPayment`` and ``OrderRefund`` objects are "final" and no longer change once they reached their
final state, ``Order``, ``OrderPosition`` and ``OrderFee`` are highly mutable and can change at any time, e.g. if
the customer moves their booking to a different day or a discount is applied retroactively.
Therefore those models can be used to answer the question "how many tickets of type X have been sold for my event
as of today?" but they cannot accurately answer the question "how many tickets of type X have been sold for my event
as of last month?" because they lack this kind of historical information.
Transactions help here because they are "immutable copies" or "modification records" of call positions and fees
at the time of their creation and change. They only record data that is usually relevant for financial reporting,
such as amounts, prices, products and dates involved. They do not record data like attendee names etc.
Even before the introduction of the Transaction Model pretix *did* store historical data for auditability in the
LogEntry model. However, it's almost impossible to do efficient reporting on that data.
Transactions should never be generated manually but only through the ``order.create_transactions()``
method which should be called **within the same database transaction**.
The big downside of this approach is that you need to remember to update transaction records every time you change
or create orders in new code paths. The mechanism introduced in ``pretix.base.models._transactions`` as well as
the ``save()`` methods of ``Order``, ``OrderPosition`` and ``OrderFee`` intends to help you notice if you missed
it. The only thing this *doesn't* catch is usage of ``OrderPosition.objects.bulk_create`` (and likewise for
``bulk_update`` and ``OrderFee``).
:param id: ID of the transaction
:param order: Order the transaction belongs to
:param datetime: Date and time of the transaction
:param migrated: Whether this object was reconstructed because the order was created before transactions where introduced
:param positionid: Affected Position ID, in case this transaction represents a change in an order position
:param count: An amount, multiplicator for price etc. For order positions this can *currently* only be -1 or +1, for
fees it can also be more in special cases
:param item: ``Item``, in case this transaction represents a change in an order position
:param variation: ``ItemVariation``, in case this transaction represents a change in an order position
:param subevent: ``subevent``, in case this transaction represents a change in an order position
:param price: Price of the changed position
:param tax_rate: Tax rate of the changed position
:param tax_rule: Used tax rule
:param tax_value: Tax value in event currency
:param fee_type: Fee type code in case this transaction represents a change in an order fee
:param internal_type: Internal fee type in case this transaction represents a change in an order fee
"""
id = models.BigAutoField(primary_key=True)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='transactions',
on_delete=models.PROTECT
)
created = models.DateTimeField(
auto_now_add=True,
db_index=True,
)
datetime = models.DateTimeField(
verbose_name=_("Date"),
db_index=True,
)
migrated = models.BooleanField(
default=False
)
positionid = models.PositiveIntegerField(default=1, null=True, blank=True)
count = models.IntegerField(
default=1
)
item = models.ForeignKey(
Item,
null=True, blank=True,
verbose_name=_("Item"),
on_delete=models.PROTECT
)
variation = models.ForeignKey(
ItemVariation,
null=True, blank=True,
verbose_name=_("Variation"),
on_delete=models.PROTECT
)
subevent = models.ForeignKey(
SubEvent,
null=True, blank=True,
on_delete=models.PROTECT,
verbose_name=pgettext_lazy("subevent", "Date"),
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Price")
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
)
tax_rule = models.ForeignKey(
'TaxRule',
on_delete=models.PROTECT,
null=True, blank=True
)
tax_value = models.DecimalField(
max_digits=10, decimal_places=2,
verbose_name=_('Tax value')
)
fee_type = models.CharField(
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
)
internal_type = models.CharField(max_length=255, null=True, blank=True)
class Meta:
ordering = 'datetime', 'pk'
def save(self, *args, **kwargs):
if not self.fee_type and not self.item:
raise ValidationError('Should set either item or fee type')
return super().save(*args, **kwargs)
@staticmethod
def key(obj):
if isinstance(obj, Transaction):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
elif isinstance(obj, OrderPosition):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, None, None)
elif isinstance(obj, OrderFee):
return (None, None, None, None, obj.value, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
raise ValueError('invalid state') # noqa
@property
def full_price(self):
return self.price * self.count
@property
def full_tax_value(self):
return self.tax_value * self.count
class CartPosition(AbstractPosition):
"""
A cart position is similar to an order line, except that it is not
@@ -2573,6 +2296,7 @@ class CartPosition(AbstractPosition):
max_digits=10, decimal_places=2,
null=True, blank=True
)
is_bundled = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
@@ -2605,12 +2329,6 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
customer = models.ForeignKey(
Customer,
related_name='invoice_addresses',
null=True, blank=True,
on_delete=models.CASCADE
)
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
@@ -2622,7 +2340,8 @@ class InvoiceAddress(models.Model):
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries)
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'))
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
help_text=_('Only for business customers within the EU.'))
vat_id_validated = models.BooleanField(default=False)
custom_field = models.CharField(max_length=255, null=True, blank=True)
internal_reference = models.TextField(
@@ -2636,7 +2355,6 @@ class InvoiceAddress(models.Model):
)
objects = ScopedManager(organizer='order__event__organizer')
profiles = ScopedManager(organizer='customer__organizer')
def save(self, **kwargs):
if self.order:
@@ -2649,20 +2367,6 @@ class InvoiceAddress(models.Model):
self.name_parts = {}
super().save(**kwargs)
def describe(self):
parts = [
self.company,
self.name,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name,
self.vat_id,
self.custom_field,
self.internal_reference,
(_('Beneficiary') + ': ' + self.beneficiary) if self.beneficiary else '',
]
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
@property
def is_empty(self):
return (
@@ -2698,30 +2402,6 @@ class InvoiceAddress(models.Model):
raise TypeError("Invalid name given.")
return scheme['concatenation'](self.name_parts).strip()
def for_js(self):
d = {}
if self.name_parts:
if '_scheme' in self.name_parts:
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
for i, (k, l, w) in enumerate(scheme['fields']):
d[f'name_parts_{i}'] = self.name_parts.get(k) or ''
d.update({
'company': self.company,
'is_business': self.is_business,
'street': self.street,
'zipcode': self.zipcode,
'city': self.city,
'country': str(self.country) if self.country else None,
'state': str(self.state) if self.state else None,
'vat_id': self.vat_id,
'custom_field': self.custom_field,
'internal_reference': self.internal_reference,
'beneficiary': self.beneficiary,
})
return d
def cachedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)

View File

@@ -36,7 +36,6 @@ import string
from datetime import date, datetime, time
import pytz
from django.conf import settings
from django.core.mail import get_connection
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
@@ -98,21 +97,10 @@ class Organizer(LoggedModel):
return self.name
def save(self, *args, **kwargs):
is_new = not self.pk
obj = super().save(*args, **kwargs)
if is_new:
self.set_defaults()
else:
self.get_cache().clear()
self.get_cache().clear()
return obj
def set_defaults(self):
"""
This will be called after organizer creation.
This way, we can use this to introduce new default settings to pretix that do not affect existing organizers.
"""
self.settings.cookie_consent = True
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -196,15 +184,16 @@ class Organizer(LoggedModel):
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the organizer's settings.
"""
from pretix.base.email import CustomSMTPBackend
if self.settings.smtp_use_custom or force_custom:
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False, timeout=timeout)
return CustomSMTPBackend(host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False, timeout=timeout)
else:
return get_connection(fail_silently=False)

View File

@@ -25,6 +25,7 @@ from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.formats import localize
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.fields import I18nCharField
from i18nfield.strings import LazyI18nString
@@ -92,7 +93,7 @@ TAXED_ZERO = TaxedPrice(
EU_COUNTRIES = {
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT',
'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB'
}
EU_CURRENCIES = {
'BG': 'BGN',
@@ -105,21 +106,17 @@ EU_CURRENCIES = {
'RO': 'RON',
'SE': 'SEK'
}
VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH'}
def is_eu_country(cc):
cc = str(cc)
return cc in EU_COUNTRIES
def ask_for_vat_id(cc):
cc = str(cc)
return cc in VAT_ID_COUNTRIES
if cc == 'GB':
return now().astimezone(get_current_timezone()).year <= 2020
else:
return cc in EU_COUNTRIES
def cc_to_vat_prefix(country_code):
country_code = str(country_code)
if country_code == 'GR':
return 'EL'
return country_code
@@ -165,13 +162,10 @@ class TaxRule(LoggedModel):
pass
def allow_delete(self):
from pretix.base.models.orders import (
OrderFee, OrderPosition, Transaction,
)
from pretix.base.models.orders import OrderFee, OrderPosition
return (
not Transaction.objects.filter(tax_rule=self, order__event=self.event).exists()
and not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
and not self.event.items.filter(tax_rule=self).exists()
and self.event.settings.tax_rate_default != self

View File

@@ -38,7 +38,7 @@ from decimal import ROUND_HALF_UP, Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import connection, models
from django.db import models
from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils.crypto import get_random_string
@@ -74,55 +74,6 @@ def generate_code(prefix=None):
return code
def generate_codes(organizer, num=1, prefix=None):
codes = set()
batch_size = 500
if 'postgres' in settings.DATABASES['default']['ENGINE']:
batch_size = 5_000
"""
We're trying to check if any of the requested codes already exists in the database. Generally, this is a
SELECT code FROM voucher WHERE code IN (…)
query. However, it turns out that this query get's rather slow if an organizer has lots of vouchers, even
with a organizer with just over 50_000 vouchers, we've seen that creating 20_000 new voucher codes took
just over 30 seconds. There's another way of doing this query on PostgreSQL, which is joining with a
temporary table
SELECT code FROM voucher INNER JOIN (VALUES …) vals(v) ON (code = v)
This is significantly faster, inserting 20_000 vouchers now takes 2-3s instead of 31s on the same dataset.
It's still slow, and removing the JOIN to the event table doesn't significantly speed it up. We might need
an entirely different approach at some point.
"""
while len(codes) < num:
new_codes = set()
for i in range(min(num - len(codes), batch_size)): # Work around SQLite's SQLITE_MAX_VARIABLE_NUMBER
new_codes.add(_generate_random_code(prefix=prefix))
if 'postgres' in settings.DATABASES['default']['ENGINE']:
with connection.cursor() as cursor:
args = list(new_codes) + [organizer.pk]
tmptable = "VALUES " + (", ".join(['(%s)'] * len(new_codes)))
cursor.execute(
f'SELECT code '
f'FROM "{Voucher._meta.db_table}" '
f'INNER JOIN ({tmptable}) vals(v) ON ("{Voucher._meta.db_table}"."code" = "v")'
f'INNER JOIN "{Event._meta.db_table}" ON ("{Voucher._meta.db_table}"."event_id" = "{Event._meta.db_table}"."id") '
f'WHERE "{Event._meta.db_table}"."organizer_id" = %s',
args
)
for row in cursor.fetchall():
new_codes.remove(row[0])
else:
new_codes -= set([v['code'] for v in Voucher.objects.filter(code__in=new_codes).values('code')])
codes |= new_codes
return list(codes)
class Voucher(LoggedModel):
"""
A Voucher can reserve ticket quota or allow special prices.

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