Compare commits

..

3 Commits

Author SHA1 Message Date
Raphael Michel
039a00edd4 Bump to 4.3.1 2022-01-26 13:43:38 +01:00
Raphael Michel
a3fb4f7868 [SECURITY] Make redirect view dependent on referer 2022-01-26 13:43:33 +01:00
Raphael Michel
7a3a6399e9 [SECURITY] Fix (non-exploitable) XSS issue 2022-01-26 13:43:33 +01:00
374 changed files with 108002 additions and 215832 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

@@ -18,17 +18,17 @@ jobs:
name: Tests
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9"]
python-version: [3.6, 3.7, 3.8]
database: [sqlite, postgres, mysql]
exclude:
- database: mysql
python-version: "3.8"
python-version: 3.7
- database: sqlite
python-version: 3.7
- database: mysql
python-version: "3.9"
python-version: 3.6
- database: sqlite
python-version: "3.7"
- database: sqlite
python-version: "3.8"
python-version: 3.6
steps:
- uses: actions/checkout@v2
- uses: getong/mariadb-action@v1.1
@@ -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

@@ -220,30 +220,12 @@ Example::
``user``, ``password``
The SMTP user data to use for the connection. Empty by default.
``tls``, ``ssl``
Use STARTTLS or SSL for the SMTP connection. Off by default.
``from``
The email address to set as ``From`` header in outgoing emails by the system.
Default: ``pretix@localhost``
``from_notifications``
The email address to set as ``From`` header in admin notification emails by the system.
Defaults to the value of ``from``.
``from_organizers``
The email address to set as ``From`` header in outgoing emails by the system sent on behalf of organizers.
Defaults to the value of ``from``.
``custom_sender_verification_required``
If this is on (the default), organizers need to verify email addresses they want to use as senders in their event.
``custom_sender_spf_string``
If this is set to a valid SPF string, pretix will show a warning if organizers use a sender address from a domain
that does not include this value.
``custom_smtp_allow_private_networks``
If this is off (the default), custom SMTP servers cannot be private network addresses.
``tls``, ``ssl``
Use STARTTLS or SSL for the SMTP connection. Off by default.
``admins``
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
@@ -300,7 +282,7 @@ You can use an existing memcached server as pretix's caching backend::
``location``
The location of memcached, either a host:port combination or a socket file.
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
shared memcached instance, not multiple ones, because cache invalidations would not be
@@ -463,10 +445,8 @@ You can configure the maximum file size for uploading various files::
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 upload size for email attachments 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

@@ -256,8 +256,6 @@ create an event and start selling tickets!
You should probably read :ref:`maintainance` next.
.. _`docker_updates`:
Updates
-------
@@ -273,8 +271,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

@@ -142,7 +142,7 @@ If you're running MySQL, also install the client library::
(venv)$ pip3 install mysqlclient
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
We also need to create a data directory::
@@ -259,14 +259,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
}
location /static/ {
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
access_log off;
expires 365d;
add_header Cache-Control "public";
}
}
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
above with your python version.
We recommend reading about setting `strong encryption settings`_ for your web server.
@@ -280,8 +280,6 @@ create an event and start selling tickets!
You should probably read :ref:`maintainance` next.
.. _`manual_updates`:
Updates
-------
@@ -296,7 +294,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

@@ -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

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

View File

@@ -58,12 +58,6 @@ lines list of objects The actual invo
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee).
├ subevent integer Event series date ID used to create this line. Note that everything
about the subevent might have changed since the creation
of the invoice. Can be ``null`` for all invoice lines
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee) as well as for all events that are not a series.
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
all invoice lines
@@ -84,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
@@ -122,14 +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.
.. versionchanged:: 4.6
The attribute ``lines.subevent`` has been added.
Endpoints
---------
@@ -195,12 +175,10 @@ Endpoints
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"subevent": null,
"fee_type": null,
"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",
@@ -285,12 +263,10 @@ Endpoints
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"subevent": null,
"fee_type": null,
"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

@@ -24,12 +24,7 @@ active boolean If ``false``, t
description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``.
position integer An integer, used for sorting
require_approval boolean If ``true``, orders with this variation will need to be
approved by the event organizer before they can be
paid.
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.
@@ -79,9 +74,7 @@ Endpoints
"en": "S"
},
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
@@ -101,9 +94,7 @@ Endpoints
"en": "L"
},
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"description": {},
"position": 1,
@@ -152,9 +143,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
@@ -189,9 +178,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
@@ -216,9 +203,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,
@@ -274,9 +259,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"sales_channels": ["web"],
"available_from": null,

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,8 +105,6 @@ 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
@@ -159,10 +155,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
-----

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

@@ -16,22 +16,15 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the tax rule
name multi-lingual string The tax rules' name
internal_name string An optional name that is only used in the backend
rate decimal (string) Tax rate in percent
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
home_country string Merchant country (required for reverse charge), can be
``null`` or empty string
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
rules keep the gross price constant (default is ``false``)
===================================== ========================== =======================================================
.. versionchanged:: 4.6
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
Endpoints
---------
@@ -63,11 +56,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
]
@@ -103,11 +94,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
@@ -151,11 +140,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
@@ -198,11 +185,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "20.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}

View File

@@ -2,7 +2,7 @@ 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
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very
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.

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

@@ -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]"

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

@@ -203,4 +203,4 @@ Then, please contact support@pretix.eu and we will enable DKIM for your domain o
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax

View File

@@ -309,10 +309,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.1"
__version__ = "4.3.1"

View File

@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta:
model = TaxRule
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
class EventSettingsSerializer(SettingsSerializer):
@@ -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',
@@ -713,6 +712,7 @@ class EventSettingsSerializer(SettingsSerializer):
'ticket_download_require_validated_email',
'ticket_secret_length',
'mail_prefix',
'mail_from',
'mail_from_name',
'mail_attach_ical',
'mail_attach_tickets',
@@ -733,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',
@@ -763,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',

View File

@@ -58,9 +58,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
@@ -75,9 +74,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
@@ -177,7 +175,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',)

View File

@@ -1404,7 +1404,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
state=OrderPayment.PAYMENT_STATE_CREATED
)
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values())
return order
@@ -1426,9 +1425,9 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceLine
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
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

@@ -296,14 +296,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'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

@@ -430,13 +430,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
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:

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(

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',
)
)
)

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

@@ -146,8 +146,7 @@ class NativeAuthBackend(BaseAuthBackend):
d = OrderedDict([
('email', forms.EmailField(label=_("E-mail"), max_length=254,
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
max_length=4096)),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
])
return d

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:
@@ -540,22 +539,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

@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import io
import re
import tempfile
from collections import OrderedDict, namedtuple
from decimal import Decimal
@@ -45,13 +46,26 @@ from django.conf import settings
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 pretix.base.models import Event
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
def excel_safe(val):
if isinstance(val, Cell):
return val
if not isinstance(val, KNOWN_TYPES):
val = str(val)
if isinstance(val, bytes):
val = val.decode("utf-8", errors="ignore")
if isinstance(val, str):
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
return val
class BaseExporter:
@@ -214,7 +228,7 @@ class ListExporter(BaseExporter):
pass
def _render_xlsx(self, form_data, output_file=None):
wb = SafeWorkbook(write_only=True)
wb = Workbook(write_only=True)
ws = wb.create_sheet()
self.prepare_xlsx_sheet(ws)
try:
@@ -228,7 +242,7 @@ class ListExporter(BaseExporter):
total = line.total
continue
ws.append([
val for val in line
excel_safe(val) for val in line
])
if total:
counter += 1
@@ -333,7 +347,7 @@ class MultiSheetListExporter(ListExporter):
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None):
wb = SafeWorkbook(write_only=True)
wb = Workbook(write_only=True)
n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets):
ws = wb.create_sheet(str(l))
@@ -347,7 +361,8 @@ class MultiSheetListExporter(ListExporter):
total = line.total
continue
ws.append([
val for val in line
excel_safe(val)
for val in line
])
if total:
counter += 1

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 (
@@ -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

@@ -154,7 +154,6 @@ class RegistrationForm(forms.Form):
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
@@ -162,7 +161,6 @@ class RegistrationForm(forms.Form):
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
max_length=4096,
required=True
)
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
@@ -206,13 +204,11 @@ class PasswordRecoverForm(forms.Form):
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput,
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput,
max_length=4096,
widget=forms.PasswordInput
)
def __init__(self, user_id=None, *args, **kwargs):

View File

@@ -37,10 +37,13 @@ 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
@@ -49,7 +52,7 @@ 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 +76,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 +154,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'
@@ -333,41 +334,23 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def guess_country(event):
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language_without_region()
country = event.settings.region or event.settings.invoice_address_from_country
if not country:
country = get_country_by_locale(get_language_without_region())
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
# TODO: does this actually work?
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale.upper() in valid_countries:
country = Country(locale.upper())
return country
def get_country_by_locale(locale):
country = None
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
# TODO: does this actually work?
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale.upper() in valid_countries:
country = Country(locale.upper())
return country
def guess_phone_prefix(event):
with language(get_babel_locale()):
country = str(guess_country(event))
return get_phone_prefix(country)
def get_phone_prefix(country):
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if country in values:
return prefix
return None
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
@@ -692,7 +675,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required,
min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max,
help_text=help_text,
help_text=q.help_text,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_STRING:
@@ -798,26 +781,25 @@ class BaseQuestionsForm(forms.Form):
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
if initial:
with language(get_babel_locale()):
default_country = guess_country(event)
default_prefix = None
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if str(default_country) in values:
default_prefix = prefix
try:
initial = PhoneNumber().from_string(initial.answer)
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
except NumberParseException:
initial = None
if not initial:
phone_prefix = guess_phone_prefix(event)
if phone_prefix:
initial = "+{}.".format(phone_prefix)
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.question = q
if answers:
# Cache the answer object for later use
@@ -888,12 +870,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
@@ -925,7 +901,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 = {
@@ -945,18 +921,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()
@@ -988,7 +952,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']
@@ -1038,7 +1002,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'):
@@ -1061,23 +1025,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

@@ -86,6 +86,14 @@ class TimePickerWidget(forms.TimeInput):
class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs):
# Browsers can't recognize that the server already has a file uploaded
# Don't mark this input as being required if we already have an answer
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
attrs = kwargs.get('attrs', {})
if kwargs.get('required') and kwargs.get('initial'):
attrs.update({'required': None})
kwargs.update({'attrs': attrs})
self.position = kwargs.pop('position')
self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer')
@@ -117,15 +125,6 @@ class UploadedFileWidget(forms.ClearableFileInput):
'answer': self.answer.pk,
})
def get_context(self, name, value, attrs):
# Browsers can't recognize that the server already has a file uploaded
# Don't mark this input as being required if we already have an answer
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
ctx = super().get_context(name, value, attrs)
if ctx['widget']['is_initial']:
ctx['widget']['attrs']['required'] = False
return ctx
def format_value(self, value):
if self.is_initial(value):
return self.FakeFile(value, self.position, self.event, self.answer)

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),
]

