forked from CGM_Public/pretix_original
Compare commits
1 Commits
consistenc
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289cad1f9c |
@@ -56,7 +56,6 @@ COPY deployment/docker/supervisord /etc/supervisord
|
||||
COPY deployment/docker/supervisord.all.conf /etc/supervisord.all.conf
|
||||
COPY deployment/docker/supervisord.web.conf /etc/supervisord.web.conf
|
||||
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY deployment/docker/nginx-max-body-size.conf /etc/nginx/conf.d/nginx-max-body-size.conf
|
||||
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY src /pretix/src
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
client_max_body_size 100M;
|
||||
@@ -16,6 +16,7 @@ http {
|
||||
charset utf-8;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
client_max_body_size 100M;
|
||||
|
||||
log_format private '[$time_local] $host "$request" $status $body_bytes_sent';
|
||||
|
||||
@@ -65,8 +66,6 @@ http {
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
gzip on;
|
||||
}
|
||||
location / {
|
||||
# Very important:
|
||||
|
||||
@@ -434,19 +434,3 @@ pretix can make use of some external tools if they are installed. Currently, the
|
||||
|
||||
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
|
||||
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
|
||||
Maximum upload file sizes
|
||||
-------------------------
|
||||
|
||||
You can configure the maximum file size for uploading various files::
|
||||
|
||||
[pretix_file_upload]
|
||||
; Max upload size for images in MiB, defaults to 10 MiB
|
||||
max_size_image = 12
|
||||
; Max upload size for favicons in MiB, defaults to 1 MiB
|
||||
max_size_favicon = 2
|
||||
; Max upload size for email attachments in MiB, defaults to 10 MiB
|
||||
max_size_email_attachment = 15
|
||||
; Max upload size for other files in MiB, defaults to 10 MiB
|
||||
; This includes all file upload type order questions
|
||||
max_size_other = 100
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,7 @@ To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmariadb-dev libjpeg-dev libopenjp2-7-dev
|
||||
gettext libpq-dev libmariadbclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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
|
||||
-------
|
||||
|
||||
|
||||
@@ -1,44 +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 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/
|
||||
@@ -25,21 +25,7 @@ description multi-lingual string A public descri
|
||||
Markdown syntax or can be ``null``.
|
||||
position integer An integer, used for sorting
|
||||
require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||
be hidden from users without a valid membership.
|
||||
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||
sales_channels list of strings Sales channels this variation is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
|
||||
The item-level list takes precedence, i.e. a sales
|
||||
channel needs to be on both lists for the item to be
|
||||
available.
|
||||
available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
@@ -77,12 +63,7 @@ Endpoints
|
||||
},
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": {
|
||||
"en": "Test2"
|
||||
},
|
||||
@@ -98,7 +79,6 @@ Endpoints
|
||||
},
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"description": {},
|
||||
"position": 1,
|
||||
@@ -148,12 +128,7 @@ Endpoints
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
@@ -184,12 +159,7 @@ Endpoints
|
||||
"default_price": "10.00",
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
@@ -210,12 +180,7 @@ Endpoints
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
@@ -267,12 +232,7 @@ Endpoints
|
||||
"original_price": null,
|
||||
"active": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ require_approval boolean If ``true``, or
|
||||
paid.
|
||||
require_bundling boolean If ``true``, this item is only available as part of bundles.
|
||||
require_membership boolean If ``true``, booking this item requires an active membership.
|
||||
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this product will
|
||||
be hidden from users without a valid membership.
|
||||
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||
grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will
|
||||
create a membership of the given type.
|
||||
@@ -107,22 +105,8 @@ variations list of objects A list with one
|
||||
├ active boolean If ``false``, this variation will not be sold or shown.
|
||||
├ description multi-lingual string A public description of the variation. May contain
|
||||
├ require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||
├ require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||
be hidden from users without a valid membership.
|
||||
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||
Markdown syntax or can be ``null``.
|
||||
├ sales_channels list of strings Sales channels this variation is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
|
||||
The item-level list takes precedence, i.e. a sales
|
||||
channel needs to be on both lists for the item to be
|
||||
available.
|
||||
├ available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
└ position integer An integer, used for sorting
|
||||
addons list of objects Definition of add-ons that can be chosen for this item.
|
||||
Only writable during creation,
|
||||
@@ -159,10 +143,6 @@ meta_data object Values set for
|
||||
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
|
||||
``grant_membership_duration_days`` and ``grant_membership_duration_months`` have been added.
|
||||
|
||||
.. versionchanged:: 4.4
|
||||
|
||||
The attributes ``require_membership_hidden`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
@@ -250,10 +230,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -265,10 +241,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -365,10 +337,6 @@ Endpoints
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"description": null,
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
@@ -379,10 +347,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -458,10 +422,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -473,10 +433,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -541,10 +497,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -556,10 +508,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -655,10 +603,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -670,10 +614,6 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
@@ -128,10 +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.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -419,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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ bic
|
||||
BIC
|
||||
boolean
|
||||
booleans
|
||||
bugfix
|
||||
cancelled
|
||||
casted
|
||||
Ceph
|
||||
@@ -78,7 +77,6 @@ mixin
|
||||
mixins
|
||||
multi
|
||||
multidomain
|
||||
multiplicator
|
||||
namespace
|
||||
namespaced
|
||||
namespaces
|
||||
|
||||
@@ -4,7 +4,8 @@ Embeddable Widget
|
||||
=================
|
||||
|
||||
If you want to show your ticket shop on your event website or blog, you can use our JavaScript widget. This way,
|
||||
users will not need to leave your site to buy their ticket in most cases.
|
||||
users will not need to leave your site to buy their ticket in most cases. The widget will still open a new tab
|
||||
for the checkout if the user is on a mobile device.
|
||||
|
||||
To obtain the correct HTML code for embedding your event into your website, we recommend that you go to the "Widget"
|
||||
tab of your event's settings. You can specify some optional settings there (for example the language of the widget)
|
||||
|
||||
@@ -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.4.0.dev0"
|
||||
__version__ = "4.2.0"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2021-09-15 11:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixapi', '0006_alter_webhook_target_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='webhookcall',
|
||||
name='target_url',
|
||||
field=models.URLField(max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -120,7 +120,7 @@ class WebHookEventListener(models.Model):
|
||||
class WebHookCall(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
target_url = models.URLField(max_length=255)
|
||||
target_url = models.URLField()
|
||||
action_type = models.CharField(max_length=255)
|
||||
is_retry = models.BooleanField(default=False)
|
||||
execution_time = models.FloatField(null=True)
|
||||
|
||||
@@ -54,7 +54,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.services.seating import (
|
||||
SeatProtected, generate_seats, validate_plan_change,
|
||||
)
|
||||
from pretix.base.settings import LazyI18nStringList, validate_event_settings
|
||||
from pretix.base.settings import validate_event_settings
|
||||
from pretix.base.signals import api_event_settings_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -704,7 +704,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'payment_term_accept_late',
|
||||
'payment_explanation',
|
||||
'payment_pending_hidden',
|
||||
'mail_days_order_expire_warning',
|
||||
'ticket_download',
|
||||
'ticket_download_date',
|
||||
'ticket_download_addons',
|
||||
@@ -790,10 +789,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
data = super().validate(data)
|
||||
settings_dict = self.instance.freeze()
|
||||
settings_dict.update(data)
|
||||
|
||||
if data.get('confirm_texts') is not None:
|
||||
data['confirm_texts'] = LazyI18nStringList(data['confirm_texts'])
|
||||
|
||||
validate_event_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
|
||||
@@ -31,10 +31,9 @@
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import os.path
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
@@ -59,8 +58,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
'require_membership', 'require_membership_types',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -75,8 +73,7 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
'require_membership', 'require_membership_types',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -164,7 +161,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||
), max_size=10 * 1024 * 1024)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -175,7 +172,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
|
||||
'require_membership', 'require_membership_types', 'grant_membership_type',
|
||||
'grant_membership_duration_like_event', 'grant_membership_duration_days',
|
||||
'grant_membership_duration_months')
|
||||
read_only_fields = ('has_variations',)
|
||||
@@ -248,13 +245,10 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
picture = validated_data.pop('picture', None)
|
||||
item = Item.objects.create(**validated_data)
|
||||
if picture:
|
||||
item.picture.save(os.path.basename(picture.name), picture)
|
||||
|
||||
for variation_data in variations_data:
|
||||
require_membership_types = variation_data.pop('require_membership_types', [])
|
||||
require_membership_types = variation_data.pop('require_membership_types')
|
||||
v = ItemVariation.objects.create(item=item, **variation_data)
|
||||
if require_membership_types:
|
||||
v.require_membership_types.add(*require_membership_types)
|
||||
@@ -275,10 +269,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
picture = validated_data.pop('picture', None)
|
||||
item = super().update(instance, validated_data)
|
||||
if picture:
|
||||
item.picture.save(os.path.basename(picture.name), picture)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
|
||||
@@ -26,7 +26,6 @@ from collections import Counter, defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
@@ -192,7 +191,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
if cf.file.size > 10 * 1024 * 1024:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
data['options'] = []
|
||||
@@ -1404,7 +1403,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
|
||||
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values())
|
||||
return order
|
||||
|
||||
|
||||
|
||||
@@ -295,7 +295,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'organizer_logo_image_inherit',
|
||||
'organizer_logo_image'
|
||||
]
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
@@ -430,13 +429,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:
|
||||
@@ -610,7 +603,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
if cf.file.size > 10 * 1024 * 1024:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
|
||||
@@ -92,8 +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')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
@@ -216,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',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -46,8 +46,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, checks, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
|
||||
@@ -82,13 +82,6 @@ class SalesChannel:
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def customer_accounts_supported(self) -> bool:
|
||||
"""
|
||||
If this property is ``True``, checkout will show the customer login step.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
def get_all_sales_channels():
|
||||
global _ALL_CHANNELS
|
||||
|
||||
@@ -462,16 +462,6 @@ def base_placeholders(sender, **kwargs):
|
||||
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
|
||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_remove', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
event, 'presale:event.waitinglist.remove'
|
||||
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.waitinglist.remove',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
@@ -539,22 +529,6 @@ def base_placeholders(sender, **kwargs):
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_url_list', ['event', 'voucher_list'],
|
||||
lambda event, voucher_list: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in voucher_list
|
||||
]),
|
||||
lambda event: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||
]),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil
|
||||
@@ -43,10 +42,10 @@ from django.db.models import (
|
||||
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
|
||||
Q, Subquery, Sum, When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import Coalesce, TruncDate
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
|
||||
from pretix.base.models import (
|
||||
@@ -130,7 +129,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
label=_('End event date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
|
||||
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
|
||||
'Will also include other dates in case of mixed orders!')
|
||||
)),
|
||||
]
|
||||
@@ -182,43 +181,41 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if not isinstance(date_value, date):
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
|
||||
filters[f'{rel}datetime__gte'] = datetime_value
|
||||
annotations['date'] = TruncDate(f'{rel}datetime')
|
||||
filters['date__gte'] = date_value
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if not isinstance(date_value, date):
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
|
||||
filters[f'{rel}datetime__lte'] = datetime_value
|
||||
annotations['date'] = TruncDate(f'{rel}datetime')
|
||||
filters['date__lte'] = date_value
|
||||
|
||||
if form_data.get('event_date_from'):
|
||||
date_value = form_data.get('event_date_from')
|
||||
if not isinstance(date_value, date):
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
|
||||
annotations['event_date_max'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_max__gte'] = datetime_value
|
||||
filters['event_date_max__gte'] = date_value
|
||||
|
||||
if form_data.get('event_date_to'):
|
||||
date_value = form_data.get('event_date_to')
|
||||
if not isinstance(date_value, date):
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
|
||||
annotations['event_date_min'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_min__lte'] = datetime_value
|
||||
filters['event_date_min__lte'] = date_value
|
||||
|
||||
if filters:
|
||||
return qs.annotate(**annotations).filter(**filters)
|
||||
@@ -873,78 +870,6 @@ class QuotaListExporter(ListExporter):
|
||||
return '{}_quotas'.format(self.event.slug)
|
||||
|
||||
|
||||
def generate_GiftCardTransactionListExporter(organizer): # hackhack
|
||||
class GiftcardTransactionListExporter(ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
d = [
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
return d
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
qs = GiftCardTransaction.objects.filter(
|
||||
card__issuer=organizer,
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(
|
||||
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
|
||||
qs = qs.filter(
|
||||
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode'),
|
||||
_('Date'),
|
||||
_('Amount'),
|
||||
_('Currency'),
|
||||
_('Order'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.card.secret,
|
||||
_('TEST MODE') if obj.card.testmode else '',
|
||||
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
obj.value,
|
||||
obj.card.currency,
|
||||
obj.order.full_code if obj.order else None,
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcardtransactions'.format(organizer.slug)
|
||||
return GiftcardTransactionListExporter
|
||||
|
||||
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
identifier = 'giftcardredemptionlist'
|
||||
verbose_name = gettext_lazy('Gift card redemptions')
|
||||
@@ -1137,8 +1062,3 @@ def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
|
||||
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
|
||||
return generate_GiftCardListExporter(sender)
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardtransactionlist")
|
||||
def register_multievent_i_giftcardtransactionlist_exporter(sender, **kwargs):
|
||||
return generate_GiftCardTransactionListExporter(sender)
|
||||
|
||||
@@ -37,19 +37,21 @@ import json
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import dateutil.parser
|
||||
import pycountry
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from babel import Locale
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.forms import Select
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
@@ -73,9 +75,8 @@ from pretix.base.i18n import (
|
||||
get_babel_locale, get_language_without_region, language,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||
from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
|
||||
from pretix.base.services.tax import (
|
||||
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
|
||||
from pretix.base.models.tax import (
|
||||
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
|
||||
)
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
|
||||
@@ -152,9 +153,8 @@ class NamePartsWidget(forms.MultiWidget):
|
||||
final_attrs,
|
||||
id='%s_%s' % (id_, i),
|
||||
title=self.scheme['fields'][i][1],
|
||||
placeholder=self.scheme['fields'][i][1],
|
||||
)
|
||||
if not isinstance(widget, widgets.Select):
|
||||
these_attrs['placeholder'] = self.scheme['fields'][i][1]
|
||||
if self.scheme['fields'][i][0] in REQUIRED_NAME_PARTS:
|
||||
if self.field.required:
|
||||
these_attrs['required'] = 'required'
|
||||
@@ -507,7 +507,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp"))
|
||||
kwargs.setdefault('max_size', settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||
kwargs.setdefault('max_size', 10 * 1024 * 1024)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -739,7 +739,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
||||
".bmp", ".tif", ".tiff"
|
||||
),
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
attrs = {}
|
||||
@@ -900,7 +900,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
'data-display-dependency': '#id_is_business_1',
|
||||
'autocomplete': 'organization',
|
||||
}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-in-eu': ','.join(EU_COUNTRIES)}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
labels = {
|
||||
@@ -920,18 +920,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
elif self.validate_vat_id:
|
||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||
str(_('Optional, but depending on the country you reside in we might need to charge you '
|
||||
'additional taxes if you do not enter it.')),
|
||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
||||
])
|
||||
else:
|
||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
|
||||
'depending on your and the seller’s country of residence.')),
|
||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
||||
])
|
||||
|
||||
self.fields['country'].choices = CachedCountries()
|
||||
|
||||
@@ -963,7 +951,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.fields['state'].widget.is_required = True
|
||||
|
||||
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
|
||||
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
|
||||
if cc and not is_eu_country(cc) and fprefix + 'vat_id' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'vat_id']
|
||||
|
||||
@@ -988,7 +976,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=_('Name'),
|
||||
initial=self.instance.name_parts,
|
||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
||||
)
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
||||
if not event.settings.invoice_name_required:
|
||||
@@ -1013,7 +1001,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not data.get('is_business'):
|
||||
data['company'] = ''
|
||||
data['vat_id'] = ''
|
||||
if data.get('is_business') and not ask_for_vat_id(data.get('country')):
|
||||
if data.get('is_business') and not is_eu_country(data.get('country')):
|
||||
data['vat_id'] = ''
|
||||
if self.event.settings.invoice_address_required:
|
||||
if data.get('is_business') and not data.get('company'):
|
||||
@@ -1036,19 +1024,36 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
||||
self.cleaned_data['country'] = ''
|
||||
|
||||
if data.get('vat_id') and is_eu_country(data.get('country')) and data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
|
||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
|
||||
elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'):
|
||||
try:
|
||||
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id = normalized_id
|
||||
except VATIDFinalError as e:
|
||||
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
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class BusinessBooleanRadio(forms.RadioSelect):
|
||||
self.require_business = require_business
|
||||
if self.require_business:
|
||||
choices = (
|
||||
('business', _('Business or institutional customer')),
|
||||
('business', _('Business customer')),
|
||||
)
|
||||
else:
|
||||
choices = (
|
||||
|
||||
@@ -595,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(
|
||||
@@ -611,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),
|
||||
]
|
||||
@@ -805,7 +803,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
objects += [
|
||||
_draw(pgettext('invoice', 'Cancellation number'), self.invoice.number,
|
||||
value_size, self.left_margin + 50 * mm, 45 * mm),
|
||||
_draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number,
|
||||
_draw(pgettext('invoice', 'Original invoice'), self.invoice.number,
|
||||
value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm),
|
||||
]
|
||||
else:
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
"""
|
||||
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||
database backend unknown to us might actually use this information for its database schema.
|
||||
|
||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||
certain that some changes to models will never require a change to the database. In this case,
|
||||
not creating a migration for certain changes will save us some performance while applying them
|
||||
*and* allow for a cleaner git history. Win-win!
|
||||
|
||||
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
||||
"""
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import models as modelops
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
|
||||
def monkeypatch_migrations():
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||
IGNORED_ATTRS = [
|
||||
# (field type, attribute name, banlist of field sub-types)
|
||||
(models.Field, 'verbose_name', []),
|
||||
(models.Field, 'help_text', []),
|
||||
(models.Field, 'validators', []),
|
||||
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
||||
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
||||
models.TimeField]),
|
||||
(models.CharField, 'choices', [CountryField])
|
||||
]
|
||||
|
||||
original_deconstruct = models.Field.deconstruct
|
||||
|
||||
def new_deconstruct(self):
|
||||
name, path, args, kwargs = original_deconstruct(self)
|
||||
for ftype, attr, banlist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
|
||||
kwargs.pop(attr, None)
|
||||
return name, path, args, kwargs
|
||||
|
||||
models.Field.deconstruct = new_deconstruct
|
||||
@@ -1,79 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import Order, OrderFee, OrderPosition
|
||||
from pretix.base.models.orders import Transaction
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check order for consistency with their transactions"
|
||||
|
||||
@scopes_disabled()
|
||||
def handle(self, *args, **options):
|
||||
qs = Order.objects.annotate(
|
||||
position_total=Coalesce(
|
||||
Subquery(
|
||||
OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('price')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(Decimal(0)), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
fee_total=Coalesce(
|
||||
Subquery(
|
||||
OrderFee.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('value')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(Decimal(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(Decimal(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(Decimal(0))),
|
||||
default=F('position_total') + F('fee_total'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).exclude(
|
||||
total=F('position_total') + F('fee_total'),
|
||||
tx_total=F('correct_total')
|
||||
).select_related('event')
|
||||
for o in qs:
|
||||
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
|
||||
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}")
|
||||
|
||||
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
|
||||
@@ -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.'))
|
||||
@@ -32,11 +32,53 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
"""
|
||||
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||
database backend unknown to us might actually use this information for its database schema.
|
||||
|
||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||
certain that some changes to models will never require a change to the database. In this case,
|
||||
not creating a migration for certain changes will save us some performance while applying them
|
||||
*and* allow for a cleaner git history. Win-win!
|
||||
|
||||
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
||||
"""
|
||||
from django.core.management.commands.makemigrations import Command as Parent
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import models as modelops
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
from ._migrations import monkeypatch_migrations
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||
IGNORED_ATTRS = [
|
||||
# (field type, attribute name, banlist of field sub-types)
|
||||
(models.Field, 'verbose_name', []),
|
||||
(models.Field, 'help_text', []),
|
||||
(models.Field, 'validators', []),
|
||||
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
||||
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
||||
models.TimeField]),
|
||||
(models.CharField, 'choices', [CountryField])
|
||||
]
|
||||
|
||||
monkeypatch_migrations()
|
||||
original_deconstruct = models.Field.deconstruct
|
||||
|
||||
|
||||
def new_deconstruct(self):
|
||||
name, path, args, kwargs = original_deconstruct(self)
|
||||
for ftype, attr, banlist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
|
||||
kwargs.pop(attr, None)
|
||||
return name, path, args, kwargs
|
||||
|
||||
|
||||
models.Field.deconstruct = new_deconstruct
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
"""
|
||||
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
||||
run when there are things we have no migrations for. Usually, this is intended, and running
|
||||
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
||||
users from doing that by going really dirty and filtering it from the output.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from django.core.management.base import OutputWrapper
|
||||
@@ -39,15 +45,9 @@ from django.core.management.commands.migrate import Command as Parent
|
||||
|
||||
|
||||
class OutputFilter(OutputWrapper):
|
||||
"""
|
||||
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
||||
run when there are things we have no migrations for. Usually, this is intended, and running
|
||||
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
||||
users from doing that by going really dirty and filtering it from the output.
|
||||
"""
|
||||
banlist = (
|
||||
"have changes that are not yet reflected",
|
||||
"re-run 'manage.py migrate'"
|
||||
"Your models have changes that are not yet reflected",
|
||||
"Run 'manage.py makemigrations' to make new "
|
||||
)
|
||||
|
||||
def write(self, msg, style_func=None, ending=None):
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 3.2.2 on 2021-05-23 13:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0195_auto_20210622_1457'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='customer',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_addresses', to='pretixbase.customer'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AttendeeProfile',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('attendee_name_cached', models.CharField(max_length=255, null=True)),
|
||||
('attendee_name_parts', models.JSONField(default=dict)),
|
||||
('attendee_email', models.EmailField(max_length=254, null=True)),
|
||||
('company', models.CharField(max_length=255, null=True)),
|
||||
('street', models.TextField(null=True)),
|
||||
('zipcode', models.CharField(max_length=30, null=True)),
|
||||
('city', models.CharField(max_length=255, null=True)),
|
||||
('country', pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True)),
|
||||
('state', models.CharField(max_length=255, null=True)),
|
||||
('answers', models.JSONField(default=list)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendee_profiles', to='pretixbase.customer')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,36 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2021-09-14 08:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
import pretix.base.models.items
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0196_auto_20210523_1322'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='available_from',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='available_until',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='hide_without_voucher',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='sales_channels',
|
||||
field=pretix.base.models.fields.MultiStringField(default=pretix.base.models.items._all_sales_channels_identifiers),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2021-10-29 09:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0200_transaction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Check',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('result', models.CharField(max_length=190)),
|
||||
('check_type', models.CharField(max_length=190)),
|
||||
('log', models.TextField()),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
@@ -23,7 +23,6 @@ from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User, WebAuthnDevice
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin, CheckinList
|
||||
from .checks import Check
|
||||
from .customers import Customer
|
||||
from .devices import Device, Gate
|
||||
from .event import (
|
||||
@@ -43,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,
|
||||
|
||||
@@ -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') 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
|
||||
@@ -1,54 +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 file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
#
|
||||
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
||||
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
||||
#
|
||||
# This file contains Apache-licensed contributions copyrighted by: Jakob Schnell
|
||||
#
|
||||
# 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.
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
class Check(LoggedModel):
|
||||
RESULT_OK = 'ok'
|
||||
RESULT_WARNING = 'warning'
|
||||
RESULT_ERROR = 'error'
|
||||
|
||||
RESULTS = (
|
||||
(RESULT_OK, _('OK')),
|
||||
(RESULT_WARNING, _('Warning')),
|
||||
(RESULT_ERROR, _('Error')),
|
||||
)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
result = models.CharField(max_length=190, choices=RESULTS)
|
||||
check_type = models.CharField(max_length=190)
|
||||
log = models.TextField()
|
||||
@@ -19,22 +19,19 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import (
|
||||
check_password, is_password_usable, make_password,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.organizer import Organizer
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
|
||||
|
||||
class Customer(LoggedModel):
|
||||
@@ -91,8 +88,6 @@ class Customer(LoggedModel):
|
||||
self.all_logentries().update(data={}, shredded=True)
|
||||
self.orders.all().update(customer=None)
|
||||
self.memberships.all().update(attendee_name_parts=None)
|
||||
self.attendee_profiles.all().delete()
|
||||
self.invoice_addresses.all().delete()
|
||||
|
||||
@scopes_disabled()
|
||||
def assign_identifier(self):
|
||||
@@ -179,94 +174,3 @@ class Customer(LoggedModel):
|
||||
continue
|
||||
ctx['name_%s' % f] = self.name_parts.get(f, '')
|
||||
return ctx
|
||||
|
||||
@property
|
||||
def stored_addresses(self):
|
||||
return self.invoice_addresses(manager='profiles')
|
||||
|
||||
def usable_memberships(self, for_event, testmode=False):
|
||||
return self.memberships.active(for_event).with_usages().filter(
|
||||
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
|
||||
testmode=testmode,
|
||||
)
|
||||
|
||||
|
||||
class AttendeeProfile(models.Model):
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
related_name='attendee_profiles',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
attendee_name_cached = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Attendee name"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
attendee_name_parts = models.JSONField(
|
||||
blank=True, default=dict
|
||||
)
|
||||
attendee_email = models.EmailField(
|
||||
verbose_name=_("Attendee email"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
|
||||
country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
|
||||
answers = models.JSONField(default=list)
|
||||
|
||||
objects = ScopedManager(organizer='customer__organizer')
|
||||
|
||||
@property
|
||||
def attendee_name(self):
|
||||
if not self.attendee_name_parts:
|
||||
return None
|
||||
if '_legacy' in self.attendee_name_parts:
|
||||
return self.attendee_name_parts['_legacy']
|
||||
if '_scheme' in self.attendee_name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
|
||||
else:
|
||||
scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
|
||||
return scheme['concatenation'](self.attendee_name_parts).strip()
|
||||
|
||||
@property
|
||||
def state_name(self):
|
||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||
if sd:
|
||||
return sd.name
|
||||
return self.state
|
||||
|
||||
@property
|
||||
def state_for_address(self):
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
return ""
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
|
||||
return self.state_name
|
||||
return self.state
|
||||
|
||||
def describe(self):
|
||||
from .items import Question
|
||||
from .orders import QuestionAnswer
|
||||
|
||||
parts = [
|
||||
self.attendee_name,
|
||||
self.attendee_email,
|
||||
self.company,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name,
|
||||
]
|
||||
for a in self.answers:
|
||||
value = a.get('value')
|
||||
try:
|
||||
value = ", ".join(value.values())
|
||||
except AttributeError:
|
||||
value = str(value)
|
||||
answer = QuestionAnswer(question=Question(type=a.get('question_type')), answer=value)
|
||||
val = str(answer)
|
||||
parts.append(f'{a["field_label"]}: {val}')
|
||||
|
||||
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
|
||||
|
||||
@@ -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,40 +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)
|
||||
|
||||
def get_time_range_display(self, tz=None, force_show_end=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start time and sometimes the end time
|
||||
of the event with respect to the current locale and to the ``show_date_to``
|
||||
setting. Dates are not shown. This is usually used in combination with get_date_range_display
|
||||
"""
|
||||
tz = tz or self.timezone
|
||||
|
||||
show_date_to = self.date_to and (self.settings.show_date_to or force_show_end) and (
|
||||
# Show date to if start and end are on the same day ("08:00-10:00")
|
||||
self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() or
|
||||
# Show date to if start and end are on consecutive days and less than 24h ("23:00-03:00")
|
||||
(self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() + timedelta(days=1) and
|
||||
self.date_to.astimezone(tz).time() < self.date_from.astimezone(tz).time())
|
||||
# Do not show end time if this is a 5-day event because there's no way to make it understandable
|
||||
)
|
||||
if show_date_to:
|
||||
return '{} – {}'.format(
|
||||
_date(self.date_from.astimezone(tz), "TIME_FORMAT"),
|
||||
_date(self.date_to.astimezone(tz), "TIME_FORMAT"),
|
||||
)
|
||||
return _date(self.date_from.astimezone(tz), "TIME_FORMAT")
|
||||
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
@@ -278,10 +245,6 @@ class EventMixin:
|
||||
).values('items')
|
||||
sq_active_variation = ItemVariation.objects.filter(
|
||||
Q(active=True)
|
||||
& Q(sales_channels__contains=channel)
|
||||
& Q(hide_without_voucher=False)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(item__active=True)
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||
@@ -1321,7 +1284,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
verbose_name=_("Frontpage text")
|
||||
)
|
||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||
related_name='subevents', verbose_name=_('Seating plan'))
|
||||
related_name='subevents')
|
||||
|
||||
items = models.ManyToManyField('Item', through='SubEventItem')
|
||||
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
||||
@@ -1431,7 +1394,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)
|
||||
|
||||
@@ -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')
|
||||
@@ -302,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):
|
||||
"""
|
||||
|
||||
@@ -523,12 +523,6 @@ class Item(LoggedModel):
|
||||
verbose_name=_('Allowed membership types'),
|
||||
blank=True,
|
||||
)
|
||||
require_membership_hidden = models.BooleanField(
|
||||
verbose_name=_('Hide without a valid membership'),
|
||||
help_text=_('Do not show this unless the customer is logged in and has a valid membership. Be aware that '
|
||||
'this means it will never be visible in the widget.'),
|
||||
default=False,
|
||||
)
|
||||
grant_membership_type = models.ForeignKey(
|
||||
'MembershipType',
|
||||
null=True, blank=True,
|
||||
@@ -693,9 +687,9 @@ class Item(LoggedModel):
|
||||
return res
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderPosition, Transaction
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
|
||||
return not Transaction.objects.filter(item=self).exists() and not OrderPosition.all.filter(item=self).exists()
|
||||
return not OrderPosition.all.filter(item=self).exists()
|
||||
|
||||
@property
|
||||
def includes_mixed_tax_rate(self):
|
||||
@@ -742,11 +736,6 @@ class Item(LoggedModel):
|
||||
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
|
||||
|
||||
|
||||
def _all_sales_channels_identifiers():
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
return list(get_all_sales_channels().keys())
|
||||
|
||||
|
||||
class ItemVariation(models.Model):
|
||||
"""
|
||||
A variation of a product. For example, if your item is 'T-Shirt'
|
||||
@@ -772,7 +761,7 @@ class ItemVariation(models.Model):
|
||||
)
|
||||
value = I18nCharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Variation')
|
||||
verbose_name=_('Description')
|
||||
)
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
@@ -808,35 +797,6 @@ class ItemVariation(models.Model):
|
||||
verbose_name=_('Membership types'),
|
||||
blank=True,
|
||||
)
|
||||
require_membership_hidden = models.BooleanField(
|
||||
verbose_name=_('Hide without a valid membership'),
|
||||
help_text=_('Do not show this unless the customer is logged in and has a valid membership. Be aware that '
|
||||
'this means it will never be visible in the widget.'),
|
||||
default=False,
|
||||
)
|
||||
available_from = models.DateTimeField(
|
||||
verbose_name=_("Available from"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold before the given date.')
|
||||
)
|
||||
available_until = models.DateTimeField(
|
||||
verbose_name=_("Available until"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold after the given date.')
|
||||
)
|
||||
sales_channels = fields.MultiStringField(
|
||||
verbose_name=_('Sales channels'),
|
||||
default=_all_sales_channels_identifiers,
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
blank=True,
|
||||
)
|
||||
hide_without_voucher = models.BooleanField(
|
||||
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
|
||||
default=False,
|
||||
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
||||
'that unlocks this variation.')
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='item__event__organizer')
|
||||
|
||||
@@ -958,37 +918,16 @@ class ItemVariation(models.Model):
|
||||
return self.position < other.position
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderPosition, Transaction,
|
||||
)
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
|
||||
return (
|
||||
not Transaction.objects.filter(variation=self).exists()
|
||||
and not OrderPosition.objects.filter(variation=self).exists()
|
||||
not OrderPosition.objects.filter(variation=self).exists()
|
||||
and not CartPosition.objects.filter(variation=self).exists()
|
||||
)
|
||||
|
||||
def is_only_variation(self):
|
||||
return ItemVariation.objects.filter(item=self.item).count() == 1
|
||||
|
||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
return False
|
||||
if self.available_until and self.available_until < now_dt:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
"""
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
and its ``available_from`` and ``available_until`` fields
|
||||
"""
|
||||
now_dt = now_dt or now()
|
||||
if not self.active or not self.is_available_by_time(now_dt):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
@@ -1681,8 +1620,6 @@ class Quota(LoggedModel):
|
||||
|
||||
@staticmethod
|
||||
def clean_items(event, items, variations):
|
||||
if not items:
|
||||
return
|
||||
for item in items:
|
||||
if event != item.event:
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -80,9 +80,6 @@ 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'])
|
||||
@@ -1028,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)
|
||||
@@ -1550,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'])
|
||||
|
||||
@@ -1564,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):
|
||||
"""
|
||||
@@ -2043,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)
|
||||
@@ -2087,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):
|
||||
@@ -2110,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
|
||||
@@ -2161,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")
|
||||
@@ -2210,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
|
||||
@@ -2276,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()
|
||||
@@ -2327,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:
|
||||
@@ -2340,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,
|
||||
@@ -2379,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
|
||||
@@ -2595,12 +2334,6 @@ class CartPosition(AbstractPosition):
|
||||
class InvoiceAddress(models.Model):
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
related_name='invoice_addresses',
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
@@ -2612,7 +2345,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(
|
||||
@@ -2626,7 +2360,6 @@ class InvoiceAddress(models.Model):
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='order__event__organizer')
|
||||
profiles = ScopedManager(organizer='customer__organizer')
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.order:
|
||||
@@ -2639,20 +2372,6 @@ class InvoiceAddress(models.Model):
|
||||
self.name_parts = {}
|
||||
super().save(**kwargs)
|
||||
|
||||
def describe(self):
|
||||
parts = [
|
||||
self.company,
|
||||
self.name,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name,
|
||||
self.vat_id,
|
||||
self.custom_field,
|
||||
self.internal_reference,
|
||||
(_('Beneficiary') + ': ' + self.beneficiary) if self.beneficiary else '',
|
||||
]
|
||||
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
|
||||
|
||||
@property
|
||||
def is_empty(self):
|
||||
return (
|
||||
@@ -2688,30 +2407,6 @@ class InvoiceAddress(models.Model):
|
||||
raise TypeError("Invalid name given.")
|
||||
return scheme['concatenation'](self.name_parts).strip()
|
||||
|
||||
def for_js(self):
|
||||
d = {}
|
||||
|
||||
if self.name_parts:
|
||||
if '_scheme' in self.name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
|
||||
for i, (k, l, w) in enumerate(scheme['fields']):
|
||||
d[f'name_parts_{i}'] = self.name_parts.get(k) or ''
|
||||
|
||||
d.update({
|
||||
'company': self.company,
|
||||
'is_business': self.is_business,
|
||||
'street': self.street,
|
||||
'zipcode': self.zipcode,
|
||||
'city': self.city,
|
||||
'country': str(self.country) if self.country else None,
|
||||
'state': str(self.state) if self.state else None,
|
||||
'vat_id': self.vat_id,
|
||||
'custom_field': self.custom_field,
|
||||
'internal_reference': self.internal_reference,
|
||||
'beneficiary': self.beneficiary,
|
||||
})
|
||||
return d
|
||||
|
||||
|
||||
def cachedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
|
||||
@@ -25,6 +25,7 @@ from decimal import Decimal
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
from i18nfield.fields import I18nCharField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
@@ -92,7 +93,7 @@ TAXED_ZERO = TaxedPrice(
|
||||
|
||||
EU_COUNTRIES = {
|
||||
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT',
|
||||
'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
|
||||
'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB'
|
||||
}
|
||||
EU_CURRENCIES = {
|
||||
'BG': 'BGN',
|
||||
@@ -105,21 +106,17 @@ EU_CURRENCIES = {
|
||||
'RO': 'RON',
|
||||
'SE': 'SEK'
|
||||
}
|
||||
VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH'}
|
||||
|
||||
|
||||
def is_eu_country(cc):
|
||||
cc = str(cc)
|
||||
return cc in EU_COUNTRIES
|
||||
|
||||
|
||||
def ask_for_vat_id(cc):
|
||||
cc = str(cc)
|
||||
return cc in VAT_ID_COUNTRIES
|
||||
if cc == 'GB':
|
||||
return now().astimezone(get_current_timezone()).year <= 2020
|
||||
else:
|
||||
return cc in EU_COUNTRIES
|
||||
|
||||
|
||||
def cc_to_vat_prefix(country_code):
|
||||
country_code = str(country_code)
|
||||
if country_code == 'GR':
|
||||
return 'EL'
|
||||
return country_code
|
||||
@@ -165,13 +162,10 @@ class TaxRule(LoggedModel):
|
||||
pass
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import (
|
||||
OrderFee, OrderPosition, Transaction,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPosition
|
||||
|
||||
return (
|
||||
not Transaction.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not self.event.items.filter(tax_rule=self).exists()
|
||||
and self.event.settings.tax_rate_default != self
|
||||
|
||||
@@ -21,9 +21,8 @@
|
||||
#
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db.models import F, Q, Sum
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
@@ -115,12 +114,9 @@ class WaitingListEntry(LoggedModel):
|
||||
return '%s waits for %s' % (str(self.email), str(self.item))
|
||||
|
||||
def clean(self):
|
||||
try:
|
||||
WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk)
|
||||
WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
|
||||
WaitingListEntry.clean_subevent(self.event, self.subevent)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError('Invalid input')
|
||||
WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk)
|
||||
WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
|
||||
WaitingListEntry.clean_subevent(self.event, self.subevent)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
@@ -151,34 +147,6 @@ class WaitingListEntry(LoggedModel):
|
||||
)
|
||||
if availability[1] is None or availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
|
||||
ev = self.subevent or self.event
|
||||
if ev.seat_category_mappings.filter(product=self.item).exists():
|
||||
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
|
||||
# to use in combination with seating plans. If your event has 50 seats and a quota of 50 and
|
||||
# default settings, everything is fine and the waiting list will work as usual. However, as soon
|
||||
# as those two numbers diverge, either due to misconfiguration or due to intentional features such
|
||||
# as our COVID-19 minimum distance feature, things get ugly. Theoretically, there could be
|
||||
# significant quota available but not a single seat! The waiting list would happily send out vouchers
|
||||
# which do not work at all. Generally, we consider this a "known bug" and not fixable with the current
|
||||
# design of the waiting list and seating features.
|
||||
# However, we've put in a simple safeguard that makes sure the waiting list on its own does not screw
|
||||
# everything up. Specifically, we will not send out vouchers if the number of available seats is less
|
||||
# than the number of valid vouchers *issued through the waiting list*. Things can still go wrong due to
|
||||
# manually created vouchers, manually blocked seats or the minimum distance feature, but this reduces
|
||||
# the possible damage a bit.
|
||||
num_free_seats_for_product = ev.free_seats().filter(product=self.item).count()
|
||||
num_valid_vouchers_for_product = self.event.vouchers.filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
|
||||
block_quota=True,
|
||||
item_id=self.item_id,
|
||||
subevent_id=self.subevent_id,
|
||||
waitinglistentries__isnull=False
|
||||
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
|
||||
free_seats = num_free_seats_for_product - num_valid_vouchers_for_product
|
||||
if not free_seats:
|
||||
raise WaitingListException(_('No seat with this product is currently available.'))
|
||||
|
||||
if self.voucher:
|
||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||
if '@' not in self.email:
|
||||
|
||||
@@ -678,12 +678,9 @@ class SeatColumn(ImportColumn):
|
||||
if value:
|
||||
try:
|
||||
value = Seat.objects.get(
|
||||
event=self.event,
|
||||
seat_guid=value,
|
||||
subevent=previous_values.get('subevent')
|
||||
)
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError(_('Multiple matching seats were found.'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError(_('No matching seat was found.'))
|
||||
if not value.is_available() or value in self._cached:
|
||||
|
||||
@@ -283,16 +283,13 @@ class CartManager:
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if (
|
||||
(op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher)) and
|
||||
(op.voucher is None or not op.voucher.show_hidden_items)
|
||||
):
|
||||
if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
|
||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels):
|
||||
if self._sales_channel not in op.item.sales_channels:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
|
||||
|
||||
@@ -1,191 +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
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
|
||||
from django.db.models.functions import Cast, Coalesce, StrIndex, Substr
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import Check, Invoice, Order, OrderFee, OrderPosition
|
||||
from pretix.base.models.orders import Transaction
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_order_transactions():
|
||||
qs = Order.objects.annotate(
|
||||
position_total=Coalesce(
|
||||
Subquery(
|
||||
OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('price')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
fee_total=Coalesce(
|
||||
Subquery(
|
||||
OrderFee.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('value')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
tx_total=Coalesce(
|
||||
Subquery(
|
||||
Transaction.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum(F('price') * F('count'))).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).annotate(
|
||||
correct_total=Case(
|
||||
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
||||
then=0),
|
||||
default=F('position_total') + F('fee_total'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).exclude(
|
||||
tx_total=F('correct_total')
|
||||
).select_related('event')
|
||||
for o in qs:
|
||||
yield [
|
||||
Check.RESULT_ERROR,
|
||||
f'Order {o.full_code} has a wrong total: Status is {o.status} and sum of positions and fees is '
|
||||
f'{o.position_total + o.fee_total}, so sum of transactions should be {o.correct_total} but is {o.tx_total}'
|
||||
]
|
||||
yield [
|
||||
Check.RESULT_OK,
|
||||
'Check completed.'
|
||||
]
|
||||
|
||||
|
||||
def check_order_total():
|
||||
qs = Order.objects.annotate(
|
||||
position_total=Coalesce(
|
||||
Subquery(
|
||||
OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('price')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
fee_total=Coalesce(
|
||||
Subquery(
|
||||
OrderFee.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('value')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).exclude(
|
||||
total=F('position_total') + F('fee_total'),
|
||||
).select_related('event')
|
||||
for o in qs:
|
||||
if o.total != o.position_total + o.fee_total:
|
||||
yield [
|
||||
Check.RESULT_ERROR,
|
||||
f'Order {o.full_code} has a wrong total: Sum of positions and fees is '
|
||||
f'{o.position_total + o.fee_total}, but total is {o.total}'
|
||||
]
|
||||
yield [
|
||||
Check.RESULT_OK,
|
||||
'Check completed.'
|
||||
]
|
||||
|
||||
|
||||
def check_invoice_gaps():
|
||||
group_qs = Invoice.objects.annotate(
|
||||
sub_prefix=Substr('invoice_no', 1, StrIndex('invoice_no', Value('-'))),
|
||||
).order_by().values(
|
||||
'organizer', 'prefix', 'sub_prefix', 'organizer__slug'
|
||||
)
|
||||
for g in group_qs:
|
||||
numbers = Invoice.objects.filter(
|
||||
prefix=g['prefix'], organizer=g['organizer']
|
||||
)
|
||||
if g['sub_prefix']:
|
||||
numbers = numbers.filter(invoice_no__startswith=g['sub_prefix']).alias(
|
||||
real_number=Cast(Substr('invoice_no', StrIndex('invoice_no', Value('-')) + 1), models.IntegerField())
|
||||
).order_by('real_number')
|
||||
else:
|
||||
numbers = numbers.exclude(invoice_no__contains='-').order_by('invoice_no')
|
||||
|
||||
numbers = list(numbers.values_list('invoice_no', flat=True))
|
||||
previous_n = "(initial state)"
|
||||
previous_numeric_part = 0
|
||||
for n in numbers:
|
||||
numeric_part = int(n.split("-")[-1])
|
||||
if numeric_part != previous_numeric_part + 1:
|
||||
print(g)
|
||||
yield [
|
||||
Check.RESULT_WARNING,
|
||||
f'Organizer {g["organizer__slug"]}, prefix {g["prefix"]}, invoice {n} follows on {previous_n} with gap'
|
||||
]
|
||||
previous_n = n
|
||||
previous_numeric_part = numeric_part
|
||||
|
||||
yield [
|
||||
Check.RESULT_OK,
|
||||
'Check completed.'
|
||||
]
|
||||
|
||||
|
||||
@app.task()
|
||||
@scopes_disabled()
|
||||
def run_default_consistency_checks():
|
||||
check_functions = [
|
||||
('pretix.orders.transactions', check_order_transactions),
|
||||
('pretix.orders.total', check_order_total),
|
||||
('pretix.invoices.gaps', check_invoice_gaps),
|
||||
]
|
||||
for check_type, check_function in check_functions:
|
||||
r = Check.RESULT_OK
|
||||
log = []
|
||||
try:
|
||||
for result, logline in check_function():
|
||||
if result == Check.RESULT_WARNING and r == Check.RESULT_OK:
|
||||
r = Check.RESULT_WARNING
|
||||
elif result == Check.RESULT_ERROR:
|
||||
r = Check.RESULT_ERROR
|
||||
log.append(f'[{result}] {logline}')
|
||||
except Exception as e:
|
||||
logger.exception('Could not run consistency check')
|
||||
r = Check.RESULT_ERROR
|
||||
log.append(f'[error] Check aborted: {e}')
|
||||
|
||||
Check.objects.create(result=r, check_type=check_type, log='\n'.join(log))
|
||||
|
||||
Check.objects.filter(created__lt=now() - timedelta(days=90)).delete()
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@minimum_interval(minutes_after_success=24 * 60)
|
||||
def periodic_consistency_checks(sender, **kwargs):
|
||||
run_default_consistency_checks.apply_async()
|
||||
@@ -40,7 +40,7 @@ def clean_cart_positions(sender, **kwargs):
|
||||
cp.delete()
|
||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
||||
cp.delete()
|
||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||
ia.delete()
|
||||
|
||||
|
||||
|
||||
@@ -291,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')
|
||||
@@ -347,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
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
# 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 os
|
||||
@@ -57,7 +57,7 @@ from django.core.mail import (
|
||||
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
|
||||
@@ -404,7 +404,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
|
||||
if attach_size < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT:
|
||||
if attach_size < 4 * 1024 * 1024:
|
||||
# Do not attach more than 4MB, it will bounce way to often.
|
||||
for a in args:
|
||||
try:
|
||||
@@ -438,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:
|
||||
@@ -450,7 +449,6 @@ 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
|
||||
@@ -474,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(
|
||||
@@ -580,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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -181,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)
|
||||
|
||||
@@ -203,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:
|
||||
@@ -223,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)
|
||||
@@ -265,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
|
||||
@@ -284,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'):
|
||||
@@ -357,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)
|
||||
|
||||
@@ -478,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):
|
||||
@@ -580,7 +572,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
if cp.pk in deleted_positions:
|
||||
continue
|
||||
|
||||
if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()):
|
||||
if not cp.item.is_available() or (cp.variation and not cp.variation.active):
|
||||
err = err or error_messages['unavailable']
|
||||
delete(cp)
|
||||
continue
|
||||
@@ -652,7 +644,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
err = err or error_messages['voucher_required']
|
||||
break
|
||||
|
||||
if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and (
|
||||
if cp.item.hide_without_voucher and (
|
||||
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
|
||||
) and not cp.is_bundled:
|
||||
delete(cp)
|
||||
@@ -912,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')
|
||||
@@ -1561,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',
|
||||
@@ -1585,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',
|
||||
@@ -2129,13 +2122,10 @@ class OrderChangeManager:
|
||||
self._recalculate_total_and_payment_fee()
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
self._reissue_invoice()
|
||||
self._check_paid_price_change()
|
||||
self._check_paid_to_free()
|
||||
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(
|
||||
@@ -2409,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
|
||||
|
||||
|
||||
|
||||
@@ -1,130 +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 re
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
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())
|
||||
client = Client(
|
||||
'https://www.uid-wse-a.admin.ch/V5.0/PublicServices.svc?wsdl',
|
||||
transport=transport
|
||||
)
|
||||
if not client.service.ValidateUID(uid=vat_id):
|
||||
raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
return 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.'
|
||||
))
|
||||
|
||||
|
||||
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}')
|
||||
@@ -22,14 +22,12 @@
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Exists, F, OuterRef, Q, Sum
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, SeatCategoryMapping, User, WaitingListEntry,
|
||||
)
|
||||
from pretix.base.models import Event, User, WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
from pretix.base.services.tasks import EventTask
|
||||
from pretix.base.signals import periodic_task
|
||||
@@ -45,19 +43,6 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
|
||||
quota_cache = {}
|
||||
gone = set()
|
||||
seats_available = {}
|
||||
|
||||
for m in SeatCategoryMapping.objects.filter(event=event).select_related('subevent'):
|
||||
# See comment in WaitingListEntry.send_voucher() for rationale
|
||||
num_free_seets_for_product = (m.subevent or event).free_seats().filter(product_id=m.product_id).count()
|
||||
num_valid_vouchers_for_product = event.vouchers.filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
|
||||
block_quota=True,
|
||||
item_id=m.product_id,
|
||||
subevent_id=m.subevent_id,
|
||||
waitinglistentries__isnull=False
|
||||
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
|
||||
seats_available[(m.product_id, m.subevent_id)] = num_free_seets_for_product - num_valid_vouchers_for_product
|
||||
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=event, voucher__isnull=True
|
||||
@@ -85,11 +70,6 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
continue
|
||||
|
||||
if (wle.item_id, wle.subevent_id) in seats_available:
|
||||
if seats_available[wle.item_id, wle.subevent_id] < 1:
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
continue
|
||||
|
||||
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
||||
if wle.variation
|
||||
else wle.item.quotas.filter(subevent=wle.subevent))
|
||||
@@ -111,9 +91,6 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0,
|
||||
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
|
||||
)
|
||||
|
||||
if (wle.item_id, wle.subevent_id) in seats_available:
|
||||
seats_available[wle.item_id, wle.subevent_id] -= 1
|
||||
else:
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -372,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'}),
|
||||
)
|
||||
},
|
||||
@@ -1446,7 +1440,6 @@ DEFAULTS = {
|
||||
('off', _('All refunds are issued to the original payment method')),
|
||||
('option', _('Customers can choose between a gift card and a refund to their payment method')),
|
||||
('force', _('All refunds are issued as gift cards')),
|
||||
('manually', _('Do not handle refunds automatically at all')),
|
||||
],
|
||||
),
|
||||
'form_class': forms.ChoiceField,
|
||||
@@ -1456,7 +1449,6 @@ DEFAULTS = {
|
||||
('off', _('All refunds are issued to the original payment method')),
|
||||
('option', _('Customers can choose between a gift card and a refund to their payment method')),
|
||||
('force', _('All refunds are issued as gift cards')),
|
||||
('manually', _('Do not handle refunds automatically at all')),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
# When adding a new ordering, remember to also define it in the event model
|
||||
@@ -1715,17 +1707,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'
|
||||
},
|
||||
@@ -1763,12 +1744,6 @@ Please note that this link is only valid within the next {hours} hours!
|
||||
We will reassign the ticket to the next person on the list if you do not
|
||||
redeem the voucher within that timeframe.
|
||||
|
||||
If you do NOT need a ticket any more, we kindly ask you to click the
|
||||
following link to let us know. This way, we can send the ticket as quickly
|
||||
as possible to the next person on the waiting list:
|
||||
|
||||
{url_remove}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -2084,7 +2059,7 @@ Your {organizer} team"""))
|
||||
'form_kwargs': dict(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your event name and date '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
@@ -2095,7 +2070,7 @@ Your {organizer} team"""))
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
|
||||
},
|
||||
@@ -2116,8 +2091,7 @@ Your {organizer} team"""))
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Show event title even if a header image is present'),
|
||||
help_text=_('The title will only be shown on the event front page. If no header image is uploaded for the event, but the header image '
|
||||
'from the organizer profile is used, this option will be ignored and the event title will always be shown.'),
|
||||
help_text=_('The title will only be shown on the event front page.'),
|
||||
)
|
||||
},
|
||||
'organizer_logo_image': {
|
||||
@@ -2127,7 +2101,7 @@ Your {organizer} team"""))
|
||||
'form_kwargs': dict(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your organization name '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
@@ -2138,7 +2112,7 @@ Your {organizer} team"""))
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'organizer_logo_image_large': {
|
||||
@@ -2151,15 +2125,6 @@ Your {organizer} team"""))
|
||||
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
||||
)
|
||||
},
|
||||
'organizer_logo_image_inherit': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Use header image also for events without an individually uploaded logo'),
|
||||
)
|
||||
},
|
||||
'og_image': {
|
||||
'default': None,
|
||||
'type': File,
|
||||
@@ -2167,7 +2132,7 @@ Your {organizer} team"""))
|
||||
'form_kwargs': dict(
|
||||
label=_('Social media image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
|
||||
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
|
||||
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
|
||||
@@ -2178,7 +2143,7 @@ Your {organizer} team"""))
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'invoice_logo_image': {
|
||||
@@ -2189,7 +2154,7 @@ Your {organizer} team"""))
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
@@ -2197,7 +2162,7 @@ Your {organizer} team"""))
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'frontpage_text': {
|
||||
|
||||
@@ -314,14 +314,14 @@ class AttendeeInfoShredder(BaseDataShredder):
|
||||
d['data'][i]['attendee_name_parts'] = {
|
||||
'_legacy': '█'
|
||||
}
|
||||
if 'company' in row:
|
||||
d['data'][i]['company'] = '█'
|
||||
if 'street' in row:
|
||||
d['data'][i]['street'] = '█'
|
||||
if 'zipcode' in row:
|
||||
d['data'][i]['zipcode'] = '█'
|
||||
if 'city' in row:
|
||||
d['data'][i]['city'] = '█'
|
||||
if 'company' in row:
|
||||
d['data'][i]['company'] = '█'
|
||||
if 'street' in row:
|
||||
d['data'][i]['street'] = '█'
|
||||
if 'zipcode' in row:
|
||||
d['data'][i]['zipcode'] = '█'
|
||||
if 'city' in row:
|
||||
d['data'][i]['city'] = '█'
|
||||
le.data = json.dumps(d)
|
||||
le.shredded = True
|
||||
le.save(update_fields=['data', 'shredded'])
|
||||
|
||||
@@ -134,10 +134,6 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import json
|
||||
from django import template
|
||||
from django.template.defaultfilters import stringfilter
|
||||
|
||||
from pretix.helpers.escapejson import escapejson, escapejson_attr
|
||||
from pretix.helpers.escapejson import escapejson
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -40,9 +40,3 @@ def escapejs_filter(value):
|
||||
def escapejs_dumps_filter(value):
|
||||
"""Hex encodes characters for use in a application/json type script."""
|
||||
return escapejson(json.dumps(value))
|
||||
|
||||
|
||||
@register.filter("attr_escapejson_dumps")
|
||||
def attr_escapejs_dumps_filter(value):
|
||||
"""Hex encodes characters for use in an HTML attribute."""
|
||||
return escapejson_attr(json.dumps(value))
|
||||
|
||||
@@ -1,34 +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(name='splitlines')
|
||||
def splitlines(value):
|
||||
return value.split("\n")
|
||||
|
||||
|
||||
@register.filter(name='joinlines')
|
||||
def joinlines(value):
|
||||
return "\n".join(value)
|
||||
@@ -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
|
||||
|
||||
@@ -135,7 +135,7 @@ def truelink_callback(attrs, new=False):
|
||||
|
||||
<a href="https://maps.google.com/location/foo">https://maps.google.com</a>
|
||||
"""
|
||||
text = re.sub(r'[^a-zA-Z0-9.\-/_ ]', '', attrs.get('_text')) # clean up link text
|
||||
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 URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||
|
||||
@@ -27,7 +27,6 @@ from django.urls import reverse
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from pretix.base.models import ItemVariation
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.signals import timeline_events
|
||||
|
||||
@@ -241,39 +240,6 @@ def timeline_for_event(event, subevent=None):
|
||||
})
|
||||
))
|
||||
|
||||
for v in ItemVariation.objects.filter(
|
||||
Q(available_from__isnull=False) | Q(available_until__isnull=False),
|
||||
item__event=event
|
||||
).select_related('item'):
|
||||
if v.available_from:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=v.available_from,
|
||||
description=pgettext_lazy('timeline', 'Product variation "{product} – {variation}" becomes available').format(
|
||||
product=str(v.item),
|
||||
variation=str(v.value),
|
||||
),
|
||||
edit_url=reverse('control:event.item', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'item': v.item.pk,
|
||||
})
|
||||
))
|
||||
if v.available_until:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=v.available_until,
|
||||
description=pgettext_lazy('timeline', 'Product variation "{product} – {variation}" becomes unavailable').format(
|
||||
product=str(v.item),
|
||||
variation=str(v.value),
|
||||
),
|
||||
edit_url=reverse('control:event.item', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'item': v.item.pk,
|
||||
})
|
||||
))
|
||||
|
||||
pprovs = event.get_payment_providers()
|
||||
# This is a special case, depending on payment providers not overriding BasePaymentProvider by too much, but it's
|
||||
# preferrable to having all plugins implement this spearately.
|
||||
|
||||
@@ -36,7 +36,6 @@ from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption,
|
||||
)
|
||||
from pretix.base.models.customers import AttendeeProfile
|
||||
from pretix.presale.signals import contact_form_fields_overrides
|
||||
|
||||
|
||||
@@ -61,9 +60,6 @@ class BaseQuestionsViewMixin:
|
||||
def get_question_override_sets(self, position):
|
||||
return []
|
||||
|
||||
def question_form_kwargs(self, cr):
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def forms(self):
|
||||
"""
|
||||
@@ -75,16 +71,13 @@ class BaseQuestionsViewMixin:
|
||||
for cr in self._positions_for_questions:
|
||||
cartpos = cr if isinstance(cr, CartPosition) else None
|
||||
orderpos = cr if isinstance(cr, OrderPosition) else None
|
||||
|
||||
kwargs = self.question_form_kwargs(cr)
|
||||
form = self.form_class(event=self.request.event,
|
||||
prefix=cr.id,
|
||||
cartpos=cartpos,
|
||||
orderpos=orderpos,
|
||||
all_optional=self.all_optional,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None),
|
||||
**kwargs)
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None))
|
||||
form.pos = cartpos or orderpos
|
||||
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
|
||||
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
|
||||
@@ -143,28 +136,25 @@ class BaseQuestionsViewMixin:
|
||||
if not form.is_valid():
|
||||
failed = True
|
||||
else:
|
||||
if form.cleaned_data.get('saved_id'):
|
||||
prof = AttendeeProfile.objects.filter(
|
||||
customer=self.cart_customer, pk=form.cleaned_data.get('saved_id')
|
||||
).first() or AttendeeProfile(customer=getattr(self, 'cart_customer', None))
|
||||
answers_key_to_index = {a.get('field_name'): i for i, a in enumerate(prof.answers)}
|
||||
else:
|
||||
prof = AttendeeProfile(customer=getattr(self, 'cart_customer', None))
|
||||
answers_key_to_index = {}
|
||||
|
||||
# This form was correctly filled, so we store the data as
|
||||
# answers to the questions / in the CartPosition object
|
||||
for k, v in form.cleaned_data.items():
|
||||
if k in ('save', 'saved_id'):
|
||||
continue
|
||||
elif k == 'attendee_name_parts':
|
||||
if k == 'attendee_name_parts':
|
||||
form.pos.attendee_name_parts = v if v else None
|
||||
prof.attendee_name_parts = form.pos.attendee_name_parts
|
||||
prof.attendee_name_cached = form.pos.attendee_name
|
||||
elif k in ('attendee_email', 'company', 'street', 'zipcode', 'city', 'country', 'state'):
|
||||
v = v if v != '' else None
|
||||
setattr(form.pos, k, v)
|
||||
setattr(prof, k, v)
|
||||
elif k == 'attendee_email':
|
||||
form.pos.attendee_email = v if v != '' else None
|
||||
elif k == 'company':
|
||||
form.pos.company = v if v != '' else None
|
||||
elif k == 'street':
|
||||
form.pos.street = v if v != '' else None
|
||||
elif k == 'zipcode':
|
||||
form.pos.zipcode = v if v != '' else None
|
||||
elif k == 'city':
|
||||
form.pos.city = v if v != '' else None
|
||||
elif k == 'country':
|
||||
form.pos.country = v if v != '' else None
|
||||
elif k == 'state':
|
||||
form.pos.state = v if v != '' else None
|
||||
elif k.startswith('question_'):
|
||||
field = form.fields[k]
|
||||
if hasattr(field, 'answer'):
|
||||
@@ -178,23 +168,6 @@ class BaseQuestionsViewMixin:
|
||||
else:
|
||||
self._save_to_answer(field, field.answer, v)
|
||||
field.answer.save()
|
||||
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
|
||||
answer_value = {o.identifier: str(o) for o in field.answer.options.all()}
|
||||
elif isinstance(field, forms.BooleanField):
|
||||
answer_value = bool(field.answer.answer)
|
||||
else:
|
||||
answer_value = str(field.answer.answer)
|
||||
answer_dict = {
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': answer_value,
|
||||
'question_type': field.question.type,
|
||||
'question_identifier': field.question.identifier,
|
||||
}
|
||||
if k in answers_key_to_index:
|
||||
prof.answers[answers_key_to_index[k]] = answer_dict
|
||||
else:
|
||||
prof.answers.append(answer_dict)
|
||||
elif v != '' and v is not None:
|
||||
answer = QuestionAnswer(
|
||||
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
|
||||
@@ -219,27 +192,7 @@ class BaseQuestionsViewMixin:
|
||||
self._save_to_answer(field, answer, v)
|
||||
answer.save()
|
||||
|
||||
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
|
||||
answer_value = {o.identifier: str(o) for o in answer.options.all()}
|
||||
elif isinstance(field, forms.BooleanField):
|
||||
answer_value = bool(answer.answer)
|
||||
else:
|
||||
answer_value = str(answer.answer)
|
||||
answer_dict = {
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': answer_value,
|
||||
'question_type': field.question.type,
|
||||
'question_identifier': field.question.identifier,
|
||||
}
|
||||
if k in answers_key_to_index:
|
||||
prof.answers[answers_key_to_index[k]] = answer_dict
|
||||
else:
|
||||
prof.answers.append(answer_dict)
|
||||
|
||||
else:
|
||||
field = form.fields[k]
|
||||
|
||||
meta_info.setdefault('question_form_data', {})
|
||||
if v is None:
|
||||
if k in meta_info['question_form_data']:
|
||||
@@ -247,25 +200,8 @@ class BaseQuestionsViewMixin:
|
||||
else:
|
||||
meta_info['question_form_data'][k] = v
|
||||
|
||||
answer_dict = {
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': str(v),
|
||||
'question_type': None,
|
||||
'question_identifier': None,
|
||||
}
|
||||
if k in answers_key_to_index:
|
||||
prof.answers[answers_key_to_index[k]] = answer_dict
|
||||
else:
|
||||
prof.answers.append(answer_dict)
|
||||
|
||||
form.pos.meta_info = json.dumps(meta_info)
|
||||
form.pos.save()
|
||||
|
||||
if form.cleaned_data.get('save') and not failed:
|
||||
prof.save()
|
||||
self.cart_session[f'saved_attendee_profile_{form.pos.pk}'] = prof.pk
|
||||
|
||||
return not failed
|
||||
|
||||
def _save_to_answer(self, field, answer, value):
|
||||
|
||||
@@ -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',
|
||||
@@ -684,20 +683,7 @@ class ItemVariationForm(I18nModelForm):
|
||||
qs = kwargs.pop('membership_types')
|
||||
super().__init__(*args, **kwargs)
|
||||
change_decimal_field(self.fields['default_price'], self.event.currency)
|
||||
self.fields['sales_channels'] = forms.MultipleChoiceField(
|
||||
label=_('Sales channels'),
|
||||
required=False,
|
||||
choices=(
|
||||
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
|
||||
),
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
if not self.instance.pk:
|
||||
self.initial.setdefault('sales_channels', list(get_all_sales_channels().keys()))
|
||||
|
||||
self.fields['description'].widget.attrs['rows'] = 3
|
||||
if qs:
|
||||
self.fields['require_membership_types'].queryset = qs
|
||||
else:
|
||||
@@ -714,20 +700,9 @@ class ItemVariationForm(I18nModelForm):
|
||||
'original_price',
|
||||
'description',
|
||||
'require_membership',
|
||||
'require_membership_hidden',
|
||||
'require_membership_types',
|
||||
'available_from',
|
||||
'available_until',
|
||||
'sales_channels',
|
||||
'hide_without_voucher',
|
||||
'require_membership_types'
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
'available_until': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
||||
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
|
||||
@@ -57,8 +57,7 @@ from pretix.base.forms.widgets import (
|
||||
DatePickerWidget, SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Invoice, InvoiceAddress, ItemAddOn, Order, OrderFee, OrderPosition,
|
||||
TaxRule,
|
||||
InvoiceAddress, ItemAddOn, Order, OrderFee, OrderPosition, TaxRule,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.pricing import get_price
|
||||
@@ -610,17 +609,6 @@ class OrderMailForm(forms.Form):
|
||||
label=_("Subject"),
|
||||
required=True
|
||||
)
|
||||
attach_tickets = forms.BooleanField(
|
||||
label=_("Attach tickets"),
|
||||
help_text=_("Will be ignored if all tickets in this order exceed a given size limit to ensure email deliverability."),
|
||||
required=False
|
||||
)
|
||||
attach_invoices = forms.ModelMultipleChoiceField(
|
||||
label=_("Attach invoices"),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
queryset=Invoice.objects.none()
|
||||
)
|
||||
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
phs = [
|
||||
@@ -653,7 +641,6 @@ class OrderMailForm(forms.Form):
|
||||
widget=forms.Textarea,
|
||||
initial=order.event.settings.mail_text_order_custom_mail.localize(order.locale),
|
||||
)
|
||||
self.fields['attach_invoices'].queryset = order.invoices.all()
|
||||
self._set_field_placeholders('message', ['event', 'order'])
|
||||
|
||||
|
||||
@@ -661,7 +648,6 @@ class OrderPositionMailForm(OrderMailForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
position = self.position = kwargs.pop('position')
|
||||
super().__init__(*args, **kwargs)
|
||||
del self.fields['attach_invoices']
|
||||
self.fields['sendto'].initial = position.attendee_email
|
||||
self.fields['message'] = forms.CharField(
|
||||
label=_("Message"),
|
||||
|
||||
@@ -295,7 +295,6 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
'organizer_homepage_text',
|
||||
'organizer_link_back',
|
||||
'organizer_logo_image_large',
|
||||
'organizer_logo_image_inherit',
|
||||
'giftcard_length',
|
||||
'giftcard_expiry_years',
|
||||
'locales',
|
||||
@@ -314,7 +313,7 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
organizer_logo_image = ExtFileField(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
max_size=10 * 1024 * 1024,
|
||||
required=False,
|
||||
help_text=_('If you provide a logo image, we will by default not show your organization name '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
@@ -325,7 +324,7 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
label=_('Favicon'),
|
||||
ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_FAVICON,
|
||||
max_size=1 * 1024 * 1024,
|
||||
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
|
||||
'We recommend a size of at least 200x200px to accommodate most devices.')
|
||||
)
|
||||
|
||||
@@ -206,7 +206,7 @@ class VoucherForm(I18nModelForm):
|
||||
seats_given=data.get('seat') or data.get('seats'),
|
||||
block_quota=data.get('block_quota')
|
||||
)
|
||||
if not data.get('show_hidden_items') and (
|
||||
if not self.instance.show_hidden_items and (
|
||||
(self.instance.quota and all(i.hide_without_voucher for i in self.instance.quota.items.all()))
|
||||
or (self.instance.item and self.instance.item.hide_without_voucher)
|
||||
):
|
||||
|
||||
@@ -422,11 +422,6 @@ def get_global_navigation(request):
|
||||
'url': reverse('control:global.license'),
|
||||
'active': (url.url_name == 'global.license'),
|
||||
},
|
||||
{
|
||||
'label': _('Consistency check'),
|
||||
'url': reverse('control:global.consistency'),
|
||||
'active': (url.url_name == 'global.consistency'),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/dragndroplist.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/orderchange.js" %}"></script>
|
||||
@@ -59,7 +58,7 @@
|
||||
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "lightbox/js/lightbox.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{{ html_head|safe }}
|
||||
|
||||
@@ -256,22 +256,9 @@
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
The waiting list currently is not compatible with some advanced features of pretix such as
|
||||
add-on products or product bundles.
|
||||
seating plans, add-on products or product bundles.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
The waiting list determines availability mainly based on quotas. If you use a seating plan and your
|
||||
number of available seats is less than the available quota, you might run into situations where
|
||||
people are sent an email from the waiting list but still are unable to book a seat.
|
||||
{% endblocktrans %}
|
||||
<strong>
|
||||
{% blocktrans trimmed %}
|
||||
Specifically, this means the waiting list is not safe to use together with the minimum distance
|
||||
feature of our seating plan module.
|
||||
{% endblocktrans %}
|
||||
</strong>
|
||||
</div>
|
||||
{% bootstrap_field sform.waiting_list_enabled layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_auto layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_hours layout="control" %}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "pretixcontrol/global_settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block inner %}
|
||||
<h2>{% trans "Consistency checks" %}</h2>
|
||||
<div class="table-responsive">
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Check type" %}</th>
|
||||
<th>{% trans "Result" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{% for r in results %}
|
||||
<tr class="{% if r.result == "error" %}danger{% elif r.result == "warning" %}warning{% endif %}">
|
||||
<td>{{ r.created|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>{{ r.check_type }}</td>
|
||||
<td>{{ r.get_result_display }}</td>
|
||||
<td>
|
||||
<a href="{% url "control:global.consistency.detail" pk=r.pk %}" class="btn btn-default">{% trans "Show log" %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "pretixcontrol/global_settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block inner %}
|
||||
<h2>{% trans "Consistency check" %}</h2>
|
||||
<p>{{ result.created|date:"SHORT_DATETIME_FORMAT" }}</p>
|
||||
<p>{{ result.check_type }}</p>
|
||||
<p>{{ result.get_result_display }}</p>
|
||||
<pre>{{ result.log }}</pre>
|
||||
{% endblock %}
|
||||
@@ -1,59 +1,37 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load formset_tags %}
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" id="item_variations">
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<details class="panel panel-default" data-formset-form>
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<summary class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-xs-12">
|
||||
<strong class="panel-title">
|
||||
<span class="fa fa-fw chevron"></span>
|
||||
<span class="fa fa-warning text-danger hidden variation-error"></span>
|
||||
<span class="variation-name">
|
||||
Variation name
|
||||
</span>
|
||||
</strong>
|
||||
<span class="fa fa-warning text-warning hidden variation-warning"></span>
|
||||
{% if form.instance.id %}
|
||||
<br>
|
||||
<small class="text-muted">#{{ form.instance.id }}</small>
|
||||
{% endif %}
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% bootstrap_field form.value layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-3 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
{% if form.instance.id %}
|
||||
<br><small class="text-muted">#{{ form.instance.id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
|
||||
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Only visible with a voucher" %}"></span>
|
||||
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Require a valid membership" %}"></span>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% for k, c in sales_channels.items %}
|
||||
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
|
||||
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-1 col-xs-6 text-right flip variation-price">
|
||||
<!-- price will be inserted by JS here -->
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% if form.instance.pk and not form.instance.quotas.exists %}
|
||||
<div class="alert alert-warning">
|
||||
@@ -65,94 +43,57 @@
|
||||
{% endif %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.active layout="control" %}
|
||||
{% bootstrap_field form.value layout="control" %}
|
||||
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.description layout="control" %}
|
||||
{% bootstrap_field form.available_from layout="control" %}
|
||||
{% bootstrap_field form.available_until layout="control" %}
|
||||
{% bootstrap_field form.sales_channels layout="control" %}
|
||||
{% bootstrap_field form.hide_without_voucher layout="control" %}
|
||||
{% if form.require_membership %}
|
||||
{% bootstrap_field form.require_membership layout="control" %}
|
||||
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
||||
{% bootstrap_field form.require_membership_types layout="control" %}
|
||||
{% bootstrap_field form.require_membership_hidden layout="control" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<details class="panel panel-default" data-formset-form open>
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<summary class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-xs-12">
|
||||
<strong class="panel-title">
|
||||
<span class="fa fa-fw chevron"></span>
|
||||
<span class="fa fa-warning text-danger hidden variation-error"></span>
|
||||
<span class="variation-name">
|
||||
{% trans "New variation" %}
|
||||
</span>
|
||||
</strong>
|
||||
<span class="fa fa-warning text-warning hidden variation-warning"></span>
|
||||
{% if form.instance.id %}
|
||||
<br>
|
||||
<small class="text-muted">#{{ form.instance.id }}</small>
|
||||
{% endif %}
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% bootstrap_field formset.empty_form.value layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-3 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
|
||||
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Only visible with a voucher" %}"></span>
|
||||
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Require a valid membership" %}"></span>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% for k, c in sales_channels.items %}
|
||||
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
|
||||
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-1 col-xs-6 text-right flip variation-price">
|
||||
<!-- price will be inserted by JS here -->
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_field formset.empty_form.active layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.value layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.description layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.available_from layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.available_until layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
|
||||
{% if formset.empty_form.require_membership %}
|
||||
{% bootstrap_field formset.empty_form.require_membership layout="control" %}
|
||||
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">
|
||||
{% bootstrap_field formset.empty_form.require_membership_types layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.require_membership_hidden layout="control" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
{% bootstrap_field form.require_membership layout="control" %}
|
||||
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
||||
{% bootstrap_field form.require_membership_types layout="control" %}
|
||||
{% bootstrap_field form.require_membership_hidden layout="control" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_field form.allow_cancel layout="control" %}
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new category" %}
|
||||
</a>
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
@@ -35,16 +33,15 @@
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
<tbody>
|
||||
{% for c in categories %}
|
||||
<tr data-dnd-id="{{ c.id }}">
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong>
|
||||
</td>
|
||||
<td>
|
||||
<button formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
|
||||
<button formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
|
||||
<span class="dnd-container"></span>
|
||||
<a href="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm {% if forloop.counter0 == 0 and is_paginated and not page_obj.has_previous %}disabled{% endif %}"><i class="fa fa-arrow-up"></i></a>
|
||||
<a href="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 and is_paginated and not page_obj.has_next %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
@@ -59,7 +56,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
<a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new product" %}</a>
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
@@ -38,16 +36,15 @@
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th>{% trans "Category" %}</th>
|
||||
<th class="action-col-2"><span class="sr-only">Move</span></th>
|
||||
<th class="action-col-2"><span class="sr-only">Edit</span></th>
|
||||
<th class="action-col-2"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% regroup items by category as cat_list %}
|
||||
{% for c in cat_list %}
|
||||
<tbody data-dnd-url="{% url "control:event.items.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
{% for i in c.list %}
|
||||
{% if forloop.counter0 == 0 and i.category %}<tr class="sortable-disabled"><th colspan="8" scope="colgroup" class="text-muted">{{ i.category.name }}</th></tr>{% endif %}
|
||||
<tr data-dnd-id="{{ i.id }}" {% if not i.active %}class="row-muted"{% endif %}>
|
||||
<tr {% if not i.active %}class="row-muted"{% endif %}>
|
||||
<td><strong>
|
||||
{% if not i.active %}<strike>{% endif %}
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}">{{ i }}</a>
|
||||
@@ -108,9 +105,8 @@
|
||||
</td>
|
||||
<td>{% if i.category %}{{ i.category.name }}{% endif %}</td>
|
||||
<td>
|
||||
<button formaction="{% url "control:event.items.up" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
|
||||
<button formaction="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
|
||||
<span class="dnd-container"></span>
|
||||
<a href="{% url "control:event.items.up" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm {% if forloop.counter0 == 0 and is_paginated and not page_obj.has_previous %}disabled{% endif %}"><i class="fa fa-arrow-up"></i></a>
|
||||
<a href="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 and is_paginated and not page_obj.has_next %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
|
||||
</td>
|
||||
<td class="text-right flip col-actions">
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
@@ -119,11 +115,10 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -52,22 +52,18 @@
|
||||
{% if order.status == 'n' or order.status == 'e' %}
|
||||
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=p"
|
||||
class="btn {% if overpaid >= 0 %}btn-success{% else %}btn-default{% endif %}">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Mark as paid" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.order.extend" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-clock-o"></span>
|
||||
{% trans "Extend payment term" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if order.cancel_allowed %}
|
||||
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
|
||||
<span class="fa fa-ban"></span>
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
{% elif order.status == 'c' %}
|
||||
<a href="{% url "control:event.order.reactivate" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-reply"></span>
|
||||
{% trans "Reactivate order" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -75,17 +71,11 @@
|
||||
|
||||
<a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}"
|
||||
class="btn btn-default" target="_blank">
|
||||
<span class="fa fa-eye"></span>
|
||||
{% trans "View order as user" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.order.mail_history" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-envelope-o"></span>
|
||||
{% trans "View email history" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.order.transactions" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-list"></span>
|
||||
{% trans "View transaction history" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -244,23 +234,7 @@
|
||||
{% for i in invoices %}
|
||||
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{{ i.number }}</a>
|
||||
({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
{% if i.sent_to_customer.year == 1970 %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% elif i.sent_to_customer %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{{ i.number }}</a> ({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
{% if not i.canceled %}
|
||||
{% if request.event.settings.invoice_regenerate_allowed %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
@@ -294,12 +268,6 @@
|
||||
<br/>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if invoices_send_link %}
|
||||
<br/>
|
||||
<a class="btn btn-default btn-xs" href="{{ invoices_send_link }}">
|
||||
{% trans "Email invoices" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_generate_invoice %}
|
||||
<br/>
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<br/><span class="fa fa-fw fa-envelope-o"></span> {{ log.parsed_data.recipient }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if log.parsed_data.message.items %}
|
||||
{% if log.parsed_data.subject.items %}
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
This email has been sent with an older version of pretix. We are therefore not able to
|
||||
|
||||
@@ -18,10 +18,6 @@
|
||||
{% bootstrap_field form.sendto layout='horizontal' %}
|
||||
{% bootstrap_field form.subject layout='horizontal' %}
|
||||
{% bootstrap_field form.message layout='horizontal' %}
|
||||
{% bootstrap_field form.attach_tickets layout='horizontal' %}
|
||||
{% if form.attach_invoices %}
|
||||
{% bootstrap_field form.attach_invoices layout='horizontal' %}
|
||||
{% endif %}
|
||||
{% if request.method == "POST" %}
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail preview" %}</legend>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block title %}{% trans "Transaction history" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Transaction history" %}
|
||||
<a class="btn btn-link btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% blocktrans trimmed with order=order.code %}
|
||||
Back to order {{ order }}
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</h1>
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Product" %}</th>
|
||||
<th class="text-right flip">{% trans "Tax rate" %}</th>
|
||||
<th class="text-right flip">{% trans "Quantity" %}</th>
|
||||
<th class="text-right flip">{% trans "Single price" %}</th>
|
||||
<th class="text-right flip">{% trans "Total tax value" %}</th>
|
||||
<th class="text-right flip">{% trans "Total price" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in transactions %}
|
||||
<tr class="{% if t.count < 0 %}text-danger{% endif %}">
|
||||
<td>
|
||||
{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if t.migrated %}
|
||||
<span class="fa fa-warning text-warning"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans 'This order was created before we introduced this table, therefore this data might be inaccurate.' %}"
|
||||
></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if t.item %}
|
||||
{{ t.item }}
|
||||
{% if t.variation %}
|
||||
– {{ t.variation }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if t.fee_type %}
|
||||
{{ t.get_fee_type_display }}
|
||||
{% endif %}
|
||||
{% if t.subevent %}
|
||||
<br>{{ t.subevent }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">{{ t.tax_rate }} %</td>
|
||||
<td class="text-right flip">{{ t.count }} ×</td>
|
||||
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
|
||||
<td class="text-right flip">{{ t.full_tax_value|money:request.event.currency }}</td>
|
||||
<td class="text-right flip">{{ t.full_price|money:request.event.currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="{% if t.count < 0 %}text-danger{% endif %}">
|
||||
<td>
|
||||
<strong>{% trans "Sum" %}</strong>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="text-right flip">
|
||||
<strong>
|
||||
{{ sums.count }}
|
||||
</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="text-right flip"><strong>{{ sums.full_tax_value|money:request.event.currency }}</strong></td>
|
||||
<td class="text-right flip"><strong>{{ sums.full_price|money:request.event.currency }}</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -111,7 +111,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ m.attendee_name|default_if_none:"" }}
|
||||
{{ m.attendee_name }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="quotabox">
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
<legend>{% trans "Organizer page" %}</legend>
|
||||
{% bootstrap_field sform.organizer_logo_image layout="control" %}
|
||||
{% bootstrap_field sform.organizer_logo_image_large layout="control" %}
|
||||
{% bootstrap_field sform.organizer_logo_image_inherit layout="control" %}
|
||||
{% bootstrap_field sform.organizer_homepage_text layout="control" %}
|
||||
{% bootstrap_field sform.event_list_type layout="control" %}
|
||||
{% bootstrap_field sform.event_list_availability layout="control" %}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete carts" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Delete carts" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete any cart positions with voucher <strong>{{ voucher }}</strong>?{% endblocktrans %}</p>
|
||||
<p>{% blocktrans %}This will silently remove products from the cart of a user currently making a purchase. This can be really confusing. Only use this if you know that the session is no longer in use.{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -10,26 +10,14 @@
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This voucher already has been used. It is not recommended to modify it." %}
|
||||
<ul>
|
||||
{% for order in voucher.distinct_orders %}
|
||||
<li><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% blocktrans with code=order.code %}Order {{ code }}{% endblocktrans %}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
{% for order in voucher.distinct_orders %}
|
||||
<li><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% blocktrans with code=order.code %}Order {{ code }}{% endblocktrans %}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if redeemed_in_carts %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed with number=redeemed_in_carts %}
|
||||
This voucher is currently used in {{ number }} cart sessions and there might not be free to use until the cart sessions
|
||||
expire.
|
||||
{% endblocktrans %}
|
||||
<p class="text-right">
|
||||
<a href="{% url "control:event.voucher.deletecarts" organizer=request.event.organizer.slug event=request.event.slug voucher=voucher.id %}" class="btn btn-default"><i class="fa fa-trash"></i> {% trans "Remove cart positions" %}</a>
|
||||
</p>
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
|
||||
@@ -55,8 +55,6 @@ urlpatterns = [
|
||||
re_path(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'),
|
||||
re_path(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
|
||||
re_path(r'^global/license/$', global_settings.LicenseCheckView.as_view(), name='global.license'),
|
||||
re_path(r'^global/consistency/$', global_settings.ConsistencyCheckListView.as_view(), name='global.consistency'),
|
||||
re_path(r'^global/consistency/(?P<pk>\d+)/$', global_settings.ConsistencyCheckDetailView.as_view(), name='global.consistency.detail'),
|
||||
re_path(r'^global/message/$', global_settings.MessageView.as_view(), name='global.message'),
|
||||
re_path(r'^logdetail/$', global_settings.LogDetailView.as_view(), name='global.logdetail'),
|
||||
re_path(r'^logdetail/payment/$', global_settings.PaymentDetailView.as_view(), name='global.paymentdetail'),
|
||||
@@ -241,7 +239,6 @@ urlpatterns = [
|
||||
re_path(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
|
||||
re_path(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
|
||||
re_path(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
|
||||
re_path(r'^items/reorder$', item.reorder_items, name='event.items.reorder'),
|
||||
re_path(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),
|
||||
re_path(r'^items/typeahead/meta/$', typeahead.item_meta_values, name='event.items.meta.typeahead'),
|
||||
re_path(r'^items/select2$', typeahead.items_select2, name='event.items.select2'),
|
||||
@@ -253,7 +250,6 @@ urlpatterns = [
|
||||
re_path(r'^categories/(?P<category>\d+)/up$', item.category_move_up, name='event.items.categories.up'),
|
||||
re_path(r'^categories/(?P<category>\d+)/down$', item.category_move_down,
|
||||
name='event.items.categories.down'),
|
||||
re_path(r'^categories/reorder$', item.reorder_categories, name='event.items.categories.reorder'),
|
||||
re_path(r'^categories/(?P<category>\d+)/$', item.CategoryUpdate.as_view(),
|
||||
name='event.items.categories.edit'),
|
||||
re_path(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
|
||||
@@ -280,8 +276,6 @@ urlpatterns = [
|
||||
re_path(r'^vouchers/(?P<voucher>\d+)/$', vouchers.VoucherUpdate.as_view(), name='event.voucher'),
|
||||
re_path(r'^vouchers/(?P<voucher>\d+)/delete$', vouchers.VoucherDelete.as_view(),
|
||||
name='event.voucher.delete'),
|
||||
re_path(r'^vouchers/(?P<voucher>\d+)/deletecarts$', vouchers.VoucherDeleteCarts.as_view(),
|
||||
name='event.voucher.deletecarts'),
|
||||
re_path(r'^vouchers/add$', vouchers.VoucherCreate.as_view(), name='event.vouchers.add'),
|
||||
re_path(r'^vouchers/go$', vouchers.VoucherGo.as_view(), name='event.vouchers.go'),
|
||||
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
|
||||
@@ -347,7 +341,6 @@ urlpatterns = [
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/cancellationrequests/(?P<req>\d+)/delete$',
|
||||
orders.OrderCancellationRequestDelete.as_view(),
|
||||
name='event.order.cancellationrequests.delete'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transactions/$', orders.OrderTransactions.as_view(), name='event.order.transactions'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
|
||||
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
|
||||
name='event.invoice.download'),
|
||||
|
||||
@@ -39,9 +39,9 @@ from django.shortcuts import get_object_or_404, redirect, reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import FormView, ListView, TemplateView, DetailView
|
||||
from django.views.generic import FormView, TemplateView
|
||||
|
||||
from pretix.base.models import Check, LogEntry, OrderPayment, OrderRefund
|
||||
from pretix.base.models import LogEntry, OrderPayment, OrderRefund
|
||||
from pretix.base.services.update_check import check_result_table, update_check
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.control.forms.global_settings import (
|
||||
@@ -265,15 +265,3 @@ class LicenseCheckView(StaffMemberRequiredMixin, FormView):
|
||||
))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class ConsistencyCheckListView(AdministratorPermissionRequiredMixin, ListView):
|
||||
queryset = Check.objects.order_by('-created').defer('log')
|
||||
context_object_name = 'results'
|
||||
template_name = 'pretixcontrol/global_consistency.html'
|
||||
|
||||
|
||||
class ConsistencyCheckDetailView(AdministratorPermissionRequiredMixin, DetailView):
|
||||
queryset = Check.objects.all()
|
||||
context_object_name = 'result'
|
||||
template_name = 'pretixcontrol/global_consistency_detail.html'
|
||||
|
||||
@@ -53,7 +53,6 @@ from django.urls import resolve, reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||
from django.views.generic.edit import DeleteView
|
||||
@@ -102,8 +101,7 @@ class ItemList(ListView):
|
||||
).annotate(
|
||||
var_count=Count('variations')
|
||||
).prefetch_related("category").order_by(
|
||||
F('category__position').desc(nulls_first=True),
|
||||
'category', 'position'
|
||||
'category__position', 'category', 'position'
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@@ -140,7 +138,6 @@ def item_move(request, item, up=True):
|
||||
|
||||
|
||||
@event_permission_required("can_change_items")
|
||||
@require_http_methods(["POST"])
|
||||
def item_move_up(request, organizer, event, item):
|
||||
item_move(request, item, up=True)
|
||||
return redirect('control:event.items',
|
||||
@@ -149,7 +146,6 @@ def item_move_up(request, organizer, event, item):
|
||||
|
||||
|
||||
@event_permission_required("can_change_items")
|
||||
@require_http_methods(["POST"])
|
||||
def item_move_down(request, organizer, event, item):
|
||||
item_move(request, item, up=False)
|
||||
return redirect('control:event.items',
|
||||
@@ -157,38 +153,6 @@ def item_move_down(request, organizer, event, item):
|
||||
event=request.event.slug)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@event_permission_required("can_change_items")
|
||||
@require_http_methods(["POST"])
|
||||
def reorder_items(request, organizer, event):
|
||||
try:
|
||||
ids = json.loads(request.body.decode('utf-8'))['ids']
|
||||
except (JSONDecodeError, KeyError, ValueError):
|
||||
return HttpResponseBadRequest("expected JSON: {ids:[]}")
|
||||
|
||||
input_items = list(request.event.items.filter(id__in=[i for i in ids if i.isdigit()]))
|
||||
|
||||
if len(input_items) != len(ids):
|
||||
raise Http404(_("Some of the provided item ids are invalid."))
|
||||
|
||||
item_categories = {i.category_id for i in input_items}
|
||||
if len(item_categories) > 1:
|
||||
raise Http404(_("You cannot reorder items spanning different categories."))
|
||||
|
||||
# get first and only category
|
||||
item_category = next(iter(item_categories))
|
||||
if len(input_items) != request.event.items.filter(category=item_category).count():
|
||||
raise Http404(_("Not all items have been selected."))
|
||||
|
||||
for i in input_items:
|
||||
pos = ids.index(str(i.pk))
|
||||
if pos != i.position: # Save unneccessary UPDATE queries
|
||||
i.position = pos
|
||||
i.save(update_fields=['position'])
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
class CategoryDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
model = ItemCategory
|
||||
form_class = CategoryForm
|
||||
@@ -343,7 +307,6 @@ def category_move(request, category, up=True):
|
||||
|
||||
|
||||
@event_permission_required("can_change_items")
|
||||
@require_http_methods(["POST"])
|
||||
def category_move_up(request, organizer, event, category):
|
||||
category_move(request, category, up=True)
|
||||
return redirect('control:event.items.categories',
|
||||
@@ -352,7 +315,6 @@ def category_move_up(request, organizer, event, category):
|
||||
|
||||
|
||||
@event_permission_required("can_change_items")
|
||||
@require_http_methods(["POST"])
|
||||
def category_move_down(request, organizer, event, category):
|
||||
category_move(request, category, up=False)
|
||||
return redirect('control:event.items.categories',
|
||||
@@ -360,32 +322,6 @@ def category_move_down(request, organizer, event, category):
|
||||
event=request.event.slug)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@event_permission_required("can_change_items")
|
||||
@require_http_methods(["POST"])
|
||||
def reorder_categories(request, organizer, event):
|
||||
try:
|
||||
ids = json.loads(request.body.decode('utf-8'))['ids']
|
||||
except (JSONDecodeError, KeyError, ValueError):
|
||||
return HttpResponseBadRequest("expected JSON: {ids:[]}")
|
||||
|
||||
input_categories = list(request.event.categories.filter(id__in=[i for i in ids if i.isdigit()]))
|
||||
|
||||
if len(input_categories) != len(ids):
|
||||
raise Http404(_("Some of the provided category ids are invalid."))
|
||||
|
||||
if len(input_categories) != request.event.categories.count():
|
||||
raise Http404(_("Not all categories have been selected."))
|
||||
|
||||
for c in input_categories:
|
||||
pos = ids.index(str(c.pk))
|
||||
if pos != c.position: # Save unneccessary UPDATE queries
|
||||
c.position = pos
|
||||
c.save(update_fields=['position'])
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required'
|
||||
)
|
||||
@@ -487,21 +423,18 @@ class QuestionList(ListView):
|
||||
|
||||
@transaction.atomic
|
||||
@event_permission_required("can_change_items")
|
||||
@require_http_methods(["POST"])
|
||||
def reorder_questions(request, organizer, event):
|
||||
try:
|
||||
ids = json.loads(request.body.decode('utf-8'))['ids']
|
||||
except (JSONDecodeError, KeyError, ValueError):
|
||||
return HttpResponseBadRequest("expected JSON: {ids:[]}")
|
||||
|
||||
# filter system_questions - normal questions are int/digit, system_questions strings
|
||||
custom_question_ids = [i for i in ids if i.isdigit()]
|
||||
input_questions = list(request.event.questions.filter(id__in=custom_question_ids))
|
||||
input_questions = request.event.questions.filter(id__in=[i for i in ids if i.isdigit()])
|
||||
|
||||
if len(input_questions) != len(custom_question_ids):
|
||||
if input_questions.count() != len([i for i in ids if i.isdigit()]):
|
||||
raise Http404(_("Some of the provided question ids are invalid."))
|
||||
|
||||
if len(input_questions) != request.event.questions.count():
|
||||
if input_questions.count() != request.event.questions.count():
|
||||
raise Http404(_("Not all questions have been selected."))
|
||||
|
||||
for q in input_questions:
|
||||
@@ -1398,7 +1331,6 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
|
||||
"Your participants won't be able to buy the bundle unless you remove this "
|
||||
"item from it."))
|
||||
|
||||
ctx['sales_channels'] = get_all_sales_channels()
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -69,7 +69,7 @@ class ImportView(EventPermissionRequiredMixin, TemplateView):
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
}))
|
||||
if request.FILES['file'].size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
if request.FILES['file'].size > 1024 * 1024 * 10:
|
||||
messages.error(request, _('Please do not upload files larger than 10 MB.'))
|
||||
return redirect(reverse('control:event.orders.import', kwargs={
|
||||
'event': request.event.slug,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user