View File

@@ -1,107 +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, Count, 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)
),
position_cnt=Case(
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
default=Coalesce(
Subquery(
OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Count('*')).values('p'),
output_field=models.IntegerField()
), Value(0), output_field=models.IntegerField()
),
output_field=models.IntegerField()
),
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)
),
tx_cnt=Coalesce(
Subquery(
Transaction.objects.filter(
order=OuterRef('pk'),
item__isnull=False,
).order_by().values('order').annotate(p=Sum(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'),
tx_cnt=F('position_cnt')
).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') \
and o.tx_cnt == o.position_cnt:
# 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}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
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

@@ -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,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

@@ -1,18 +0,0 @@
# Generated by Django 3.2.9 on 2021-12-13 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0204_orderposition_backfill_is_bundled'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='require_approval',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 3.2.9 on 2022-01-12 10:59
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0205_itemvariation_require_approval'),
]
operations = [
migrations.AddField(
model_name='customer',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.2.4 on 2022-01-19 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0206_customer_phone'),
]
operations = [
migrations.AddField(
model_name='taxrule',
name='internal_name',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='taxrule',
name='keep_gross_if_rate_changes',
field=models.BooleanField(default=False),
),
]

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

@@ -25,11 +25,9 @@ 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_scopes import ScopedManager, scopes_disabled
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
@@ -46,7 +44,6 @@ class Customer(LoggedModel):
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
identifier = models.CharField(max_length=190, db_index=True, unique=True)
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
password = models.CharField(verbose_name=_('Password'), max_length=128)
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = models.JSONField(default=dict)
@@ -89,7 +86,6 @@ class Customer(LoggedModel):
self.name_parts = {}
self.name_cached = ''
self.email = None
self.phone = None
self.save()
self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None)
@@ -187,12 +183,6 @@ class Customer(LoggedModel):
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(

View File

@@ -57,7 +57,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
@@ -146,7 +145,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,17 +153,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)
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
def get_time_range_display(self, tz=None, force_show_end=False) -> str:
"""
@@ -565,8 +555,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):
@@ -665,22 +653,21 @@ class Event(EventMixin, LoggedModel):
return locking.LockManager(self)
def get_mail_backend(self, timeout=None):
def get_mail_backend(self, timeout=None, force_custom=False):
"""
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:
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)
if self.settings.smtp_use_custom or force_custom:
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)
@@ -1434,7 +1421,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):
@@ -764,9 +758,6 @@ class ItemVariation(models.Model):
:type default_price: decimal.Decimal
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
approval by an administrator
:type require_approval: bool
"""
item = models.ForeignKey(
Item,
@@ -802,13 +793,6 @@ class ItemVariation(models.Model):
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
require_approval = models.BooleanField(
verbose_name=_('Require approval'),
default=False,
help_text=_('If this variation is part of an order, the order will be put into an "approval" state and '
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
'discounted tickets that are only available to specific groups.'),
)
require_membership = models.BooleanField(
verbose_name=_('Require a valid membership'),
default=False,
@@ -818,12 +802,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,
@@ -842,7 +820,7 @@ class ItemVariation(models.Model):
blank=True,
)
hide_without_voucher = models.BooleanField(
verbose_name=_('Show only if a matching voucher is redeemed.'),
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.')
@@ -968,13 +946,10 @@ 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()
)

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')
@@ -453,27 +444,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'])
@@ -581,7 +552,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 +577,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()
@@ -950,7 +917,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 +961,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 +999,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 +1224,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)
@@ -1442,15 +1354,6 @@ class AbstractPosition(models.Model):
lines = [r.strip() for r in lines if r]
return '\n'.join(lines).strip()
def requires_approval(self, invoice_address=None):
if self.item.require_approval:
return True
if self.variation and self.variation.require_approval:
return True
if self.item.tax_rule and self.item.tax_rule._require_approval(invoice_address):
return True
return False
class OrderPayment(models.Model):
"""
@@ -1557,7 +1460,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:
@@ -1565,7 +1468,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'])
@@ -1579,8 +1481,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):
"""
@@ -1633,6 +1533,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:
@@ -1676,15 +1580,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
@@ -1692,8 +1588,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):
@@ -2062,12 +1958,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)
@@ -2106,15 +1996,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):
@@ -2129,7 +2010,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
@@ -2180,12 +2061,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")
@@ -2229,7 +2104,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
@@ -2295,14 +2169,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()
@@ -2326,7 +2192,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:
@@ -2346,7 +2212,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:
@@ -2359,7 +2225,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,
@@ -2367,7 +2232,6 @@ class OrderPosition(AbstractPosition):
invoices=invoices,
attach_tickets=attach_tickets,
attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
except SendMailException:
raise
@@ -2399,151 +2263,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
@@ -2582,6 +2301,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')
@@ -2631,7 +2351,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(

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
@@ -191,20 +179,21 @@ class Organizer(LoggedModel):
e.delete()
self.teams.all().delete()
def get_mail_backend(self, timeout=None):
def get_mail_backend(self, timeout=None, force_custom=False):
"""
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the organizer's settings.
"""
if self.settings.smtp_use_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)
from pretix.base.email import CustomSMTPBackend
if self.settings.smtp_use_custom or force_custom:
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
@@ -81,15 +82,6 @@ class TaxedPrice:
name=self.name,
)
def __eq__(self, other):
return (
self.gross == other.gross and
self.net == other.net and
self.tax == other.tax and
self.rate == other.rate and
self.name == other.name
)
TAXED_ZERO = TaxedPrice(
gross=Decimal('0.00'),
@@ -101,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',
@@ -114,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
@@ -136,13 +124,8 @@ def cc_to_vat_prefix(country_code):
class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
internal_name = models.CharField(
verbose_name=_('Internal name'),
max_length=190,
null=True, blank=True,
)
name = I18nCharField(
verbose_name=_('Official name'),
verbose_name=_('Name'),
help_text=_('Should be short, e.g. "VAT"'),
max_length=190,
)
@@ -155,10 +138,6 @@ class TaxRule(LoggedModel):
verbose_name=_("The configured product prices include the tax amount"),
default=True,
)
keep_gross_if_rate_changes = models.BooleanField(
verbose_name=_("Keep gross amount constant if the tax rate changes based on the invoice address"),
default=False,
)
eu_reverse_charge = models.BooleanField(
verbose_name=_("Use EU reverse charge taxation rules"),
default=False,
@@ -183,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
@@ -216,8 +192,6 @@ class TaxRule(LoggedModel):
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
if self.eu_reverse_charge:
s += ' ({})'.format(_('reverse charge enabled'))
if self.internal_name:
return f'{self.internal_name} ({s})'
return str(s)
@property
@@ -231,7 +205,7 @@ class TaxRule(LoggedModel):
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
return Decimal(rule.get('rate'))
return Decimal(self.rate)
@@ -248,19 +222,13 @@ class TaxRule(LoggedModel):
rate = override_tax_rate
elif invoice_address:
adjust_rate = self.tax_rate_for(invoice_address)
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price or self.keep_gross_if_rate_changes) and base_price_is == 'gross':
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
rate = adjust_rate
elif adjust_rate != rate:
if self.keep_gross_if_rate_changes:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.gross
base_price_is = 'gross'
subtract_from_gross = Decimal('0.00')
else:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.net
base_price_is = 'net'
subtract_from_gross = Decimal('0.00')
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.net
base_price_is = 'net'
subtract_from_gross = Decimal('0.00')
rate = adjust_rate
if rate == Decimal('0.00'):
@@ -363,19 +331,12 @@ class TaxRule(LoggedModel):
return False
def _require_approval(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'require_approval':
return True
return False
def _tax_applicable(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
return rule.get('action', 'vat') in ('vat', 'require_approval')
return rule.get('action', 'vat') == 'vat'
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!

View File

@@ -191,15 +191,6 @@ class BasePaymentProvider:
"""
return self.verbose_name
@property
def confirm_button_name(self) -> str:
"""
A label for the "confirm" button on the last page before a payment is started. This
is **not** used in the regular checkout flow, but only if the payment method is selected
for an existing order later on.
"""
return _("Pay now")
@property
def identifier(self) -> str:
"""

View File

@@ -273,11 +273,6 @@ class RelativeDateTimeField(forms.MultiValueField):
minutes_before=None
))
def has_changed(self, initial, data):
if initial is None:
initial = self.widget.decompress(initial)
return super().has_changed(initial, data)
def clean(self, value):
if value[0] == 'absolute' and not value[1]:
raise ValidationError(self.error_messages['incomplete'])

View File

@@ -426,10 +426,10 @@ class CartManager:
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
@@ -1106,11 +1106,10 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
if pos.tax_rate != rate:
if not pos.item.tax_rule.keep_gross_if_rate_changes:
current_net = pos.price - pos.tax_value
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
totaldiff += new_gross - pos.price
pos.price = new_gross
current_net = pos.price - pos.tax_value
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
totaldiff += new_gross - pos.price
pos.price = new_gross
pos.includes_tax = rate != Decimal('0.00')
pos.override_tax_rate = rate
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])

View File

@@ -736,11 +736,7 @@ def process_exit_all(sender, **kwargs):
exit_all_at__isnull=False
).select_related('event', 'event__organizer')
for cl in qs:
positions = cl.positions_inside.filter(
Q(last_exit__isnull=True) | Q(last_exit__lte=cl.exit_all_at),
last_entry__lte=cl.exit_all_at,
)
for p in positions:
for p in cl.positions_inside:
with scope(organizer=cl.event.organizer):
ci = Checkin.objects.create(
position=p, list=cl, auto_checked_in=True, type=Checkin.TYPE_EXIT, datetime=cl.exit_all_at
@@ -752,9 +748,6 @@ def process_exit_all(sender, **kwargs):
cl.event.settings.delete(f'autocheckin_dst_hack_{cl.pk}')
try:
cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time()), cl.event.timezone)
except pytz.exceptions.AmbiguousTimeError:
cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time()), cl.event.timezone,
is_dst=False)
except pytz.exceptions.NonExistentTimeError:
cl.event.settings.set(f'autocheckin_dst_hack_{cl.pk}', True)
d += timedelta(hours=1)

View File

@@ -69,10 +69,6 @@ from pretix.helpers.models import modelcopy
logger = logging.getLogger(__name__)
def _location_oneliner(loc):
return ', '.join([l.strip() for l in loc.splitlines() if l and l.strip()])
@transaction.atomic
def build_invoice(invoice: Invoice) -> Invoice:
invoice.locale = invoice.event.settings.get('invoice_language', invoice.event.settings.locale)
@@ -102,7 +98,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
payment = ""
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
if payment:
payment += "<br /><br />"
payment += "<br />"
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
)
@@ -180,38 +176,19 @@ def build_invoice(invoice: Invoice) -> Invoice:
reverse_charge = False
positions.sort(key=lambda p: p.sort_key)
fees = list(invoice.order.fees.all())
locations = {
str((p.subevent or invoice.event).location) if (p.subevent or invoice.event).location else None
for p in positions
}
if fees and invoice.event.has_subevents:
locations.add(None)
tax_texts = []
if invoice.event.settings.invoice_event_location and len(locations) == 1 and list(locations)[0] is not None:
tax_texts.append(pgettext("invoice", "Event location: {location}").format(
location=_location_oneliner(str(list(locations)[0]))
))
for i, p in enumerate(positions):
if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c:
continue
location = str((p.subevent or invoice.event).location) if (p.subevent or invoice.event).location else None
desc = str(p.item.name)
if p.variation:
desc += " - " + str(p.variation.value)
if p.addon_to_id:
desc = " + " + desc
if invoice.event.settings.invoice_attendee_name and p.attendee_name:
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(
name=p.attendee_name
)
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(name=p.attendee_name)
for recv, resp in invoice_line_text.send(sender=invoice.event, position=p):
if resp:
desc += "<br/>" + resp
@@ -227,12 +204,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
if invoice.event.has_subevents:
desc += "<br />" + pgettext("subevent", "Date: {}").format(p.subevent)
if invoice.event.settings.invoice_event_location and location and len(locations) > 1:
desc += "<br />" + pgettext("invoice", "Event location: {location}").format(
location=_location_oneliner(location)
)
InvoiceLine.objects.create(
position=i,
invoice=invoice,
@@ -245,7 +216,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
attendee_name=p.attendee_name if invoice.event.settings.invoice_attendee_name else None,
event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to,
event_location=location if invoice.event.settings.invoice_event_location else None,
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
)
@@ -258,7 +228,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
tax_texts.append(tax_text)
offset = len(positions)
for i, fee in enumerate(fees):
for i, fee in enumerate(invoice.order.fees.all()):
if fee.fee_type == OrderFee.FEE_TYPE_OTHER and fee.description:
fee_title = fee.description
else:
@@ -272,12 +242,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
gross_value=fee.value,
event_date_from=None if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=None if invoice.event.has_subevents else invoice.event.date_to,
event_location=(
None if invoice.event.has_subevents
else (str(invoice.event.location)
if invoice.event.settings.invoice_event_location and invoice.event.location
else None)
),
tax_value=fee.tax_value,
tax_rate=fee.tax_rate,
tax_name=fee.tax_rule.name if fee.tax_rule else '',
@@ -327,7 +291,6 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.payment_provider_text = ''
cancellation.file = None
cancellation.sent_to_organizer = None
cancellation.sent_to_customer = None
with language(invoice.locale, invoice.event.settings.region):
cancellation.invoice_from = invoice.event.settings.get('invoice_address_from')
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
@@ -383,8 +346,8 @@ def invoice_pdf_task(invoice: int):
i.file.delete()
with language(i.locale, i.event.settings.region):
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
i.file.save(fname, ContentFile(fcontent), save=False)
i.save(update_fields=['file'])
i.file.save(fname, ContentFile(fcontent))
i.save()
return i.file.name

View File

@@ -32,10 +32,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 hashlib
import inspect
import logging
import mimetypes
import os
import re
import smtplib
@@ -52,14 +51,13 @@ from bs4 import BeautifulSoup
from celery import chain
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.mail import (
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
)
from django.core.mail.message import SafeMIMEText
from django.db import transaction
from django.template.loader import get_template
from django.utils.timezone import now, override
from django.utils.timezone import override
from django.utils.translation import gettext as _, pgettext
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
@@ -75,7 +73,6 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_ical
@@ -97,7 +94,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
attach_ical=False, attach_cached_files: Sequence = None):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -145,8 +142,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param attach_cached_files: A list of cached file to attach to this email.
:param attach_other_files: A list of file paths on our storage to attach.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend.
"""
@@ -217,8 +212,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
bcc.append(bcc_mail.strip())
if settings_holder.settings.mail_from not in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \
and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = settings_holder.settings.contact_mail
prefix = settings_holder.settings.get('mail_prefix')
@@ -307,7 +301,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
organizer=organizer.pk if organizer else None,
customer=customer.pk if customer else None,
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
attach_other_files=attach_other_files,
)
if invoices:
@@ -345,8 +338,7 @@ class CustomEmail(EmailMultiAlternatives):
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
attach_other_files: List[str] = None) -> bool:
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool:
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
@@ -446,7 +438,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
invoices_sent = []
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
@@ -458,25 +449,10 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
inv.file.file.read(),
'application/pdf'
)
invoices_sent.append(inv)
except:
logger.exception('Could not attach invoice to email')
pass
if attach_other_files:
for fname in attach_other_files:
ftype, _ = mimetypes.guess_type(fname)
data = default_storage.open(fname).read()
try:
email.attach(
clean_filename(os.path.basename(fname)),
data,
ftype
)
except:
logger.exception('Could not attach file to email')
pass
if attach_cached_files:
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
if cf.file:
@@ -496,30 +472,10 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
try:
backend.send_messages([email])
except (smtplib.SMTPResponseException, smtplib.SMTPSenderRefused) as e:
if e.smtp_code in (101, 111, 421, 422, 431, 432, 442, 447, 452):
if e.smtp_code == 432 and settings.HAS_REDIS:
# This is likely Microsoft Exchange Online which has a pretty bad rate limit of max. 3 concurrent
# SMTP connections which is *easily* exceeded with many celery threads. Just retrying with exponential
# backoff won't be good enough if we have a lot of emails, instead we'll need to make sure our retry
# intervals scatter such that the email won't all be retried at the same time again and cause the
# same problem.
# See also https://docs.microsoft.com/en-us/exchange/troubleshoot/send-emails/smtp-submission-improvements
from django_redis import get_redis_connection
redis_key = "pretix_mail_retry_" + hashlib.sha1(f"{getattr(backend, 'username', '_')}@{getattr(backend, 'host', '_')}".encode()).hexdigest()
rc = get_redis_connection("redis")
cnt = rc.incr(redis_key)
rc.expire(redis_key, 300)
max_retries = 10
retry_after = 30 + cnt * 10
else:
# Most likely some other kind of temporary failure, retry again (but pretty soon)
max_retries = 5
retry_after = 2 ** (self.request.retries * 3) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
# Most likely temporary, retry again (but pretty soon)
try:
self.retry(max_retries=max_retries, countdown=retry_after)
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
except MaxRetriesExceededError:
if log_target:
log_target.log_action(
@@ -602,10 +558,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
)
logger.exception('Error sending email')
raise SendMailException('Failed to send an email to {}.'.format(to))
else:
for i in invoices_sent:
i.sent_to_customer = now()
i.save(update_fields=['sent_to_customer'])
def mail_send(*args, **kwargs):

View File

@@ -148,7 +148,7 @@ def send_notification_mail(notification: Notification, user: User):
),
'body': body_plain,
'html': body_html,
'sender': settings.MAIL_FROM_NOTIFICATIONS,
'sender': settings.MAIL_FROM,
'headers': {},
'user': user.pk
})

View File

@@ -33,7 +33,6 @@ from pretix.base.models import (
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
User,
)
from pretix.base.models.orders import Transaction
from pretix.base.orderimport import get_all_columns
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.tasks import ProfiledEventTask
@@ -147,7 +146,6 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
# quota check?
with event.lock():
with transaction.atomic():
save_transactions = []
for o in orders:
o.total = sum([c.price for c in o._positions]) # currently no support for fees
if o.total == Decimal('0.00'):
@@ -189,8 +187,6 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
user=user,
data={'source': 'import'}
)
save_transactions += o.create_transactions(is_new=True, fees=[], positions=o._positions, save=False)
Transaction.objects.bulk_create(save_transactions)
for o in orders:
with language(o.locale, event.settings.region):

View File

@@ -35,7 +35,7 @@
import json
import logging
from collections import Counter, defaultdict, namedtuple
from collections import Counter, namedtuple
from datetime import datetime, time, timedelta
from decimal import Decimal
from typing import List, Optional
@@ -46,7 +46,7 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
)
from django.db.models.functions import Coalesce, Greatest
from django.db.transaction import get_connection
@@ -73,7 +73,7 @@ from pretix.base.models.orders import (
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.models.tax import TaxRule
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret
@@ -122,7 +122,8 @@ error_messages = {
'from your cart.'),
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
'removed this item from your cart.'),
'voucher_required': _('You need a valid voucher code to order one of the products.'),
'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this '
'item from your cart.'),
'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The '
'affected positions have been removed from your cart.'),
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
@@ -130,13 +131,6 @@ error_messages = {
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
'country_blocked': _('One of the selected products is not available in the selected country.'),
'not_for_sale': _('You selected a product which is not available for sale.'),
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
'product %(base)s.'),
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
}
logger = logging.getLogger(__name__)
@@ -187,7 +181,6 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
for m in position.granted_memberships.all():
m.canceled = False
m.save()
order.create_transactions()
else:
raise OrderError(is_available)
@@ -209,7 +202,6 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
if new_date < now():
raise OrderError(_('The new expiry date needs to be in the future.'))
@transaction.atomic
def change(was_expired=True):
order.expires = new_date
if was_expired:
@@ -229,7 +221,6 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
num_invoices = order.invoices.filter(is_cancellation=False).count()
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
generate_invoice(order)
order.create_transactions()
if order.status == Order.STATUS_PENDING:
change(was_expired=False)
@@ -271,7 +262,6 @@ def mark_order_expired(order, user=None, auth=None):
i = order.invoices.filter(is_cancellation=False).last()
if i and not i.refered.exists():
generate_cancellation(i)
order.create_transactions()
order_expired.send(order.event, order=order)
return order
@@ -290,7 +280,6 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
order.require_approval = False
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
order.save(update_fields=['require_approval', 'expires'])
order.create_transactions()
order.log_action('pretix.event.order.approved', user=user, auth=auth)
if order.total == Decimal('0.00'):
@@ -363,7 +352,6 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
order.create_transactions()
order_denied.send(order.event, order=order)
@@ -484,8 +472,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
data={'cancellation_fee': cancellation_fee})
order.cancellation_requests.all().delete()
order.create_transactions()
if send_mail:
email_template = order.event.settings.mail_text_order_canceled
with language(order.locale, order.event.settings.region):
@@ -700,7 +686,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = Decimal('0.00')
bundled_sum = 0
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
@@ -856,7 +842,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
total=total,
testmode=True if sales_channel.testmode_supported and event.testmode else False,
meta_info=json.dumps(meta_info or {}),
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
require_approval=any(p.item.require_approval for p in positions),
sales_channel=sales_channel.identifier,
customer=customer,
)
@@ -918,8 +904,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
fee=pf
)
orderpositions = OrderPosition.transform_cart_positions(positions, order)
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
OrderPosition.transform_cart_positions(positions, order)
order.log_action('pretix.event.order.placed')
if order.require_approval:
order.log_action('pretix.event.order.placed.require_approval')
@@ -941,10 +926,7 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical,
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
attach_ical=event.settings.mail_attach_ical
)
except SendMailException:
logger.exception('Order received email could not be sent')
@@ -961,10 +943,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
invoices=[],
attach_tickets=True,
position=position,
attach_ical=event.settings.mail_attach_ical,
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
attach_ical=event.settings.mail_attach_ical
)
except SendMailException:
logger.exception('Order received email could not be sent to attendee')
@@ -1273,15 +1252,15 @@ class OrderChangeManager:
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price', 'price_diff'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff'))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
@@ -1398,7 +1377,7 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._operations.append(self.PriceOperation(position, price, price.gross - position.price))
self._operations.append(self.PriceOperation(position, price))
def change_tax_rule(self, position_or_fee, tax_rule: TaxRule):
self._operations.append(self.TaxRuleOperation(position_or_fee, tax_rule))
@@ -1438,28 +1417,28 @@ class OrderChangeManager:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate)
self._totaldiff += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._operations.append(self.PriceOperation(pos, new_tax))
def cancel_fee(self, fee: OrderFee):
self._totaldiff -= fee.value
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
self._operations.append(self.CancelFeeOperation(fee))
self._invoice_dirty = True
def add_fee(self, fee: OrderFee):
self._totaldiff += fee.value
self._invoice_dirty = True
self._operations.append(self.AddFeeOperation(fee, fee.value))
self._operations.append(self.AddFeeOperation(fee))
def change_fee(self, fee: OrderFee, value: Decimal):
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross')
self._totaldiff += value.gross - fee.value
self._invoice_dirty = True
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
self._operations.append(self.FeeValueOperation(fee, value))
def cancel(self, position: OrderPosition):
self._totaldiff -= position.price
self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position, -position.price))
self._operations.append(self.CancelOperation(position))
if position.seat:
self._seatdiff.subtract([position.seat])
@@ -1484,7 +1463,7 @@ class OrderChangeManager:
try:
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
elif not isinstance(price, TaxedPrice):
else:
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
@@ -1527,190 +1506,6 @@ class OrderChangeManager:
self._operations.append(self.SplitOperation(position))
def set_addons(self, addons):
if self._operations:
raise ValueError("Setting addons should be the first/only operation")
# Prepare various containers to hold data later
current_addons = defaultdict(lambda: defaultdict(list)) # OrderPos -> currently attached add-ons
input_addons = defaultdict(Counter) # OrderPos -> final desired set of add-ons
selected_addons = defaultdict(Counter) # OrderPos, ItemAddOn -> final desired set of add-ons
opcache = {} # OrderPos.pk -> OrderPos
quota_diff = Counter() # Quota -> Number of usages
available_categories = defaultdict(set) # OrderPos -> Category IDs to choose from
price_included = defaultdict(dict) # OrderPos -> CategoryID -> bool(price is included)
toplevel_op = self.order.positions.filter(
addon_to__isnull=True
).prefetch_related(
'addons', 'item__addons', 'item__addons__addon_category'
).select_related('item', 'variation')
_items_cache = {
i.pk: i
for i in self.event.items.select_related('category').prefetch_related(
'addons', 'bundles', 'addons__addon_category', 'quotas'
).annotate(
has_variations=Count('variations'),
).filter(
id__in=[a['item'] for a in addons]
).order_by()
}
_variations_cache = {
v.pk: v
for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related(
'quotas'
).select_related('item', 'item__event').filter(
id__in=[a['variation'] for a in addons if a.get('variation')]
).order_by()
}
# Prefill some of the cache containers
for op in toplevel_op:
if op.canceled:
continue
available_categories[op.pk] = {iao.addon_category_id for iao in op.item.addons.all()}
price_included[op.pk] = {iao.addon_category_id: iao.price_included for iao in op.item.addons.all()}
opcache[op.pk] = op
for a in op.addons.all():
if a.canceled:
continue
if not a.is_bundled:
current_addons[op][a.item_id, a.variation_id].append(a)
# Create operations, perform various checks
for a in addons:
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if a['item'] not in _items_cache or (a['variation'] and a['variation'] not in _variations_cache):
raise OrderError(error_messages['not_for_sale'])
# Only attach addons to things that are actually in this user's cart
if a['addon_to'] not in opcache:
raise OrderError(error_messages['addon_invalid_base'])
op = opcache[a['addon_to']]
item = _items_cache[a['item']]
variation = _variations_cache[a['variation']] if a['variation'] is not None else None
if item.category_id not in available_categories[op.pk]:
raise OrderError(error_messages['addon_invalid_base'])
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
quotas = list(item.quotas.filter(subevent=op.subevent)
if variation is None else variation.quotas.filter(subevent=op.subevent))
if not quotas:
raise OrderError(error_messages['unavailable'])
if (a['item'], a['variation']) in input_addons[op.id]:
raise OrderError(error_messages['addon_duplicate_item'])
if item.require_voucher or op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher):
raise OrderError(error_messages['voucher_required'])
if not item.is_available() or (variation and not variation.is_available()):
raise OrderError(error_messages['unavailable'])
if self.order.sales_channel not in item.sales_channels or (
variation and self.order.sales_channel not in variation.sales_channels):
raise OrderError(error_messages['unavailable'])
if op.subevent and item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
raise OrderError(error_messages['not_for_sale'])
if op.subevent and variation and variation.pk in op.subevent.var_overrides and \
not op.subevent.var_overrides[variation.pk].is_available():
raise OrderError(error_messages['not_for_sale'])
if item.has_variations and not variation:
raise OrderError(error_messages['not_for_sale'])
if variation and variation.item_id != item.pk:
raise OrderError(error_messages['not_for_sale'])
if op.subevent and op.subevent.presale_start and now() < op.subevent.presale_start:
raise OrderError(error_messages['not_started'])
if (op.subevent and op.subevent.presale_has_ended) or self.event.presale_has_ended:
raise OrderError(error_messages['ended'])
if item.require_bundling:
raise OrderError(error_messages['unavailable'])
input_addons[op.id][a['item'], a['variation']] = a.get('count', 1)
selected_addons[op.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
if price_included[op.pk].get(item.category_id):
price = TAXED_ZERO
else:
price = get_price(
item, variation, voucher=None, custom_price=a.get('price'), subevent=op.subevent,
custom_price_is_net=self.event.settings.display_net_prices,
invoice_address=self._invoice_address,
)
if a.get('count', 1) > len(current_addons[op][a['item'], a['variation']]):
# This add-on is new, add it to the cart
for quota in quotas:
quota_diff[quota] += a.get('count', 1) - len(current_addons[op][a['item'], a['variation']])
for i in range(a.get('count', 1) - len(current_addons[op][a['item'], a['variation']])):
self.add_position(
item=item, variation=variation, price=price,
addon_to=op, subevent=op.subevent, seat=None,
)
# Check constraints on the add-on combinations
for op in toplevel_op:
item = op.item
for iao in item.addons.all():
selected = selected_addons[op.id, iao.addon_category_id]
n_per_i = Counter()
for (i, v), c in selected.items():
n_per_i[i] += c
if sum(selected.values()) > iao.max_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise OrderError(
error_messages['addon_max_count'],
{
'base': str(item.name),
'max': iao.max_count,
'cat': str(iao.addon_category.name),
}
)
elif sum(selected.values()) < iao.min_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise OrderError(
error_messages['addon_min_count'],
{
'base': str(item.name),
'min': iao.min_count,
'cat': str(iao.addon_category.name),
}
)
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
raise OrderError(
error_messages['addon_no_multi'],
{
'base': str(item.name),
'cat': str(iao.addon_category.name),
}
)
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
self.cancel(a)
def _check_seats(self):
for seat, diff in self._seatdiff.items():
if diff <= 0:
@@ -1757,16 +1552,17 @@ class OrderChangeManager:
self.order.save()
elif self.open_payment:
try:
self.open_payment.payment_provider.cancel_payment(self.open_payment)
self.order.log_action(
'pretix.event.order.payment.canceled',
{
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
},
user=self.user,
auth=self.auth
)
with transaction.atomic():
self.open_payment.payment_provider.cancel_payment(self.open_payment)
self.order.log_action(
'pretix.event.order.payment.canceled',
{
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
},
user=self.user,
auth=self.auth
)
except PaymentException as e:
self.order.log_action(
'pretix.event.order.payment.canceled.failed',
@@ -1781,11 +1577,12 @@ class OrderChangeManager:
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
if self.open_payment:
try:
self.open_payment.payment_provider.cancel_payment(self.open_payment)
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
}, user=self.user, auth=self.auth)
with transaction.atomic():
self.open_payment.payment_provider.cancel_payment(self.open_payment)
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
}, user=self.user, auth=self.auth)
except PaymentException as e:
self.order.log_action(
'pretix.event.order.payment.canceled.failed',
@@ -2071,7 +1868,7 @@ class OrderChangeManager:
split_order.code = None
split_order.datetime = now()
split_order.secret = generate_secret()
split_order.require_approval = self.order.require_approval and any(p.requires_approval(invoice_address=self._invoice_address) for p in split_positions)
split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
split_order.save()
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
'original_order': self.order.code
@@ -2323,15 +2120,12 @@ class OrderChangeManager:
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee()
self._check_paid_price_change()
self._check_paid_to_free()
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
self._reissue_invoice()
self._clear_tickets_cache()
self.order.touch()
self.order.create_transactions()
if self.split_order:
self.split_order.create_transactions()
self._check_paid_price_change()
self._check_paid_to_free()
if self.notify:
notify_user_changed_order(
@@ -2605,7 +2399,6 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
generate_cancellation(i)
generate_invoice(order)
order.create_transactions()
return old_fee, new_fee, fee, new_payment

View File

@@ -113,8 +113,10 @@ class QuotaAvailability:
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
"""
now_dt = now_dt or now()
quota_ids_set = {q.id for q in self._queue}
if not quota_ids_set:
quotas = list(set(self._queue))
quotas_original = list(self._queue)
self._queue.clear()
if not quotas:
return
if allow_cache:
@@ -127,7 +129,7 @@ class QuotaAvailability:
elif settings.HAS_REDIS:
rc = get_redis_connection("redis")
quotas_by_event = defaultdict(list)
for q in [_q for _q in self._queue if _q.id in quota_ids_set]:
for q in quotas_original:
quotas_by_event[q.event_id].append(q)
for eventid, evquotas in quotas_by_event.items():
@@ -137,19 +139,16 @@ class QuotaAvailability:
data = [rv for rv in redisval.decode().split(',')]
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
if time.time() - int(data[2]) < 120 or allow_cache_stale:
quota_ids_set.remove(q.id)
quotas_original.remove(q)
quotas.remove(q)
if data[1] == "None":
self.results[q] = int(data[0]), None
else:
self.results[q] = int(data[0]), int(data[1])
if not quota_ids_set:
if not quotas:
return
quotas = [_q for _q in self._queue if _q.id in quota_ids_set]
quotas_original = list(quotas)
self._queue.clear()
self._compute(quotas, now_dt)
for q in quotas_original:
@@ -285,16 +284,15 @@ class QuotaAvailability:
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
quota_ids = {q.pk for q in quotas}
op_lookup = OrderPosition.objects.filter(
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
order__event_id__in=events,
).filter(seq).filter(
Q(
Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas})
).order_by()
if any(q.release_after_exit for q in quotas):
op_lookup = op_lookup.annotate(
@@ -361,7 +359,6 @@ class QuotaAvailability:
func = 'GREATEST'
subevents = {q.subevent_id for q in quotas}
quota_ids = {q.pk for q in quotas}
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
@@ -373,9 +370,10 @@ class QuotaAvailability:
Q(
Q(
Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
variation_id__in={i['itemvariation_id'] for i in q_vars if
self._quota_objects[i['quota_id']] in quotas}
) | Q(
quota_id__in=[q.pk for q in quotas]
)
@@ -400,7 +398,6 @@ class QuotaAvailability:
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
events = {q.event_id for q in quotas}
subevents = {q.subevent_id for q in quotas}
quota_ids = {q.pk for q in quotas}
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
@@ -416,9 +413,9 @@ class QuotaAvailability:
Q(
Q(
Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}
)
)
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
@@ -437,7 +434,6 @@ class QuotaAvailability:
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
events = {q.event_id for q in quotas}
subevents = {q.subevent_id for q in quotas}
quota_ids = {q.pk for q in quotas}
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
@@ -448,8 +444,9 @@ class QuotaAvailability:
Q(
Q(
Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if
self._quota_objects[i['quota_id']] in quotas})
)
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
for line in w_lookup:

View File

@@ -1,134 +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 logging
import os
import re
from urllib.error import HTTPError
import vat_moss.errors
import vat_moss.id
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from zeep import Client, Transport
from zeep.cache import SqliteCache
from zeep.exceptions import Fault
from pretix.base.models.tax import cc_to_vat_prefix, is_eu_country
logger = logging.getLogger(__name__)
class VATIDError(Exception):
def __init__(self, message):
self.message = message
class VATIDFinalError(VATIDError):
pass
class VATIDTemporaryError(VATIDError):
pass
def _validate_vat_id_EU(vat_id, country_code):
if vat_id[:2] != cc_to_vat_prefix(country_code):
raise VATIDFinalError(_('Your VAT ID does not match the selected country.'))
try:
result = vat_moss.id.validate(vat_id)
if result:
country_code, normalized_id, company_name = result
return normalized_id
except (vat_moss.errors.InvalidError, ValueError):
raise VATIDFinalError(_('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(country_code))
raise VATIDTemporaryError(_(
'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(country_code))
raise VATIDTemporaryError(_(
'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.'
))
def _validate_vat_id_CH(vat_id, country_code):
if vat_id[:3] != 'CHE':
raise VATIDFinalError(_('Your VAT ID does not match the selected country.'))
vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', ''))
try:
transport = Transport(cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")))
client = Client(
'https://www.uid-wse.admin.ch/V5.0/PublicServices.svc?wsdl',
transport=transport
)
result = client.service.ValidateUID(uid=vat_id)
except Fault as e:
if e.message == 'Data_validation_failed':
raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.'))
elif e.message == 'Request_limit_exceeded':
logger.exception('VAT ID checking failed for country {} due to request limit'.format(country_code))
raise VATIDTemporaryError(_(
'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:
logger.exception('VAT ID checking failed for country {}'.format(country_code))
raise VATIDTemporaryError(_(
'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.'
))
except:
logger.exception('VAT ID checking failed for country {}'.format(country_code))
raise VATIDTemporaryError(_(
'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.'
))
else:
if not result:
raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.'))
return vat_id
def validate_vat_id(vat_id, country_code):
country_code = str(country_code)
if is_eu_country(country_code):
return _validate_vat_id_EU(vat_id, country_code)
elif country_code == 'CH':
return _validate_vat_id_CH(vat_id, country_code)
raise VATIDTemporaryError(f'VAT ID should not be entered for country {country_code}')

View File

@@ -48,12 +48,10 @@ from django.core.validators import (
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.db.models import Model
from django.utils.functional import lazy
from django.utils.text import format_lazy
from django.utils.translation import (
gettext, gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy,
gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy,
)
from django_countries.fields import Country
from hierarkey.models import GlobalSettingsBase, Hierarkey
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
@@ -63,7 +61,7 @@ from pretix.api.serializers.fields import (
ListMultipleChoiceField, UploadedFileField,
)
from pretix.api.serializers.i18n import I18nField
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
from pretix.base.models.tax import TaxRule
from pretix.base.reldate import (
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
SerializerRelativeDateField, SerializerRelativeDateTimeField,
@@ -136,15 +134,11 @@ DEFAULTS = {
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
min_value=1,
),
'form_kwargs': dict(
min_value=1,
required=True,
label=_("Maximum number of items per order"),
help_text=_("Add-on products will not be counted.")
),
)
},
'display_net_prices': {
'default': 'False',
@@ -314,17 +308,6 @@ DEFAULTS = {
label=_("Show attendee names on invoices"),
)
},
'invoice_event_location': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Show event location on invoices"),
help_text=_("The event location will be shown below the list of products if it is the same for all "
"lines. It will be shown on every line if there are different locations.")
)
},
'invoice_eu_currencies': {
'default': 'True',
'type': bool,
@@ -372,12 +355,11 @@ DEFAULTS = {
'form_class': I18nFormField,
'serializer_class': I18nField,
'form_kwargs': dict(
label=_("Custom recipient field"),
label=_("Custom address field"),
widget=I18nTextInput,
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
"your invoice address form, please fill in the label here. This label will both be used for "
"asking the user to input their details as well as for displaying the value on the invoice. It will "
"be shown on the invoice below the headline. "
"asking the user to input their details as well as for displaying the value on the invoice. "
"The field will not be required.")
)
},
@@ -388,11 +370,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for VAT ID"),
help_text=format_lazy(
_("Only works if an invoice address is asked for. VAT ID is never required and only requested from "
"business customers in the following countries: {countries}"),
countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)()
),
help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
)
},
@@ -431,7 +409,7 @@ DEFAULTS = {
)
},
'invoice_include_expire_date': {
'default': 'False', # default for new events is True
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
@@ -445,11 +423,9 @@ DEFAULTS = {
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(),
'form_kwargs': dict(
label=_("Minimum length of invoice number after prefix"),
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
required=True,
)
},
'invoice_numbers_consecutive': {
@@ -489,7 +465,7 @@ DEFAULTS = {
)
},
'invoice_renderer': {
'default': 'classic', # default for new events is 'modern1'
'default': 'classic',
'type': str,
},
'ticket_secret_generator': {
@@ -513,7 +489,6 @@ DEFAULTS = {
MinValueValidator(12),
MaxValueValidator(64),
],
required=True,
widget=forms.NumberInput(
attrs={
'min': '12',
@@ -528,13 +503,9 @@ DEFAULTS = {
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
min_value=0,
),
'form_kwargs': dict(
min_value=0,
label=_("Reservation period"),
required=True,
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
)
},
@@ -589,7 +560,6 @@ DEFAULTS = {
'form_kwargs': dict(
label=_("Set payment term"),
widget=forms.RadioSelect,
required=True,
choices=(
('days', _("in days")),
('minutes', _("in minutes"))
@@ -921,7 +891,7 @@ DEFAULTS = {
'type': str
},
'invoice_email_attachment': {
'default': 'False', # default for new events is True
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
@@ -1104,13 +1074,9 @@ DEFAULTS = {
'type': int,
'serializer_class': serializers.IntegerField,
'form_class': forms.IntegerField,
'serializer_kwargs': dict(
min_value=1,
),
'form_kwargs': dict(
label=_("Waiting list response time"),
min_value=1,
required=True,
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
"number of hours until it expires and can be re-assigned to the next person on the list."),
widget=forms.NumberInput(),
@@ -1258,7 +1224,7 @@ DEFAULTS = {
)
},
'event_list_type': {
'default': 'list', # default for new events is 'calendar'
'default': 'list',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
@@ -1318,15 +1284,6 @@ DEFAULTS = {
label=_("Customers can change the variation of the products they purchased"),
)
},
'change_allow_user_addons': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Customers can change their selected add-on products"),
)
},
'change_allow_user_price': {
'default': 'gte',
'type': str,
@@ -1529,17 +1486,6 @@ DEFAULTS = {
),
'serializer_class': serializers.URLField,
},
'privacy_url': {
'default': None,
'type': str,
'form_class': forms.URLField,
'form_kwargs': dict(
label=_("Privacy Policy URL"),
help_text=_("This should point e.g. to a part of your website that explains how you use data gathered in "
"your ticket shop."),
),
'serializer_class': serializers.URLField,
},
'confirm_texts': {
'default': LazyI18nStringList(),
'type': LazyI18nStringList,
@@ -1589,7 +1535,7 @@ DEFAULTS = {
'type': str
},
'mail_from': {
'default': settings.MAIL_FROM_ORGANIZERS,
'default': settings.MAIL_FROM,
'type': str,
'form_class': forms.EmailField,
'serializer_class': serializers.EmailField,
@@ -1704,30 +1650,6 @@ You can change your order details and view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_attachment_new_order': {
'default': None,
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Attachment for new orders'),
ext_whitelist=(".pdf",),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
help_text=_('This file will be attached to the first email that we send for every new order. Therefore it will be '
'combined with the "Placed order", "Free order", or "Received order" texts from above. It will be sent '
'to both order contacts and attendees. You can use this e.g. to send your terms of service. Do not use '
'it to send non-public information as this file might be sent before payment is confirmed or the order '
'is approved. To avoid this vital email going to spam, you can only upload PDF files of up to {size} MB.').format(
size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT // (1024 * 1024),
)
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'application/pdf'
],
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
)
},
'mail_send_order_placed_attendee': {
'type': bool,
'default': 'False'
@@ -1787,17 +1709,6 @@ Best regards,
Your {event} team"""))
},
'mail_days_order_expire_warning': {
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
min_value=0,
),
'form_kwargs': dict(
label=_("Number of days"),
min_value=0,
help_text=_("This email will be sent out this many days before the order expires. If the "
"value is 0, the mail will never be sent.")
),
'type': int,
'default': '3'
},
@@ -2046,7 +1957,7 @@ Your {organizer} team"""))
),
},
'theme_color_success': {
'default': '#50a167',
'default': '#50A167',
'type': str,
'form_class': forms.CharField,
'serializer_class': serializers.CharField,
@@ -2068,7 +1979,7 @@ Your {organizer} team"""))
),
},
'theme_color_danger': {
'default': '#c44f4f',
'default': '#C44F4F',
'type': str,
'form_class': forms.CharField,
'serializer_class': serializers.CharField,
@@ -2090,7 +2001,7 @@ Your {organizer} team"""))
),
},
'theme_color_background': {
'default': '#f5f5f5',
'default': '#FFFFFF',
'type': str,
'form_class': forms.CharField,
'serializer_class': serializers.CharField,
@@ -2516,7 +2427,7 @@ Your {organizer} team"""))
)
},
'name_scheme': {
'default': 'full', # default for new events is 'given_family'
'default': 'full',
'type': str
},
'giftcard_length': {
@@ -2541,77 +2452,6 @@ Your {organizer} team"""))
'many years. If you keep it empty, gift cards do not have an explicit expiry date.'),
)
},
'cookie_consent': {
'default': 'False',
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Enable cookie consent management features"),
),
'type': bool,
},
'cookie_consent_dialog_text': {
'default': LazyI18nString.from_gettext(gettext_noop(
'By clicking "Accept all cookies", you agree to the storing of cookies and use of similar technologies on '
'your device.'
)),
'type': LazyI18nString,
'serializer_class': I18nField,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Dialog text"),
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
)
},
'cookie_consent_dialog_text_secondary': {
'default': LazyI18nString.from_gettext(gettext_noop(
'We use cookies and similar technologies to gather data that allows us to improve this website and our '
'offerings. If you do not agree, we will only use cookies if they are essential to providing the services '
'this website offers.'
)),
'type': LazyI18nString,
'serializer_class': I18nField,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Secondary dialog text"),
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
)
},
'cookie_consent_dialog_title': {
'default': LazyI18nString.from_gettext(gettext_noop('Privacy settings')),
'type': LazyI18nString,
'serializer_class': I18nField,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_('Dialog title'),
widget=I18nTextInput,
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
)
},
'cookie_consent_dialog_button_yes': {
'default': LazyI18nString.from_gettext(gettext_noop('Accept all cookies')),
'type': LazyI18nString,
'serializer_class': I18nField,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_('"Accept" button description'),
widget=I18nTextInput,
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
)
},
'cookie_consent_dialog_button_no': {
'default': LazyI18nString.from_gettext(gettext_noop('Required cookies only')),
'type': LazyI18nString,
'serializer_class': I18nField,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_('"Reject" button description'),
widget=I18nTextInput,
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
)
},
'seating_choice': {
'default': 'True',
'form_class': forms.BooleanField,

View File

@@ -85,6 +85,9 @@
-webkit-hyphens: auto;
hyphens: auto;
}
p:last-child {
margin-bottom: 0;
}
.footer {
padding: 10px;
@@ -113,9 +116,6 @@
width: 100%;
height: auto;
}
.content {
text-align: left;
}
.content table {
width: 100%;
@@ -142,8 +142,7 @@
}
.order-button {
padding-top: 5px;
text-align: center;
padding-top: 5px
}
.order-button a.button {
font-size: 12px;
@@ -174,7 +173,7 @@
body {
direction: rtl;
}
.content {
.content table td {
text-align: right;
}
{% endif %}

View File

@@ -1,6 +1,5 @@
{% load eventurl %}
{% load i18n %}
{% load oneline %}
{% if position %}
<div class="order-info">
@@ -108,10 +107,6 @@
{% if event.settings.show_times %}
{{ groupkey.2.date_from|date:"TIME_FORMAT" }}
{% endif %}
{% if groupkey.2.location %}
<br>
{{ groupkey.2.location|oneline }}
{% endif %}
{% endif %}
{% if groupkey.3 %} {# attendee name #}
<br>

View File

@@ -14,17 +14,16 @@
</o:OfficeDocumentSettings>
</xml><![endif]-->
<style type="text/css">
body, .container {
body {
background-color: #eee;
background-position: top;
background-repeat: repeat-x;
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
}
.container {
padding: 20px;
padding-top: 20px;
}
table.layout > tr > td,
@@ -37,8 +36,7 @@
table.layout > tr > td.logo,
table.layout > tbody > tr > td.logo,
table.layout > thead > tr > td.logo {
padding: {% if event.settings.logo_image_large %}0 0 0 0{% else %}20px 0 0 0{% endif %};
mso-line-height-rule: at-least;
padding: 20px 0 0 0;
}
table.layout > tr > td.header,
@@ -104,6 +102,9 @@
-webkit-hyphens: auto;
hyphens: auto;
}
p:last-child {
margin-bottom: 0;
}
.footer {
padding: 10px;
@@ -111,6 +112,10 @@
font-size: 12px;
}
.content {
padding: 0 18px;
}
::selection {
background: {{ color }};
color: #FFF;
@@ -129,14 +134,6 @@
height: auto;
}
img {
display: block;
}
.content {
text-align: left;
}
.content table {
width: 100%;
}
@@ -148,7 +145,7 @@
table.layout > tr > td.containertd,
table.layout > tbody > tr > td.containertd,
table.layout > thead > tr > td.containertd {
padding: 20px;
padding: 15px 0;
}
a.button {
@@ -166,8 +163,7 @@
}
.order-button {
padding-top: 5px;
text-align: center;
padding-top: 5px
}
.order-button a.button {
font-size: 12px;
@@ -198,7 +194,7 @@
body {
direction: rtl;
}
.content {
.content table td {
text-align: right;
}
{% endif %}
@@ -214,14 +210,14 @@
<![endif]-->
</head>
<body align="center">
<table width="100%"><tr><td align="center" class="container">
<!--[if gte mso 9]>
<table width="600"><tr><td align="center">
<table width="100%"><tr><td align="center">
<table width="600"><tr><td align="center"
<![endif]-->
<table class="layout" style="max-width:600px" border="0" cellspacing="0">
{% if event.settings.logo_image %}
<tr>
<td align="center" class="logo">
<td style="line-height: 0; {% if event.settings.logo_image_large %}padding: 0;{% endif %}" align="center" class="logo">
{% if event.settings.logo_image_large %}
<img src="{% if event.settings.logo_image|thumb:'600_x5000'|first == '/' %}{{ site_url }}{% endif %}{{ event.settings.logo_image|thumb:'600_x5000' }}" alt="{{ event.name }}" style="width:100%" />
{% else %}
@@ -232,6 +228,9 @@
{% endif %}
<tr>
<td class="header" align="center">
<!--[if gte mso 9]>
<table cellpadding="20"><tr><td align="center">
<![endif]-->
{% if event %}
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
</h2>
@@ -244,30 +243,51 @@
{% block header %}
<h1>{{ subject }}</h1>
{% endblock %}
<!--[if gte mso 9]>
</td></tr></table>
<![endif]-->
</td>
</tr>
<tr>
<td class="containertd">
<!--[if gte mso 9]>
<table cellpadding="20"><tr><td>
<![endif]-->
<div class="content">
{{ body|safe }}
</div>
<!--[if gte mso 9]>
</td></tr></table>
<![endif]-->
</td>
</tr>
{% if order %}
<tr>
<td class="order containertd">
<!--[if gte mso 9]>
<table cellpadding="20"><tr><td>
<![endif]-->
<div class="content">
{% include "pretixbase/email/order_details.html" %}
</div>
<!--[if gte mso 9]>
</td></tr></table>
<![endif]-->
</td>
</tr>
{% endif %}
{% if signature %}
<tr>
<td class="order containertd">
<!--[if gte mso 9]>
<table cellpadding="20"><tr><td>
<![endif]-->
<div class="content">
{{ signature | safe }}
</div>
<!--[if gte mso 9]>
</td></tr></table>
<![endif]-->
</td>
</tr>
{% endif %}
@@ -279,7 +299,7 @@
<br/>
<!--[if gte mso 9]>
</td></tr></table>
<![endif]-->
</td></tr></table>
<![endif]-->
</body>
</html>

View File

@@ -1,29 +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 django import template
register = template.Library()
@register.filter
def classname(obj):
return obj.__class__.__name__

View File

@@ -34,8 +34,6 @@ register = template.Library()
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, (float, int)):
value = Decimal(value)
if value is None:
value = Decimal('0.00')
if not isinstance(value, Decimal):
if value == '':
return value

View File

@@ -1,31 +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 django import template
register = template.Library()
@register.filter
def oneline(value):
if not value:
return ''
return ', '.join([l.strip() for l in str(value).splitlines() if l and l.strip()])

View File

@@ -138,7 +138,7 @@ def truelink_callback(attrs, new=False):
text = re.sub(r'[^a-zA-Z0-9.\-/_ ]', '', attrs.get('_text')) # clean up link text
url = attrs.get((None, 'href'), '/')
href_url = urllib.parse.urlparse(url)
if (None, 'href') in attrs and URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
if URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
# link text looks like a url
if text.startswith('//'):
text = 'https:' + text
@@ -157,8 +157,6 @@ def abslink_callback(attrs, new=False):
Makes sure that all links will be absolute links and will be opened in a new page with no
window.opener attribute.
"""
if (None, 'href') not in attrs:
return attrs
url = attrs.get((None, 'href'), '/')
if not url.startswith('mailto:') and not url.startswith('tel:'):
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)

View File

@@ -47,7 +47,7 @@ class DownloadView(TemplateView):
return HttpResponse('1' if self.object.file else '0')
elif self.object.file:
resp = ChunkBasedFileResponse(self.object.file.file, content_type=self.object.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.object.filename).encode('ascii', 'ignore')
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.object.filename)
return resp
else:
return super().get(request, *args, **kwargs)

View File

@@ -30,85 +30,67 @@ from django.utils.translation import gettext as _
from django.views.decorators.csrf import requires_csrf_token
from sentry_sdk import last_event_id
from pretix.base.i18n import language
from pretix.base.middleware import get_language_from_request
def csrf_failure(request, reason=""):
try:
locale = get_language_from_request(request)
except:
locale = "en"
with language(locale): # Middleware might not have run, need to do this manually
t = get_template('csrffail.html')
c = {
'reason': reason,
'no_referer': reason == REASON_NO_REFERER,
'no_referer1': _(
"You are seeing this message because this HTTPS site requires a "
"'Referer header' to be sent by your Web browser, but none was "
"sent. This header is required for security reasons, to ensure "
"that your browser is not being hijacked by third parties."),
'no_referer2': _(
"If you have configured your browser to disable 'Referer' headers, "
"please re-enable them, at least for this site, or for HTTPS "
"connections, or for 'same-origin' requests."),
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
'no_cookie1': _(
"You are seeing this message because this site requires a CSRF "
"cookie when submitting forms. This cookie is required for "
"security reasons, to ensure that your browser is not being "
"hijacked by third parties."),
'no_cookie2': _(
"If you have configured your browser to disable cookies, please "
"re-enable them, at least for this site, or for 'same-origin' "
"requests."),
}
return HttpResponseForbidden(t.render(c), content_type='text/html')
t = get_template('csrffail.html')
c = {
'reason': reason,
'no_referer': reason == REASON_NO_REFERER,
'no_referer1': _(
"You are seeing this message because this HTTPS site requires a "
"'Referer header' to be sent by your Web browser, but none was "
"sent. This header is required for security reasons, to ensure "
"that your browser is not being hijacked by third parties."),
'no_referer2': _(
"If you have configured your browser to disable 'Referer' headers, "
"please re-enable them, at least for this site, or for HTTPS "
"connections, or for 'same-origin' requests."),
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
'no_cookie1': _(
"You are seeing this message because this site requires a CSRF "
"cookie when submitting forms. This cookie is required for "
"security reasons, to ensure that your browser is not being "
"hijacked by third parties."),
'no_cookie2': _(
"If you have configured your browser to disable cookies, please "
"re-enable them, at least for this site, or for 'same-origin' "
"requests."),
}
return HttpResponseForbidden(t.render(c), content_type='text/html')
@requires_csrf_token
def page_not_found(request, exception):
exception_repr = exception.__class__.__name__
# Try to get an "interesting" exception message, if any (and not the ugly
# Resolver404 dictionary)
try:
locale = get_language_from_request(request)
except:
locale = "en"
with language(locale): # Middleware might not have run, need to do this manually
exception_repr = exception.__class__.__name__
# Try to get an "interesting" exception message, if any (and not the ugly
# Resolver404 dictionary)
try:
message = exception.args[0]
except (AttributeError, IndexError):
pass
else:
if isinstance(message, (str, Promise)):
exception_repr = str(message)
context = {
'request_path': request.path,
'exception': exception_repr,
}
template = get_template('404.html')
body = template.render(context, request)
r = HttpResponseNotFound(body)
r.xframe_options_exempt = True
return r
message = exception.args[0]
except (AttributeError, IndexError):
pass
else:
if isinstance(message, (str, Promise)):
exception_repr = str(message)
context = {
'request_path': request.path,
'exception': exception_repr,
}
template = get_template('404.html')
body = template.render(context, request)
r = HttpResponseNotFound(body)
r.xframe_options_exempt = True
return r
@requires_csrf_token
def server_error(request):
try:
locale = get_language_from_request(request)
except:
locale = "en"
with language(locale): # Middleware might not have run, need to do this manually
try:
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
}))
r.xframe_options_exempt = True
return r
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
}))
r.xframe_options_exempt = True
return r

View File

@@ -355,29 +355,20 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
override[k].pop('initial', None)
return override_sets
@cached_property
def vat_id_validation_enabled(self):
return any([p.item.tax_rule and (p.item.tax_rule.eu_reverse_charge or p.item.tax_rule.custom_rules)
for p in self.positions])
@cached_property
def invoice_form(self):
if not self.address_asked and self.request.event.settings.invoice_name_required:
f = self.invoice_name_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address,
validate_vat_id=False,
request=self.request,
instance=self.invoice_address, validate_vat_id=False,
all_optional=self.all_optional
)
elif self.address_asked:
f = self.invoice_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address,
validate_vat_id=self.vat_id_validation_enabled,
request=self.request,
instance=self.invoice_address, validate_vat_id=False,
all_optional=self.all_optional,
)
else:

View File

@@ -48,7 +48,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField
from ...base.forms import I18nModelForm
from ...base.forms import I18nModelForm, SecretKeySettingsField
# Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa
@@ -373,6 +373,49 @@ class FontSelect(forms.RadioSelect):
option_template_name = 'pretixcontrol/font_option.html'
class SMTPSettingsMixin(forms.Form):
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
required=False
)
smtp_host = forms.CharField(
label=_("Hostname"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
)
smtp_port = forms.IntegerField(
label=_("Port"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
)
smtp_username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
required=False
)
smtp_password = SecretKeySettingsField(
label=_("Password"),
required=False,
)
smtp_use_tls = forms.BooleanField(
label=_("Use STARTTLS"),
help_text=_("Commonly enabled on port 587."),
required=False
)
smtp_use_ssl = forms.BooleanField(
label=_("Use SSL"),
help_text=_("Commonly enabled on port 465."),
required=False
)
def clean(self):
data = super().clean()
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
return data
class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
def label_from_instance(self, obj):
return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>')

View File

@@ -43,7 +43,6 @@ from django.core.validators import validate_email
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import CheckboxSelectMultiple, formset_factory
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name
@@ -64,7 +63,7 @@ from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
)
from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2
@@ -535,39 +534,34 @@ class EventSettingsForm(SettingsForm):
'og_image',
]
def _resolve_virtual_keys_input(self, data, prefix=''):
# set all dependants of virtual_keys and
# delete all virtual_fields to prevent them from being saved
for virtual_key in self.virtual_keys:
if prefix + virtual_key not in data:
continue
base_key = prefix + virtual_key.rsplit('_', 2)[0]
asked_key = base_key + '_asked'
required_key = base_key + '_required'
if data[prefix + virtual_key] == 'optional':
data[asked_key] = True
data[required_key] = False
elif data[prefix + virtual_key] == 'required':
data[asked_key] = True
data[required_key] = True
# Explicitly check for 'do_not_ask'.
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
elif data[prefix + virtual_key] == 'do_not_ask':
data[asked_key] = False
data[required_key] = False
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
if not prefix:
data[virtual_key] = None
return data
def clean(self):
data = super().clean()
settings_dict = self.event.settings.freeze()
settings_dict.update(data)
data = self._resolve_virtual_keys_input(data)
# set all dependants of virtual_keys and
# delete all virtual_fields to prevent them from being saved
for virtual_key in self.virtual_keys:
if virtual_key not in data:
continue
base_key = virtual_key.rsplit('_', 2)[0]
asked_key = base_key + '_asked'
required_key = base_key + '_required'
if data[virtual_key] == 'optional':
data[asked_key] = True
data[required_key] = False
elif data[virtual_key] == 'required':
data[asked_key] = True
data[required_key] = True
# Explicitly check for 'do_not_ask'.
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
elif data[virtual_key] == 'do_not_ask':
data[asked_key] = False
data[required_key] = False
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
data[virtual_key] = None
validate_event_settings(self.event, data)
return data
@@ -627,35 +621,6 @@ class EventSettingsForm(SettingsForm):
else:
self.initial[virtual_key] = 'do_not_ask'
@cached_property
def changed_data(self):
data = []
# We need to resolve the mapping between our "virtual" fields and the "real"fields here, otherwise
# they are detected as "changed" on every save even though they aren't.
in_data = self._resolve_virtual_keys_input(self.data.copy(), prefix=f'{self.prefix}-' if self.prefix else '')
for name, field in self.fields.items():
prefixed_name = self.add_prefix(name)
data_value = field.widget.value_from_datadict(in_data, self.files, prefixed_name)
if not field.show_hidden_initial:
# Use the BoundField's initial as this is the value passed to
# the widget.
initial_value = self[name].initial
else:
initial_prefixed_name = self.add_initial_prefix(name)
hidden_widget = field.hidden_widget()
try:
initial_value = field.to_python(hidden_widget.value_from_datadict(
self.data, self.files, initial_prefixed_name))
except ValidationError:
# Always assume data has changed if validation fails.
data.append(name)
continue
if field.has_changed(initial_value, data_value):
data.append(name)
return data
class CancelSettingsForm(SettingsForm):
auto_fields = [
@@ -674,7 +639,6 @@ class CancelSettingsForm(SettingsForm):
'change_allow_user_variation',
'change_allow_user_price',
'change_allow_user_until',
'change_allow_user_addons',
]
def __init__(self, *args, **kwargs):
@@ -787,7 +751,6 @@ class InvoiceSettingsForm(SettingsForm):
'invoice_reissue_after_modify',
'invoice_generate',
'invoice_attendee_name',
'invoice_event_location',
'invoice_include_expire_date',
'invoice_numbers_consecutive',
'invoice_numbers_prefix',
@@ -865,13 +828,13 @@ def contains_web_channel_validate(val):
raise ValidationError(_("The online shop must be selected to receive these emails."))
class MailSettingsForm(SettingsForm):
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
auto_fields = [
'mail_prefix',
'mail_from',
'mail_from_name',
'mail_attach_ical',
'mail_attach_tickets',
'mail_attachment_new_order',
]
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
@@ -1214,7 +1177,6 @@ class TaxRuleLineForm(I18nForm):
('reverse', _('Reverse charge')),
('no', _('No VAT')),
('block', _('Sale not allowed')),
('require_approval', _('Order requires approval')),
],
)
rate = forms.DecimalField(
@@ -1248,7 +1210,7 @@ TaxRuleLineFormSet = formset_factory(
class TaxRuleForm(I18nModelForm):
class Meta:
model = TaxRule
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes']
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country']
class WidgetCodeForm(forms.Form):

View File

@@ -811,213 +811,6 @@ class OrderSearchFilterForm(OrderFilterForm):
)
class OrderPaymentSearchFilterForm(forms.Form):
orders = {'id': 'id', 'local_id': 'local_id', 'state': 'state', 'amount': 'amount', 'order': 'order',
'created': 'created', 'payment_date': 'payment_date', 'provider': 'provider', 'info': 'info',
'fee': 'fee'}
query = forms.CharField(
label=_('Search for…'),
widget=forms.TextInput(attrs={
'placeholder': _('Search for…'),
'autofocus': 'autofocus'
}),
required=False,
)
event = forms.ModelChoiceField(
label=_('Event'),
queryset=Event.objects.none(),
required=False,
widget=Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse_lazy('control:events.typeahead'),
'data-placeholder': _('All events')
}
)
)
organizer = forms.ModelChoiceField(
label=_('Organizer'),
queryset=Organizer.objects.none(),
required=False,
empty_label=_('All organizers'),
widget=Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse_lazy('control:organizers.select2'),
'data-placeholder': _('All organizers')
}
),
)
state = forms.ChoiceField(
label=_('Status'),
required=False,
choices=[('', _('All payments'))] + list(OrderPayment.PAYMENT_STATES),
)
provider = forms.ChoiceField(
label=_('Payment provider'),
choices=[
('', _('All payment providers')),
],
required=False,
)
created_from = forms.DateField(
label=_('Payment created from'),
required=False,
widget=DatePickerWidget,
)
created_until = forms.DateField(
label=_('Payment created until'),
required=False,
widget=DatePickerWidget,
)
completed_from = forms.DateField(
label=_('Paid from'),
required=False,
widget=DatePickerWidget,
)
completed_until = forms.DateField(
label=_('Paid until'),
required=False,
widget=DatePickerWidget,
)
amount = forms.CharField(
label=_('Amount'),
required=False,
widget=forms.NumberInput(attrs={
'placeholder': _('Amount'),
}),
)
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['ordering'] = forms.ChoiceField(
choices=sum([
[(a, a), ('-' + a, '-' + a)]
for a in self.orders.keys()
], []),
required=False
)
if self.request.user.has_active_staff_session(self.request.session.session_key):
self.fields['organizer'].queryset = Organizer.objects.all()
self.fields['event'].queryset = Event.objects.all()
else:
self.fields['organizer'].queryset = Organizer.objects.filter(
pk__in=self.request.user.teams.values_list('organizer', flat=True)
)
self.fields['event'].queryset = self.request.user.get_events_with_permission('can_view_orders')
self.fields['provider'].choices += get_all_payment_providers()
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('created_from'):
date_start = make_aware(datetime.combine(
fdata.get('created_from'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(created__gte=date_start)
if fdata.get('created_until'):
date_end = make_aware(datetime.combine(
fdata.get('created_until') + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(created__lt=date_end)
if fdata.get('completed_from'):
date_start = make_aware(datetime.combine(
fdata.get('completed_from'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(payment_date__gte=date_start)
if fdata.get('completed_until'):
date_end = make_aware(datetime.combine(
fdata.get('completed_until') + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(payment_date__lt=date_end)
if fdata.get('event'):
qs = qs.filter(order__event=fdata.get('event'))
if fdata.get('organizer'):
qs = qs.filter(order__event__organizer=fdata.get('organizer'))
if fdata.get('state'):
qs = qs.filter(state=fdata.get('state'))
if fdata.get('provider'):
qs = qs.filter(provider=fdata.get('provider'))
if fdata.get('query'):
u = fdata.get('query')
matching_invoices = Invoice.objects.filter(
Q(invoice_no__iexact=u)
| Q(invoice_no__iexact=u.zfill(5))
| Q(full_invoice_no__iexact=u)
).values_list('order_id', flat=True)
matching_invoice_addresses = InvoiceAddress.objects.filter(
Q(
Q(name_cached__icontains=u) | Q(company__icontains=u)
)
).values_list('order_id', flat=True)
if "-" in u:
code = (Q(event__slug__icontains=u.rsplit("-", 1)[0])
& Q(code__icontains=Order.normalize_code(u.rsplit("-", 1)[1])))
else:
code = Q(code__icontains=Order.normalize_code(u))
matching_orders = Order.objects.filter(
Q(
code
| Q(email__icontains=u)
| Q(comment__icontains=u)
)
).values_list('id', flat=True)
mainq = (
Q(order__id__in=matching_invoices)
| Q(order__id__in=matching_invoice_addresses)
| Q(order__id__in=matching_orders)
)
qs = qs.filter(mainq)
if fdata.get('amount'):
amount = fdata.get('amount')
def is_decimal(value):
result = True
parts = value.split('.', maxsplit=1)
for part in parts:
result = result & part.isdecimal()
return result
if is_decimal(amount):
qs = qs.filter(amount=Decimal(amount))
if fdata.get('ordering'):
p = self.cleaned_data.get('ordering')
if p.startswith('-') and p not in self.orders:
qs = qs.order_by('-' + self.orders[p[1:]])
else:
qs = qs.order_by(self.orders[p])
else:
qs = qs.order_by('-created')
return qs
class SubEventFilterForm(FilterForm):
orders = {
'date_from': 'date_from',
@@ -2225,15 +2018,6 @@ class DeviceFilterForm(FilterForm):
],
required=False,
)
state = forms.ChoiceField(
label=_('Device status'),
choices=[
('', _('All devices')),
('active', _('Active devices')),
('revoked', _('Revoked devices'))
],
required=False
)
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
@@ -2263,11 +2047,6 @@ class DeviceFilterForm(FilterForm):
if fdata.get('gate'):
qs = qs.filter(gate=fdata['gate'])
if fdata.get('state') == 'active':
qs = qs.filter(revoked=False)
elif fdata.get('state') == 'revoked':
qs = qs.filter(revoked=True)
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:

View File

@@ -607,7 +607,6 @@ class ItemUpdateForm(I18nModelForm):
'issue_giftcard',
'require_membership',
'require_membership_types',
'require_membership_hidden',
'grant_membership_type',
'grant_membership_duration_like_event',
'grant_membership_duration_days',
@@ -713,9 +712,7 @@ class ItemVariationForm(I18nModelForm):
'default_price',
'original_price',
'description',
'require_approval',
'require_membership',
'require_membership_hidden',
'require_membership_types',
'available_from',
'available_until',

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