Compare commits

..

5 Commits

Author SHA1 Message Date
Raphael Michel
dba9a56a67 Bump to 2.7.1 2019-05-14 09:16:14 +02:00
Raphael Michel
15f445cb3d Fix #1279 -- Incorrect initial price value in widget in German locale 2019-05-14 09:10:12 +02:00
Raphael Michel
c9f6c71c81 Fix #1282 -- Work around issues in file backends 2019-05-14 09:10:12 +02:00
Raphael Michel
2032d36ad6 Set original_price to TaxedPrice even with variations 2019-05-14 09:10:12 +02:00
Raphael Michel
ffb4cf08d1 Assign flag for zh_Hans 2019-05-14 09:10:12 +02:00
402 changed files with 69077 additions and 101484 deletions

View File

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

View File

@@ -78,15 +78,6 @@ Example::
Enables or disables nagging staff users for leaving comments on their sessions for auditability. Enables or disables nagging staff users for leaving comments on their sessions for auditability.
Defaults to ``off``. Defaults to ``off``.
``obligatory_2fa``
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
Defaults to ``False``
``trust_x_forwarded_for``
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
Defaults to ``off``.
Locale settings Locale settings
--------------- ---------------
@@ -282,24 +273,6 @@ to speed up various operations::
If redis is not configured, pretix will store sessions and locks in the database. If memcached If redis is not configured, pretix will store sessions and locks in the database. If memcached
is configured, memcached will be used for caching instead of redis. is configured, memcached will be used for caching instead of redis.
Translations
------------
pretix comes with a number of translations. Some of them are marked as "incubating", which means
they can usually only be selected in development mode. If you want to use them nevertheless, you
can activate them like this::
[languages]
allow_incubating=pt-br,da
You can also tell pretix about additional paths where it will search for translations::
[languages]
path=/path/to/my/translations
For a given language (e.g. ``pt-br``), pretix will then look in the
specific sub-folder, e.g. ``/path/to/my/translations/pt_BR/LC_MESSAGES/django.po``.
Celery task queue Celery task queue
----------------- -----------------

View File

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

View File

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

View File

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

View File

@@ -209,7 +209,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": "VIP entry", "name": "VIP entry",
@@ -396,7 +396,6 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
@@ -506,7 +505,6 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
@@ -548,8 +546,6 @@ Order position endpoints
you do not implement question handling in your user interface, you **must** you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults set this to ``false``. In that case, questions will just be ignored. Defaults
to ``true``. to ``true``.
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
returned. Otherwise, canceled orders will return ``unpaid``.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used. :<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required :<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
questions that have not been filled. Defaults to ``false``. questions that have not been filled. Defaults to ``false``.
@@ -578,7 +574,6 @@ Order position endpoints
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA", "nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"datetime": null, "datetime": null,
"questions_supported": true, "questions_supported": true,
"canceled_supported": true,
"answers": { "answers": {
"4": "XS" "4": "XS"
} }
@@ -662,9 +657,7 @@ Order position endpoints
Possible error reasons: Possible error reasons:
* ``unpaid`` - Ticket is not paid for * ``unpaid`` - Ticket is not paid for or has been refunded
* ``canceled`` Ticket is canceled or expired. This reason is only sent when your request sets
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
* ``already_redeemed`` - Ticket already has been redeemed * ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device * ``product`` - Tickets with this product may not be scanned at this device

View File

@@ -27,13 +27,9 @@ presale_end datetime The date at whi
location multi-lingual string The event location (or ``null``) location multi-lingual string The event location (or ``null``)
has_subevents boolean ``true`` if the event series feature is active for this has_subevents boolean ``true`` if the event series feature is active for this
event. Cannot change after event is created. event. Cannot change after event is created.
meta_data object Values set for organizer-specific meta data parameters. meta_data dict Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this plugins list A list of package names of the enabled plugins for this
event. event.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -54,14 +50,6 @@ seat_category_mapping object An object mappi
The ``testmode`` attribute has been added. The ``testmode`` attribute has been added.
.. versionchanged:: 2.8
When cloning events, the ``testmode`` attribute will now be cloned, too.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints Endpoints
--------- ---------
@@ -107,8 +95,6 @@ Endpoints
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"seating_plan": null,
"seat_category_mapping": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
"pretix.plugins.stripe" "pretix.plugins.stripe"
@@ -126,9 +112,6 @@ Endpoints
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned. :query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.
Default: ``slug``.
:param organizer: The ``slug`` field of a valid organizer :param organizer: The ``slug`` field of a valid organizer
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -170,8 +153,6 @@ Endpoints
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
@@ -203,7 +184,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "Sample Conference"}, "name": {"en": "Sample Conference"},
@@ -217,8 +198,6 @@ Endpoints
"is_public": false, "is_public": false,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
@@ -249,8 +228,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
@@ -269,7 +246,7 @@ Endpoints
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public', Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
`testmode`, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions. settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
their value will be copied from the existing event. their value will be copied from the existing event.
@@ -285,7 +262,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "Sample Conference"}, "name": {"en": "Sample Conference"},
@@ -300,8 +277,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
@@ -332,8 +307,6 @@ Endpoints
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
@@ -362,7 +335,7 @@ Endpoints
PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1 PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"plugins": [ "plugins": [
@@ -395,8 +368,6 @@ Endpoints
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",

View File

@@ -23,5 +23,3 @@ Resources and endpoints
waitinglist waitinglist
carts carts
webhooks webhooks
seatingplans
billing_invoices

View File

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

View File

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

View File

@@ -152,7 +152,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},

View File

@@ -44,9 +44,6 @@ available_from datetime The first date
(or ``null``). (or ``null``).
available_until datetime The last date time at which this item can be bought available_until datetime The last date time at which this item can be bought
(or ``null``). (or ``null``).
hidden_if_available integer The internal ID of a quota object, or ``null``. If
set, this item won't be shown publicly as long as this
quota is available.
require_voucher boolean If ``true``, this item can only be bought using a require_voucher boolean If ``true``, this item can only be bought using a
voucher that is specifically assigned to this item. voucher that is specifically assigned to this item.
hide_without_voucher boolean If ``true``, this item is only shown during the voucher hide_without_voucher boolean If ``true``, this item is only shown during the voucher
@@ -75,10 +72,6 @@ generate_tickets boolean If ``false``, t
non-admission or add-on product, regardless of event non-admission or add-on product, regardless of event
settings. If this is ``null``, regular ticketing settings. If this is ``null``, regular ticketing
rules apply. rules apply.
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations. has_variations boolean Shows whether or not this item has variations.
variations list of objects A list with one object for each variation of this item. variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation, Can be empty. Only writable during creation,
@@ -149,10 +142,6 @@ bundles list of objects Definition of b
The ``bundles`` and ``require_bundling`` attributes have been added. The ``bundles`` and ``require_bundling`` attributes have been added.
.. versionchanged:: 3.0
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
Notes Notes
----- -----
@@ -210,7 +199,6 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
@@ -219,8 +207,6 @@ Endpoints
"checkin_attention": false, "checkin_attention": false,
"has_variations": false, "has_variations": false,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"require_approval": false, "require_approval": false,
"require_bundling": false, "require_bundling": false,
"variations": [ "variations": [
@@ -304,13 +290,10 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
@@ -359,7 +342,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"id": 1, "id": 1,
@@ -379,13 +362,10 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
@@ -441,15 +421,12 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": true, "has_variations": true,
"require_approval": false, "require_approval": false,
@@ -535,12 +512,9 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"allow_cancel": true, "allow_cancel": true,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,

View File

@@ -176,10 +176,6 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field ├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types) ├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
pdf_data object Data object required for ticket PDF generation. By default, pdf_data object Data object required for ticket PDF generation. By default,
this field is missing. It will be added only if you add the this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request. ``pdf_data=true`` query parameter to your request.
@@ -201,10 +197,6 @@ pdf_data object Data object req
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added. The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
.. versionchanged:: 3.0
The attribute ``seat`` has been added.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -336,7 +328,6 @@ List of all orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
@@ -479,7 +470,6 @@ Fetching individual orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
@@ -698,6 +688,8 @@ Creating orders
Creates a new order. Creates a new order.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning:: .. warning::
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend, This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend,
@@ -745,7 +737,7 @@ Creating orders
then call the ``mark_paid`` API method. then call the ``mark_paid`` API method.
* ``testmode`` (optional) Defaults to ``false`` * ``testmode`` (optional) Defaults to ``false``
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the * ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order order creation is successful. Any quotas that become free by this operation will be credited to your order
creation. creation.
* ``email`` * ``email``
* ``locale`` * ``locale``
@@ -779,7 +771,6 @@ Creating orders
* ``item`` * ``item``
* ``variation`` * ``variation``
* ``price`` * ``price``
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` * ``attendee_name`` **or** ``attendee_name_parts``
* ``attendee_email`` * ``attendee_email``
* ``secret`` (optional) * ``secret`` (optional)
@@ -1296,7 +1287,6 @@ List of all order positions
"tax_value": "0.00", "tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"checkins": [ "checkins": [
@@ -1399,7 +1389,6 @@ Fetching individual positions
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,

View File

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

View File

@@ -54,12 +54,11 @@ dependency_question integer Internal ID of
this attribute is set to the value given in this attribute is set to the value given in
``dependency_value``. This cannot be combined with ``dependency_value``. This cannot be combined with
``ask_during_checkin``. ``ask_during_checkin``.
dependency_values list of strings If ``dependency_question`` is set to a boolean dependency_value string The value ``dependency_question`` needs to be set to.
question, this should be ``["True"]`` or ``["False"]``. If ``dependency_question`` is set to a boolean
Otherwise, it should be a list of ``identifier`` values question, this should be ``"true"`` or ``"false"``.
of question options. Otherwise, it should be the ``identifier`` of a
dependency_value string An old version of ``dependency_values`` that only allows question option.
for one value. **Deprecated.**
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.12 .. versionchanged:: 1.12
@@ -76,10 +75,6 @@ dependency_value string An old version
The attribute ``hidden`` and the question type ``CC`` have been added. The attribute ``hidden`` and the question type ``CC`` have been added.
.. versionchanged:: 3.0
The attribute ``dependency_values`` has been added.
Endpoints Endpoints
--------- ---------
@@ -125,7 +120,6 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -194,7 +188,6 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -235,7 +228,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"question": {"en": "T-Shirt size"}, "question": {"en": "T-Shirt size"},
@@ -246,7 +239,7 @@ Endpoints
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_values": [], "dependency_value": null,
"options": [ "options": [
{ {
"answer": {"en": "S"} "answer": {"en": "S"}
@@ -281,7 +274,6 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -354,7 +346,6 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,

View File

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

View File

@@ -1,209 +0,0 @@
.. _`rest-seatingplans`:
Seating plans
=============
Resource description
--------------------
The seating plan resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the plan
name string Human-readable name of the plan
layout object JSON representation of the seating plan. These
representations follow a JSON schema that currently
still evolves. The version in use can be found `here`_.
===================================== ========================== =======================================================
.. versionchanged:: 3.0
This endpoint has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/
Returns a list of all seating plans within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"name": "Main plan",
"layout": { … }
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Returns information on one plan, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 2,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the seating plan to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/seatingplans/
Creates a new seating plan
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "Main plan",
"layout": { … }
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to create a seating plan for
:statuscode 201: no error
:statuscode 400: The seating plan could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Update a plan. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id`` field. **You can not change a plan while it is in use for
any events.**
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "Old plan"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Old plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the plan to modify
:statuscode 200: no error
:statuscode 400: The plan could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource **or** the plan is currently in use.
.. http:delete:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Delete a plan. You can not delete plans which are currently in use by any events.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the plan to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the plan is currently in use.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/seating/seating-plan.schema.json

View File

@@ -36,11 +36,7 @@ variation_price_overrides list of objects List of variati
the default price the default price
├ variation integer The internal variation ID ├ variation integer The internal variation ID
└ price money (string) The price or ``null`` for the default price └ price money (string) The price or ``null`` for the default price
meta_data object Values set for organizer-specific meta data parameters. meta_data dict Values set for organizer-specific meta data parameters.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.7 .. versionchanged:: 1.7
@@ -58,10 +54,6 @@ seat_category_mapping object An object mappi
The attribute ``is_public`` has been added. The attribute ``is_public`` has been added.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints Endpoints
--------- ---------
@@ -101,8 +93,6 @@ Endpoints
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null, "location": null,
"item_price_overrides": [ "item_price_overrides": [
{ {
@@ -140,7 +130,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "First Sample Conference"}, "name": {"en": "First Sample Conference"},
@@ -152,8 +142,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -184,8 +172,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -237,8 +223,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -271,7 +255,7 @@ Endpoints
PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1 PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "New Subevent Name"}, "name": {"en": "New Subevent Name"},
@@ -303,8 +287,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -389,8 +371,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,

View File

@@ -41,7 +41,6 @@ quota integer An ID of a quot
tag string A string that is used for grouping vouchers tag string A string that is used for grouping vouchers
comment string An internal comment on the voucher comment string An internal comment on the voucher
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``). subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -49,10 +48,6 @@ show_hidden_items boolean Only if set to
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attribute ``show_hidden_items`` has been added.
Endpoints Endpoints
--------- ---------

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,12 +21,10 @@ Your should install the following on your system:
* Python 3.5 or newer * Python 3.5 or newer
* ``pip`` for Python 3 (Debian package: ``python3-pip``) * ``pip`` for Python 3 (Debian package: ``python3-pip``)
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``) * ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
* On Debian/Ubuntu: ``python-venv`` for Python 3 (Debian package: ``python3-venv``)
* ``libffi`` (Debian package: ``libffi-dev``) * ``libffi`` (Debian package: ``libffi-dev``)
* ``libssl`` (Debian package: ``libssl-dev``) * ``libssl`` (Debian package: ``libssl-dev``)
* ``libxml2`` (Debian package ``libxml2-dev``) * ``libxml2`` (Debian package ``libxml2-dev``)
* ``libxslt`` (Debian package ``libxslt1-dev``) * ``libxslt`` (Debian package ``libxslt1-dev``)
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
* ``msgfmt`` (Debian package ``gettext``) * ``msgfmt`` (Debian package ``gettext``)
* ``git`` * ``git``
@@ -65,7 +63,9 @@ Then, create the local database::
python manage.py migrate python manage.py migrate
A first user with username ``admin@localhost`` and password ``admin`` will be automatically A first user with username ``admin@localhost`` and password ``admin`` will be automatically
created. created. If you want to generate more test data, run::
python make_testdata.py
If you want to see pretix in a different language than English, you have to compile our language If you want to see pretix in a different language than English, you have to compile our language
files:: files::

View File

@@ -36,13 +36,10 @@ eu
filename filename
filesystem filesystem
fontawesome fontawesome
formset
formsets
frontend frontend
frontpage frontpage
gettext gettext
gunicorn gunicorn
guid
hardcoded hardcoded
hostname hostname
idempotency idempotency
@@ -137,7 +134,6 @@ versa
versioning versioning
viewset viewset
viewsets viewsets
waitinglist
webhook webhook
webhooks webhooks
webserver webserver

View File

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

View File

@@ -143,11 +143,6 @@ You can see an example here:
</div> </div>
</noscript> </noscript>
You can filter events by meta data attributes. You can create those attributes in your order profile and set their values in both event and series date
settings. For example, if you set up a meta data property called "Promoted" that you set to "Yes" on some events, you can pass a filter like this::
<pretix-widget event="https://pretix.eu/demo/series/" style="list" filter="attr[Promoted]=Yes"></pretix-widget>
pretix Button pretix Button
------------- -------------

71
src/make_testdata.py Normal file
View File

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

View File

@@ -1 +1 @@
__version__ = "3.0.0" __version__ = "2.7.1"

View File

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

View File

@@ -3,7 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.base.models import Device, Event, User from pretix.base.models import Device, Event, User
from pretix.base.models.auth import SuperuserPermissionSet from pretix.base.models.auth import SuperuserPermissionSet
from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.helpers.security import ( from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid, SessionInvalid, SessionReauthRequired, assert_session_valid,
) )
@@ -50,6 +50,9 @@ class EventPermission(BasePermission):
return False return False
elif 'organizer' in request.resolver_match.kwargs: elif 'organizer' in request.resolver_match.kwargs:
request.organizer = Organizer.objects.filter(
slug=request.resolver_match.kwargs['organizer'],
).first()
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request): if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request):
return False return False
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):

View File

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

View File

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

View File

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

View File

@@ -118,8 +118,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'position', 'picture', 'available_from', 'available_until', 'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets')
'show_quota_left', 'hidden_if_available', 'allow_waitinglist')
read_only_fields = ('has_variations', 'picture') read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self): def get_serializer_context(self):
@@ -201,25 +200,15 @@ class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
fields = ('id', 'identifier', 'answer', 'position') fields = ('id', 'identifier', 'answer', 'position')
class LegacyDependencyValueField(serializers.CharField):
def to_representation(self, obj):
return obj[0] if obj else None
def to_internal_value(self, data):
return [data] if data else []
class QuestionSerializer(I18nAwareModelSerializer): class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False) options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True) identifier = serializers.CharField(allow_null=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta: class Meta:
model = Question model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values', 'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value',
'hidden', 'dependency_value') 'hidden')
def validate_identifier(self, value): def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance) Question._clean_identifier(self.context['event'], value, self.instance)
@@ -273,7 +262,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
def create(self, validated_data): def create(self, validated_data):
options_data = validated_data.pop('options') if 'options' in validated_data else [] options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items') items = validated_data.pop('items')
question = Question.objects.create(**validated_data) question = Question.objects.create(**validated_data)
question.items.set(items) question.items.set(items)
for opt_data in options_data: for opt_data in options_data:
@@ -285,7 +273,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Quota model = Quota
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out') fields = ('id', 'name', 'size', 'items', 'variations', 'subevent')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)

View File

@@ -1,4 +1,5 @@
import json import json
from collections import Counter
from decimal import Decimal from decimal import Decimal
from django.utils.timezone import now from django.utils.timezone import now
@@ -14,7 +15,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, OrderPosition, Question, QuestionAnswer, SubEvent,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -70,13 +71,6 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()] return [o.identifier for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta:
model = Seat
fields = ('id', 'name', 'seat_guid')
class AnswerSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -172,13 +166,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
downloads = PositionDownloadsField(source='*') downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*') pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat') 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -312,6 +305,7 @@ class OrderSerializer(I18nAwareModelSerializer):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer # Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update. # (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = ['comment', 'checkin_attention', 'email', 'locale'] update_fields = ['comment', 'checkin_attention', 'email', 'locale']
print(validated_data)
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -436,12 +430,11 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
addon_to = serializers.IntegerField(required=False, allow_null=True) addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False) secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True) attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers', 'seat') 'secret', 'addon_to', 'subevent', 'answers')
def validate_secret(self, secret): def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -597,9 +590,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]} {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
for p in data for p in data
] ]
else:
for i, p in enumerate(data):
p['positionid'] = i + 1
if any(errs): if any(errs):
raise ValidationError(errs) raise ValidationError(errs)
@@ -625,8 +615,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
ia = None ia = None
with self.context['event'].lock() as now_dt: with self.context['event'].lock() as now_dt:
free_seats = set() quotadiff = Counter()
seats_seen = set()
consume_carts = validated_data.pop('consume_carts', []) consume_carts = validated_data.pop('consume_carts', [])
delete_cps = [] delete_cps = []
quota_avail_cache = {} quota_avail_cache = {}
@@ -640,8 +630,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if quota_avail_cache[quota][1] is not None: if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1 quota_avail_cache[quota][1] += 1
if cp.expires > now_dt: if cp.expires > now_dt:
if cp.seat: quotadiff.subtract(quotas)
free_seats.add(cp.seat)
delete_cps.append(cp) delete_cps.append(cp)
errs = [{} for p in positions_data] errs = [{} for p in positions_data]
@@ -669,22 +658,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
) )
] ]
for i, pos_data in enumerate(positions_data): quotadiff.update(new_quotas)
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat)
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
if any(errs): if any(errs):
raise ValidationError({'positions': errs}) raise ValidationError({'positions': errs})

View File

@@ -1,20 +1,8 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField from pretix.base.models import Organizer
from pretix.base.models import Organizer, SeatingPlan
from pretix.base.models.seating import SeatingPlanLayoutValidator
class OrganizerSerializer(I18nAwareModelSerializer): class OrganizerSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Organizer model = Organizer
fields = ('name', 'slug') fields = ('name', 'slug')
class SeatingPlanSerializer(I18nAwareModelSerializer):
layout = CompatibleJSONField(
validators=[SeatingPlanLayoutValidator()]
)
class Meta:
model = SeatingPlan
fields = ('id', 'name', 'layout')

View File

@@ -27,7 +27,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
model = Voucher model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota', fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent', 'show_hidden_items') 'tag', 'comment', 'subevent')
read_only_fields = ('id', 'redeemed') read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer list_serializer_class = VoucherListSerializer

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import DateTimeField from rest_framework.fields import DateTimeField
@@ -25,7 +24,7 @@ from pretix.base.services.checkin import (
) )
from pretix.helpers.database import FixedOrderBy from pretix.helpers.database import FixedOrderBy
with scopes_disabled():
class CheckinListFilter(FilterSet): class CheckinListFilter(FilterSet):
class Meta: class Meta:
model = CheckinList model = CheckinList
@@ -93,7 +92,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
) )
if not clist.all_products: if not clist.all_products:
pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True)) pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True))
cqs = cqs.filter(position__item__in=clist.limit_products.values_list('id', flat=True))
ev = clist.subevent or clist.event ev = clist.subevent or clist.event
response = { response = {
@@ -148,7 +146,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
return Response(response) return Response(response)
with scopes_disabled():
class CheckinOrderPositionFilter(OrderPositionFilter): class CheckinOrderPositionFilter(OrderPositionFilter):
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
@@ -157,7 +154,7 @@ with scopes_disabled():
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter) filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid') ordering = ('attendee_name_cached', 'positionid')
ordering_fields = ( ordering_fields = (
@@ -232,7 +229,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
) )
)) ))
).select_related( ).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat' 'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
@@ -242,7 +239,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
), ),
'answers', 'answers__options', 'answers__question', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat') ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order')
if not self.checkinlist.all_products: if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True)) qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
@@ -281,7 +278,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
nonce=nonce, nonce=nonce,
datetime=dt, datetime=dt,
questions_supported=self.request.data.get('questions_supported', True), questions_supported=self.request.data.get('questions_supported', True),
canceled_supported=self.request.data.get('canceled_supported', False),
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
) )

View File

@@ -3,7 +3,6 @@ from django.db import transaction
from django.db.models import ProtectedError, Q from django.db.models import ProtectedError, Q
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, viewsets from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -19,7 +18,7 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class EventFilter(FilterSet): class EventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs') is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs') is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
@@ -73,8 +72,6 @@ class EventViewSet(viewsets.ModelViewSet):
lookup_url_kwarg = 'event' lookup_url_kwarg = 'event'
permission_classes = (EventCRUDPermission,) permission_classes = (EventCRUDPermission,)
filter_backends = (DjangoFilterBackend, filters.OrderingFilter) filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
ordering = ('slug',)
ordering_fields = ('date_from', 'slug')
filterset_class = EventFilter filterset_class = EventFilter
def get_queryset(self): def get_queryset(self):
@@ -86,7 +83,7 @@ class EventViewSet(viewsets.ModelViewSet):
) )
return qs.prefetch_related( return qs.prefetch_related(
'meta_values', 'meta_values__property', 'seat_category_mappings' 'meta_values', 'meta_values__property'
) )
def perform_update(self, serializer): def perform_update(self, serializer):
@@ -183,7 +180,6 @@ class CloneEventViewSet(viewsets.ModelViewSet):
) )
with scopes_disabled():
class SubEventFilter(FilterSet): class SubEventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs') is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs') is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
@@ -242,18 +238,12 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
event__in=self.request.user.get_events_with_any_permission() event__in=self.request.user.get_events_with_any_permission()
) )
return qs.prefetch_related( return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings' 'subeventitem_set', 'subeventitemvariation_set'
) )
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
super().perform_update(serializer) super().perform_update(serializer)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
serializer.instance.log_action( serializer.instance.log_action(
'pretix.subevent.changed', 'pretix.subevent.changed',
user=self.request.user, user=self.request.user,

View File

@@ -3,7 +3,6 @@ from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -22,7 +21,7 @@ from pretix.base.models import (
) )
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class ItemFilter(FilterSet): class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs') tax_rate = django_filters.CharFilter(method='tax_rate_qs')
@@ -66,14 +65,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx return ctx
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.item.changed', 'pretix.event.item.changed',
user=self.request.user, user=self.request.user,
@@ -320,7 +312,6 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
with scopes_disabled():
class QuestionFilter(FilterSet): class QuestionFilter(FilterSet):
class Meta: class Meta:
model = Question model = Question
@@ -420,7 +411,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
with scopes_disabled():
class QuotaFilter(FilterSet): class QuotaFilter(FilterSet):
class Meta: class Meta:
model = Quota model = Quota
@@ -462,30 +452,9 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx return ctx
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
current_subevent = serializer.instance.subevent current_subevent = serializer.instance.subevent
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
request_subevent = serializer.instance.subevent request_subevent = serializer.instance.subevent
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
if original_data['closed'] is True and serializer.instance.closed is False:
serializer.instance.log_action(
'pretix.event.quota.opened',
user=self.request.user,
auth=self.request.auth,
)
elif original_data['closed'] is False and serializer.instance.closed is True:
serializer.instance.log_action(
'pretix.event.quota.closed',
user=self.request.user,
auth=self.request.auth,
)
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.quota.changed', 'pretix.event.quota.changed',
user=self.request.user, user=self.request.user,

View File

@@ -11,7 +11,6 @@ from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import mixins, serializers, status, viewsets from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
@@ -51,7 +50,7 @@ from pretix.base.signals import (
) )
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
with scopes_disabled():
class OrderFilter(FilterSet): class OrderFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact') email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact') code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
@@ -93,8 +92,8 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to', 'seat', 'item__category', 'addon_to',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
) )
) )
) )
@@ -103,7 +102,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch( Prefetch(
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
) )
) )
) )
@@ -483,7 +482,6 @@ class OrderViewSet(viewsets.ModelViewSet):
) )
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'): if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
serializer.instance.email_known_to_work = False
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.order.contact.changed', 'pretix.event.order.contact.changed',
user=self.request.user, user=self.request.user,
@@ -532,7 +530,6 @@ class OrderViewSet(viewsets.ModelViewSet):
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
with scopes_disabled():
class OrderPositionFilter(FilterSet): class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs') has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
@@ -574,7 +571,7 @@ with scopes_disabled():
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter) filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid') ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
@@ -611,13 +608,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
) )
)) ))
).select_related( ).select_related(
'item', 'variation', 'item__category', 'addon_to', 'seat' 'item', 'variation', 'item__category', 'addon_to'
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question' 'checkins', 'answers', 'answers__options', 'answers__question'
).select_related( ).select_related(
'item', 'order', 'order__event', 'order__event__organizer', 'seat' 'item', 'order', 'order__event', 'order__event__organizer'
) )
return qs return qs
@@ -962,7 +959,6 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer.save() serializer.save()
with scopes_disabled():
class InvoiceFilter(FilterSet): class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs') refers = django_filters.CharFilter(method='refers_qs')
number = django_filters.CharFilter(method='nr_qs') number = django_filters.CharFilter(method='nr_qs')

View File

@@ -1,12 +1,8 @@
from rest_framework import filters, viewsets from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import ( from pretix.api.serializers.organizer import OrganizerSerializer
OrganizerSerializer, SeatingPlanSerializer, from pretix.base.models import Organizer
)
from pretix.base.models import Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -14,9 +10,6 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Organizer.objects.none() queryset = Organizer.objects.none()
lookup_field = 'slug' lookup_field = 'slug'
lookup_url_kwarg = 'organizer' lookup_url_kwarg = 'organizer'
filter_backends = (filters.OrderingFilter,)
ordering = ('slug',)
ordering_fields = ('name', 'slug')
def get_queryset(self): def get_queryset(self):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
@@ -34,50 +27,3 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
return Organizer.objects.filter(pk=self.request.auth.organizer_id) return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else: else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id) return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer
queryset = SeatingPlan.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
def get_queryset(self):
return self.request.organizer.seating_plans.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.seatingplan.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
def perform_update(self, serializer):
if serializer.instance.events.exists() or serializer.instance.subevents.exists():
raise PermissionDenied('This plan can not be changed while it is in use for an event.')
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.seatingplan.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
return inst
def perform_destroy(self, instance):
if instance.events.exists() or instance.subevents.exists():
raise PermissionDenied('This plan can not be deleted while it is in use for an event.')
instance.log_action(
'pretix.seatingplan.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()

View File

@@ -6,7 +6,6 @@ from django.utils.timezone import now
from django_filters.rest_framework import ( from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet, BooleanFilter, DjangoFilterBackend, FilterSet,
) )
from django_scopes import scopes_disabled
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -16,7 +15,7 @@ from rest_framework.response import Response
from pretix.api.serializers.voucher import VoucherSerializer from pretix.api.serializers.voucher import VoucherSerializer
from pretix.base.models import Voucher from pretix.base.models import Voucher
with scopes_disabled():
class VoucherFilter(FilterSet): class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active') active = BooleanFilter(method='filter_active')

View File

@@ -1,6 +1,5 @@
import django_filters import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -11,7 +10,7 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.models import WaitingListEntry from pretix.base.models import WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException from pretix.base.models.waitinglist import WaitingListException
with scopes_disabled():
class WaitingListFilter(FilterSet): class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs') has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')

View File

@@ -8,7 +8,6 @@ from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import scope, scopes_disabled
from requests import RequestException from requests import RequestException
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
@@ -204,10 +203,9 @@ def notify_webhooks(logentry_id: int):
@app.task(base=ProfiledTask, bind=True, max_retries=9) @app.task(base=ProfiledTask, bind=True, max_retries=9)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int): def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours # 9 retries with 2**(2*x) timing is roughly 72 hours
with scopes_disabled():
webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer):
logentry = LogEntry.all.get(id=logentry_id) logentry = LogEntry.all.get(id=logentry_id)
webhook = WebHook.objects.get(id=webhook_id)
types = get_all_webhook_events() types = get_all_webhook_events()
event_type = types.get(action_type) event_type = types.get(action_type)
if not event_type or not webhook.enabled: if not event_type or not webhook.enabled:

View File

@@ -8,7 +8,7 @@ from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
from pretix.base.models import Event, Order, OrderPosition from pretix.base.models import Event, Order
from pretix.base.signals import register_html_mail_renderers from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
@@ -44,8 +44,7 @@ class BaseHTMLMailRenderer:
def __str__(self): def __str__(self):
return self.identifier return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None, def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None) -> str:
position: OrderPosition=None) -> str:
""" """
This method should generate the HTML part of the email. This method should generate the HTML part of the email.
@@ -53,7 +52,6 @@ class BaseHTMLMailRenderer:
:param plain_signature: The signature with event organizer contact details in plain text. :param plain_signature: The signature with event organizer contact details in plain text.
:param subject: The email subject. :param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``. :param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``.
:return: An HTML string :return: An HTML string
""" """
raise NotImplementedError() raise NotImplementedError()
@@ -97,7 +95,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self): def template_name(self):
raise NotImplementedError() raise NotImplementedError()
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order, position: OrderPosition) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
body_md = markdown_compile_email(plain_body) body_md = markdown_compile_email(plain_body)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
@@ -118,9 +116,6 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if order: if order:
htmlctx['order'] = order htmlctx['order'] = order
if position:
htmlctx['position'] = position
tpl = get_template(self.template_name) tpl = get_template(self.template_name)
body_html = inline_css(tpl.render(htmlctx)) body_html = inline_css(tpl.render(htmlctx))
return body_html return body_html

View File

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

View File

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

View File

@@ -93,7 +93,7 @@ class DekodiNREIExporter(BaseExporter):
'PTNo15': p.full_id or '', 'PTNo15': p.full_id or '',
}) })
elif p.provider.startswith('stripe'): elif p.provider.startswith('stripe'):
src = p.info_data.get("source", p.info_data) src = p.info_data.get("source", "{}")
payments.append({ payments.append({
'PTID': '81', 'PTID': '81',
'PTN': 'Stripe', 'PTN': 'Stripe',

View File

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

View File

@@ -14,7 +14,7 @@ class LoginForm(forms.Form):
Base class for authenticating users. Extend this to get a form that accepts Base class for authenticating users. Extend this to get a form that accepts
username/password logins. username/password logins.
""" """
email = forms.EmailField(label=_("E-mail"), max_length=254, widget=forms.EmailInput(attrs={'autofocus': 'autofocus'})) email = forms.EmailField(label=_("E-mail"), max_length=254)
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False) keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)

View File

@@ -1,8 +1,6 @@
import copy import copy
import json
import logging import logging
from decimal import Decimal from decimal import Decimal
from urllib.error import HTTPError
import dateutil.parser import dateutil.parser
import pytz import pytz
@@ -11,13 +9,10 @@ import vat_moss.id
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.forms import Select
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import get_language, ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_countries import countries from django_countries.fields import CountryField
from django_countries.fields import Country, CountryField
from pretix.base.forms.widgets import ( from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
@@ -25,10 +20,9 @@ from pretix.base.forms.widgets import (
) )
from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import SplitDateTimeField from pretix.control.forms import SplitDateTimeField
from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
@@ -38,17 +32,13 @@ logger = logging.getLogger(__name__)
class NamePartsWidget(forms.MultiWidget): class NamePartsWidget(forms.MultiWidget):
widget = forms.TextInput widget = forms.TextInput
def __init__(self, scheme: dict, field: forms.Field, attrs=None, titles: list=None): def __init__(self, scheme: dict, field: forms.Field, attrs=None):
widgets = [] widgets = []
self.scheme = scheme self.scheme = scheme
self.field = field self.field = field
self.titles = titles
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
a = copy.copy(attrs) or {} a = copy.copy(attrs) or {}
a['data-fname'] = fname a['data-fname'] = fname
if fname == 'title' and self.titles:
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
else:
widgets.append(self.widget(attrs=a)) widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs) super().__init__(widgets, attrs)
@@ -108,31 +98,16 @@ class NamePartsFormField(forms.MultiValueField):
'max_length': kwargs.pop('max_length', None), 'max_length': kwargs.pop('max_length', None),
} }
self.scheme_name = kwargs.pop('scheme') self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name) self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
if self.titles:
self.scheme_titles = PERSON_NAME_TITLE_GROUPS.get(self.titles)
else:
self.scheme_titles = None
self.one_required = kwargs.get('required', True) self.one_required = kwargs.get('required', True)
require_all_fields = kwargs.pop('require_all_fields', False) require_all_fields = kwargs.pop('require_all_fields', False)
kwargs['required'] = False kwargs['required'] = False
kwargs['widget'] = (kwargs.get('widget') or self.widget)( kwargs['widget'] = (kwargs.get('widget') or self.widget)(
scheme=self.scheme, titles=self.scheme_titles, field=self, **kwargs.pop('widget_kwargs', {}) scheme=self.scheme, field=self, **kwargs.pop('widget_kwargs', {})
) )
defaults.update(**kwargs) defaults.update(**kwargs)
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
defaults['label'] = label defaults['label'] = label
if fname == 'title' and self.scheme_titles:
d = dict(defaults)
d.pop('max_length', None)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
)
field.part_name = fname
fields.append(field)
else:
field = forms.CharField(**defaults) field = forms.CharField(**defaults)
field.part_name = fname field.part_name = fname
fields.append(field) fields.append(field)
@@ -180,7 +155,6 @@ class BaseQuestionsForm(forms.Form):
max_length=255, max_length=255,
required=event.settings.attendee_names_required, required=event.settings.attendee_names_required,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Attendee name'), label=_('Attendee name'),
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts), initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
) )
@@ -302,7 +276,7 @@ class BaseQuestionsForm(forms.Form):
if q.dependency_question_id: if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values)) field.widget.attrs['data-question-dependency-value'] = q.dependency_value
if q.type != 'M': if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional field._required = q.required and not self.all_optional
@@ -323,24 +297,26 @@ class BaseQuestionsForm(forms.Form):
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)} question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
def question_is_visible(parentid, qvals): def question_is_visible(parentid, qval):
parentq = question_cache[parentid] parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values): if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value):
return False return False
if 'question_%d' % parentid not in d: if 'question_%d' % parentid not in d:
return False return False
dval = d.get('question_%d' % parentid) dval = d.get('question_%d' % parentid)
return ( if qval == 'True':
('True' in qvals and dval) return dval
or ('False' in qvals and not dval) elif qval == 'False':
or (isinstance(dval, QuestionOption) and dval.identifier in qvals) return not dval
or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals)) elif isinstance(dval, QuestionOption):
) return dval.identifier == qval
else:
return qval in [o.identifier for o in dval]
def question_is_required(q): def question_is_required(q):
return ( return (
q.required and q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values)) (not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value))
) )
if not self.all_optional: if not self.all_optional:
@@ -375,27 +351,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.request = kwargs.pop('request', None) self.request = kwargs.pop('request', None)
self.validate_vat_id = kwargs.pop('validate_vat_id') self.validate_vat_id = kwargs.pop('validate_vat_id')
self.all_optional = kwargs.pop('all_optional', False) self.all_optional = kwargs.pop('all_optional', False)
kwargs.setdefault('initial', {})
if not kwargs.get('instance') or not kwargs['instance'].country:
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language()
country = event.settings.invoice_address_from_country
if not country:
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale in valid_countries:
country = Country(locale.upper())
kwargs['initial']['country'] = country
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid: if not event.settings.invoice_address_vatid:
del self.fields['vat_id'] del self.fields['vat_id']
@@ -421,7 +376,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
max_length=255, max_length=255,
required=event.settings.invoice_name_required and not self.all_optional, required=event.settings.invoice_name_required and not self.all_optional,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Name'), label=_('Name'),
initial=(self.instance.name_parts if self.instance else self.instance.name_parts), initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
) )
@@ -448,12 +402,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.name_parts = data.get('name_parts') self.instance.name_parts = data.get('name_parts')
if all(
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
) and len(data.get('name_parts', {})) == 1:
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'): elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
@@ -475,7 +423,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'your country is currently not available. We will therefore ' 'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount ' 'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.')) 'back via the VAT reimbursement process.'))
except (vat_moss.errors.WebServiceError, HTTPError): except vat_moss.errors.WebServiceError:
logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
if self.request and self.vat_warning: if self.request and self.vat_warning:

View File

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

View File

@@ -2,7 +2,7 @@
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate" 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 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 "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. users from doing that by going really dirty and fitlering it from the output.
""" """
import sys import sys

View File

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

View File

@@ -1,39 +0,0 @@
import sys
from django.apps import apps
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django_scopes import scope, scopes_disabled
class Command(BaseCommand):
def create_parser(self, *args, **kwargs):
parser = super().create_parser(*args, **kwargs)
parser.parse_args = lambda x: parser.parse_known_args(x)[0]
return parser
def handle(self, *args, **options):
parser = self.create_parser(sys.argv[0], sys.argv[1])
flags = parser.parse_known_args(sys.argv[2:])[1]
if "--override" in flags:
with scopes_disabled():
return call_command("shell_plus", *args, **options)
lookups = {}
for flag in flags:
lookup, value = flag.lstrip("-").split("=")
lookup = lookup.split("__", maxsplit=1)
lookups[lookup[0]] = {
lookup[1] if len(lookup) > 1 else "pk": value
}
models = {
model_name.split(".")[-1]: model_class
for app_name, app_content in apps.all_models.items()
for (model_name, model_class) in app_content.items()
}
scope_options = {
app_name: models[app_name].objects.get(**app_value)
for app_name, app_value in lookups.items()
}
with scope(**scope_options):
return call_command("shell_plus", *args, **options)

View File

@@ -1,18 +0,0 @@
# Generated by Django 2.2.1 on 2019-05-15 05:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0120_auto_20190509_0736'),
]
operations = [
migrations.AddField(
model_name='order',
name='email_known_to_work',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 2.2.1 on 2019-05-15 13:23
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0121_order_email_known_to_work'),
]
operations = [
migrations.AddField(
model_name='orderposition',
name='web_secret',
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_secret, max_length=32),
),
]

View File

@@ -1,70 +0,0 @@
# Generated by Django 2.2.1 on 2019-05-30 10:35
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0122_orderposition_web_secret'),
]
operations = [
migrations.CreateModel(
name='SeatingPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=190)),
('layout', models.TextField()),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seating_plans', to='pretixbase.Organizer')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='SeatCategoryMapping',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('layout_category', models.CharField(max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Event')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Item')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.SubEvent')),
],
),
migrations.CreateModel(
name='Seat',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=190)),
('blocked', models.BooleanField(default=False)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Event')),
('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Item')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.SubEvent')),
],
),
migrations.AddField(
model_name='cartposition',
name='seat',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
),
migrations.AddField(
model_name='event',
name='seating_plan',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.SeatingPlan'),
),
migrations.AddField(
model_name='orderposition',
name='seat',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
),
migrations.AddField(
model_name='subevent',
name='seating_plan',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.SeatingPlan'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 2.2.1 on 2019-05-30 11:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0123_auto_20190530_1035'),
]
operations = [
migrations.AddField(
model_name='seat',
name='seat_guid',
field=models.CharField(db_index=True, default=None, max_length=190),
preserve_default=False,
),
]

View File

@@ -1,26 +0,0 @@
# Generated by Django 2.2.1 on 2019-07-07 10:10
from django.db import migrations, models
def set_show_hidden_items(apps, schema_editor):
Voucher = apps.get_model('pretixbase', 'Voucher')
Voucher.objects.filter(quota__isnull=False).update(show_hidden_items=False)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0124_seat_seat_guid'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='show_hidden_items',
field=models.BooleanField(default=True),
),
migrations.RunPython(
set_show_hidden_items,
migrations.RunPython.noop,
)
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 2.2.1 on 2019-07-10 13:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0125_voucher_show_hidden_items'),
]
operations = [
migrations.AddField(
model_name='item',
name='show_quota_left',
field=models.NullBooleanField(),
),
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 2.2.1 on 2019-07-11 07:05
from django.db import migrations
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0126_item_show_quota_left'),
]
operations = [
migrations.RenameField(
model_name='question',
old_name='dependency_value',
new_name='dependency_values',
),
migrations.AlterField(
model_name='question',
name='dependency_values',
field=pretix.base.models.fields.MultiStringField(default=['']),
),
]

View File

@@ -1,26 +0,0 @@
# Generated by Django 2.2.1 on 2019-07-15 15:10
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0127_auto_20190711_0705'),
]
operations = [
migrations.AddField(
model_name='quota',
name='close_when_sold_out',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='quota',
name='closed',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 2.2.1 on 2019-07-24 15:48
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0128_auto_20190715_1510'),
]
operations = [
migrations.AddField(
model_name='item',
name='hidden_if_available',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='pretixbase.Quota'),
),
]

View File

@@ -1,31 +0,0 @@
# Generated by Django 2.2.1 on 2019-07-29 13:11
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0129_auto_20190724_1548'),
]
operations = [
migrations.AddField(
model_name='seat',
name='row_name',
field=models.CharField(default='', max_length=190),
),
migrations.AddField(
model_name='seat',
name='seat_number',
field=models.CharField(default='', max_length=190),
),
migrations.AddField(
model_name='seat',
name='zone_name',
field=models.CharField(default='', max_length=190),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 2.2.1 on 2019-07-29 14:22
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0130_auto_20190729_1311'),
]
operations = [
migrations.AddField(
model_name='item',
name='allow_waitinglist',
field=models.BooleanField(default=True),
),
]

View File

@@ -24,7 +24,6 @@ from .orders import (
from .organizer import ( from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
) )
from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule from .tax import TaxRule
from .vouchers import Voucher from .vouchers import Voucher
from .waitinglist import WaitingListEntry from .waitinglist import WaitingListEntry

View File

@@ -12,7 +12,6 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
from django_scopes import scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri from pretix.helpers.urls import build_absolute_uri
@@ -284,7 +283,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True return True
return False return False
@scopes_disabled()
def get_events_with_any_permission(self, request=None): def get_events_with_any_permission(self, request=None):
""" """
Returns a queryset of events the user has any permissions to. Returns a queryset of events the user has any permissions to.
@@ -302,7 +300,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
| Q(id__in=self.teams.values_list('limit_events__id', flat=True)) | Q(id__in=self.teams.values_list('limit_events__id', flat=True))
) )
@scopes_disabled()
def get_events_with_permission(self, permission, request=None): def get_events_with_permission(self, permission, request=None):
""" """
Returns a queryset of events the user has a specific permissions to. Returns a queryset of events the user has a specific permissions to.

View File

@@ -6,9 +6,7 @@ from django.db import models
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
from django.db.models.signals import post_delete from django.db.models.signals import post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from pretix.helpers.json import CustomJSONEncoder from pretix.helpers.json import CustomJSONEncoder
@@ -86,7 +84,7 @@ class LoggingMixin:
if (sensitivekey in k) and v: if (sensitivekey in k) and v:
data[k] = "********" data[k] = "********"
logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True) logentry.data = json.dumps(data, cls=CustomJSONEncoder)
elif data: elif data:
raise TypeError("You should only supply dictionaries as log data.") raise TypeError("You should only supply dictionaries as log data.")
if save: if save:
@@ -115,40 +113,6 @@ class LoggedModel(models.Model, LoggingMixin):
class Meta: class Meta:
abstract = True abstract = True
@cached_property
def logs_content_type(self):
return ContentType.objects.get_for_model(type(self))
@cached_property
def all_logentries_link(self):
from pretix.base.models import Event
if isinstance(self, Event):
event = self
elif hasattr(self, 'event'):
event = self.event
else:
return None
return reverse(
'control:event.log',
kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}
) + '?content_type={}&object={}'.format(
self.logs_content_type.pk,
self.pk
)
def top_logentries(self):
qs = self.all_logentries()
if self.all_logentries_link:
qs = qs[:25]
return qs
def top_logentries_has_more(self):
return self.all_logentries().count() > 25
def all_logentries(self): def all_logentries(self):
""" """
Returns all log entries that are attached to this object. Returns all log entries that are attached to this object.
@@ -158,7 +122,7 @@ class LoggedModel(models.Model, LoggingMixin):
from .log import LogEntry from .log import LogEntry
return LogEntry.objects.filter( return LogEntry.objects.filter(
content_type=self.logs_content_type, object_id=self.pk content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
).select_related('user', 'event', 'oauth_application', 'api_token', 'device') ).select_related('user', 'event', 'oauth_application', 'api_token', 'device')

View File

@@ -3,7 +3,6 @@ from django.db.models import Case, Count, F, OuterRef, Q, Subquery, When
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
@@ -21,8 +20,6 @@ class CheckinList(LoggedModel):
'order have not been paid. This only works with pretixdesk ' 'order have not been paid. This only works with pretixdesk '
'0.3.0 or newer or pretixdroid 1.9 or newer.')) '0.3.0 or newer or pretixdroid 1.9 or newer.'))
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
ordering = ('subevent__date_from', 'name') ordering = ('subevent__date_from', 'name')
@@ -170,8 +167,6 @@ class Checkin(models.Model):
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT, 'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
) )
objects = ScopedManager(organizer='position__order__event__organizer')
class Meta: class Meta:
unique_together = (('list', 'position'),) unique_together = (('list', 'position'),)

View File

@@ -4,12 +4,10 @@ from django.db import models
from django.db.models import Max from django.db.models import Max
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
@scopes_disabled()
def generate_serial(): def generate_serial():
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16) serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
while Device.objects.filter(unique_serial=serial).exists(): while Device.objects.filter(unique_serial=serial).exists():
@@ -17,7 +15,6 @@ def generate_serial():
return serial return serial
@scopes_disabled()
def generate_initialization_token(): def generate_initialization_token():
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits) token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(initialization_token=token).exists(): while Device.objects.filter(initialization_token=token).exists():
@@ -25,7 +22,6 @@ def generate_initialization_token():
return token return token
@scopes_disabled()
def generate_api_token(): def generate_api_token():
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(api_token=token).exists(): while Device.objects.filter(api_token=token).exists():
@@ -75,8 +71,6 @@ class Device(LoggedModel):
null=True, blank=True null=True, blank=True
) )
objects = ScopedManager(organizer='organizer')
class Meta: class Meta:
unique_together = (('organizer', 'device_id'),) unique_together = (('organizer', 'device_id'),)

View File

@@ -17,7 +17,6 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
@@ -100,14 +99,14 @@ class EventMixin:
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
) )
def get_date_range_display(self, tz=None, force_show_end=False) -> str: def get_date_range_display(self, tz=None) -> str:
""" """
Returns a formatted string containing the start date and the end date 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_times`` and of the event with respect to the current locale and to the ``show_times`` and
``show_date_to`` settings. ``show_date_to`` settings.
""" """
tz = tz or self.timezone tz = tz or self.timezone
if (not self.settings.show_date_to and not force_show_end) or not self.date_to: if not self.settings.show_date_to or not self.date_to:
return _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)) return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
@@ -336,10 +335,6 @@ class Event(EventMixin, LoggedModel):
verbose_name=_('Event series'), verbose_name=_('Event series'),
default=False default=False
) )
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
objects = ScopedManager(organizer='organizer')
class Meta: class Meta:
verbose_name = _("Event") verbose_name = _("Event")
@@ -350,26 +345,6 @@ class Event(EventMixin, LoggedModel):
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@property @property
def presale_has_ended(self): def presale_has_ended(self):
if self.has_subevents: if self.has_subevents:
@@ -470,7 +445,6 @@ class Event(EventMixin, LoggedModel):
self.plugins = other.plugins self.plugins = other.plugins
self.is_public = other.is_public self.is_public = other.is_public
self.testmode = other.testmode
self.save() self.save()
tax_map = {} tax_map = {}
@@ -516,21 +490,14 @@ class Event(EventMixin, LoggedModel):
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
items = list(q.items.all()) items = list(q.items.all())
vars = list(q.variations.all()) vars = list(q.variations.all())
oldid = q.pk
q.pk = None q.pk = None
q.event = self q.event = self
q.cached_availability_state = None
q.cached_availability_number = None
q.cached_availability_paid_orders = None
q.cached_availability_time = None
q.closed = False
q.save() q.save()
for i in items: for i in items:
if i.pk in item_map: if i.pk in item_map:
q.items.add(item_map[i.pk]) q.items.add(item_map[i.pk])
for v in vars: for v in vars:
q.variations.add(variation_map[v.pk]) q.variations.add(variation_map[v.pk])
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
question_map = {} question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
@@ -560,24 +527,6 @@ class Event(EventMixin, LoggedModel):
for i in items: for i in items:
cl.limit_products.add(item_map[i.pk]) cl.limit_products.add(item_map[i.pk])
if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id:
self.seating_plan = other.seating_plan
else:
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
self.save()
for m in other.seat_category_mappings.filter(subevent__isnull=True):
m.pk = None
m.event = self
m.product = item_map[m.product_id]
m.save()
for s in other.seats.filter(subevent__isnull=True):
s.pk = None
s.event = self
s.save()
for s in other.settings._objects.all(): for s in other.settings._objects.all():
s.object = self s.object = self
s.pk = None s.pk = None
@@ -717,12 +666,8 @@ class Event(EventMixin, LoggedModel):
@property @property
def meta_data(self): def meta_data(self):
data = {p.name: p.default for p in self.organizer.meta_properties.all()} data = {p.name: p.default for p in self.organizer.meta_properties.all()}
if hasattr(self, 'meta_values_cached'):
data.update({v.property.name: v.value for v in self.meta_values_cached})
else:
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()}) data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return data
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
@property @property
def has_payment_provider(self): def has_payment_provider(self):
@@ -925,14 +870,10 @@ class SubEvent(EventMixin, LoggedModel):
null=True, blank=True, null=True, blank=True,
verbose_name=_("Frontpage text") verbose_name=_("Frontpage text")
) )
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents')
items = models.ManyToManyField('Item', through='SubEventItem') items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation') variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Date in event series") verbose_name = _("Date in event series")
verbose_name_plural = _("Dates in event series") verbose_name_plural = _("Dates in event series")
@@ -941,28 +882,6 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self): def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display()) return '{} - {}'.format(self.name, self.get_date_range_display())
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@cached_property @cached_property
def settings(self): def settings(self):
return self.event.settings return self.event.settings
@@ -1021,7 +940,6 @@ class SubEvent(EventMixin, LoggedModel):
raise ValidationError(_('One or more variations do not belong to this event.')) raise ValidationError(_('One or more variations do not belong to this event.'))
@scopes_disabled()
def generate_invite_token(): def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)

View File

@@ -9,7 +9,6 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import pgettext from django.utils.translation import pgettext
from django_countries.fields import CountryField from django_countries.fields import CountryField
from django_scopes import ScopedManager
def invoice_filename(instance, filename: str) -> str: def invoice_filename(instance, filename: str) -> str:
@@ -108,8 +107,6 @@ class Invoice(models.Model):
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255) file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
internal_reference = models.TextField(blank=True) internal_reference = models.TextField(blank=True)
objects = ScopedManager(organizer='event__organizer')
@staticmethod @staticmethod
def _to_numeric_invoice_number(number): def _to_numeric_invoice_number(number):
return '{:05d}'.format(int(number)) return '{:05d}'.format(int(number))
@@ -175,8 +172,6 @@ class Invoice(models.Model):
self.organizer = self.order.event.organizer self.organizer = self.order.event.organizer
if not self.prefix: if not self.prefix:
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-') self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
if self.is_cancellation:
self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
if not self.invoice_no: if not self.invoice_no:
if self.order.testmode: if self.order.testmode:
self.prefix += 'TEST-' self.prefix += 'TEST-'

View File

@@ -17,14 +17,11 @@ from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields from pretix.base.models import fields
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice from pretix.base.models.tax import TaxedPrice
from pretix.base.signals import quota_availability
from .event import Event, SubEvent from .event import Event, SubEvent
@@ -158,7 +155,8 @@ class SubEventItemVariation(models.Model):
self.subevent.event.cache.clear() self.subevent.event.cache.clear()
def filter_available(qs, channel='web', voucher=None, allow_addons=False): class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False):
q = ( q = (
# IMPORTANT: If this is updated, also update the ItemVariation query # IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated() # in models/event.py: EventMixin.annotated()
@@ -169,32 +167,16 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
) )
if not allow_addons: if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
qs = self.filter(q)
vouchq = Q(hide_without_voucher=False)
if voucher: if voucher:
if voucher.item_id: if voucher.item_id:
q &= Q(pk=voucher.item_id) vouchq |= Q(pk=voucher.item_id)
qs = qs.filter(pk=voucher.item_id)
elif voucher.quota_id: elif voucher.quota_id:
q &= Q(quotas__in=[voucher.quota_id]) qs = qs.filter(quotas__in=[voucher.quota_id])
else: return qs.filter(vouchq)
return qs.none()
if not voucher or not voucher.show_hidden_items:
q &= Q(hide_without_voucher=False)
return qs.filter(q)
class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False):
return filter_available(self, channel, voucher, allow_addons)
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
def __init__(self):
super().__init__()
self._queryset_class = ItemQuerySet
def filter_available(self, channel='web', voucher=None, allow_addons=False):
return filter_available(self.get_queryset(), channel, voucher, allow_addons)
class Item(LoggedModel): class Item(LoggedModel):
@@ -244,7 +226,7 @@ class Item(LoggedModel):
:type sales_channels: bool :type sales_channels: bool
""" """
objects = ItemQuerySetManager() objects = ItemQuerySet.as_manager()
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -311,16 +293,6 @@ class Item(LoggedModel):
verbose_name=_("Generate tickets"), verbose_name=_("Generate tickets"),
blank=True, null=True, blank=True, null=True,
) )
allow_waitinglist = models.BooleanField(
verbose_name=_("Show a waiting list for this ticket"),
help_text=_("This will only work of waiting lists are enabled for this event."),
default=True
)
show_quota_left = models.NullBooleanField(
verbose_name=_("Show number of tickets left"),
help_text=_("Publicly show how many tickets are still available."),
blank=True, null=True,
)
position = models.IntegerField( position = models.IntegerField(
default=0 default=0
) )
@@ -339,17 +311,6 @@ class Item(LoggedModel):
null=True, blank=True, null=True, blank=True,
help_text=_('This product will not be sold after the given date.') help_text=_('This product will not be sold after the given date.')
) )
hidden_if_available = models.ForeignKey(
'Quota',
null=True, blank=True,
on_delete=models.SET_NULL,
verbose_name=_("Only show after sellout of"),
help_text=_("If you select a quota here, this product will only be shown when that quota is "
"unavailable. If combined with the option to hide sold-out products, this allows you to "
"swap out products for more expensive ones once they are sold out. There might be a short period "
"in which both products are visible while all tickets in the referenced quota are reserved, "
"but not yet sold.")
)
require_voucher = models.BooleanField( require_voucher = models.BooleanField(
verbose_name=_('This product can only be bought using a voucher.'), verbose_name=_('This product can only be bought using a voucher.'),
default=False, default=False,
@@ -367,7 +328,7 @@ class Item(LoggedModel):
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'), verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
default=False, default=False,
help_text=_('This product will be hidden from the event page until the user enters a voucher ' help_text=_('This product will be hidden from the event page until the user enters a voucher '
'that unlocks this product.') 'code that is specifically tied to this product (and not via a quota).')
) )
require_bundling = models.BooleanField( require_bundling = models.BooleanField(
verbose_name=_('Only sell this product as part of a bundle'), verbose_name=_('Only sell this product as part of a bundle'),
@@ -430,17 +391,10 @@ class Item(LoggedModel):
self.event.cache.clear() self.event.cache.clear()
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.vouchers.update(item=None, variation=None, quota=None)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.event: if self.event:
self.event.cache.clear() self.event.cache.clear()
@property
def do_show_quota_left(self):
if self.show_quota_left is None:
return self.event.settings.show_quota_left
return self.show_quota_left
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False): def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
price = price if price is not None else self.default_price price = price if price is not None else self.default_price
@@ -493,7 +447,7 @@ class Item(LoggedModel):
return check_quotas return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None, def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False, fail_on_no_quotas=False): include_bundled=False, trust_parameters=False):
""" """
This method is used to determine whether this Item is currently available This method is used to determine whether this Item is currently available
for sale. for sale.
@@ -541,8 +495,6 @@ class Item(LoggedModel):
res = (code_avail, num_avail) res = (code_avail, num_avail)
if len(quotacounter) == 0: if len(quotacounter) == 0:
if fail_on_no_quotas:
return Quota.AVAILABILITY_GONE, 0
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res return res
@@ -639,8 +591,6 @@ class ItemVariation(models.Model):
'discounted one. This is just a cosmetic setting and will not actually impact pricing.') 'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
) )
objects = ScopedManager(organizer='item__event__organizer')
class Meta: class Meta:
verbose_name = _("Product variation") verbose_name = _("Product variation")
verbose_name_plural = _("Product variations") verbose_name_plural = _("Product variations")
@@ -677,7 +627,6 @@ class ItemVariation(models.Model):
return t return t
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.vouchers.update(item=None, variation=None, quota=None)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.item: if self.item:
self.item.event.cache.clear() self.item.event.cache.clear()
@@ -698,7 +647,7 @@ class ItemVariation(models.Model):
return check_quotas return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None, def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False, fail_on_no_quotas=False) -> Tuple[int, int]: include_bundled=False, trust_parameters=False) -> Tuple[int, int]:
""" """
This method is used to determine whether this ItemVariation is currently This method is used to determine whether this ItemVariation is currently
available for sale in terms of quotas. available for sale in terms of quotas.
@@ -740,8 +689,6 @@ class ItemVariation(models.Model):
if code_avail < res[0] or res[1] is None or num_avail < res[1]: if code_avail < res[0] or res[1] is None or num_avail < res[1]:
res = (code_avail, num_avail) res = (code_avail, num_avail)
if len(quotacounter) == 0: if len(quotacounter) == 0:
if fail_on_no_quotas:
return Quota.AVAILABILITY_GONE, 0
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res return res
@@ -955,8 +902,8 @@ class Question(LoggedModel):
:type identifier: str :type identifier: str
:param dependency_question: This question will only show up if the referenced question is set to `dependency_value`. :param dependency_question: This question will only show up if the referenced question is set to `dependency_value`.
:type dependency_question: Question :type dependency_question: Question
:param dependency_values: The values that `dependency_question` needs to be set to for this question to be applicable. :param dependency_value: The value that `dependency_question` needs to be set to for this question to be applicable.
:type dependency_values: list[str] :type dependency_value: str
""" """
TYPE_NUMBER = "N" TYPE_NUMBER = "N"
TYPE_STRING = "S" TYPE_STRING = "S"
@@ -1036,9 +983,7 @@ class Question(LoggedModel):
dependency_question = models.ForeignKey( dependency_question = models.ForeignKey(
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions' 'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
) )
dependency_values = MultiStringField(default=[]) dependency_value = models.TextField(null=True, blank=True)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Question") verbose_name = _("Question")
@@ -1289,17 +1234,6 @@ class Quota(LoggedModel):
cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True) cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True)
cached_availability_time = models.DateTimeField(null=True, blank=True) cached_availability_time = models.DateTimeField(null=True, blank=True)
close_when_sold_out = models.BooleanField(
verbose_name=_('Close this quota permanently once it is sold out'),
help_text=_('If you enable this, when the quota is sold out once, no more tickets will be sold, '
'even if tickets become available again through cancellations or expiring orders. Of course, '
'you can always re-open it manually.'),
default=False
)
closed = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Quota") verbose_name = _("Quota")
verbose_name_plural = _("Quotas") verbose_name_plural = _("Quotas")
@@ -1309,7 +1243,6 @@ class Quota(LoggedModel):
return self.name return self.name
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.vouchers.update(item=None, variation=None, quota=None)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.event: if self.event:
self.event.cache.clear() self.event.cache.clear()
@@ -1359,14 +1292,6 @@ class Quota(LoggedModel):
return _cache[self.pk] return _cache[self.pk]
now_dt = now_dt or now() now_dt = now_dt or now()
res = self._availability(now_dt, count_waitinglist) res = self._availability(now_dt, count_waitinglist)
for recv, resp in quota_availability.send(sender=self.event, quota=self, result=res,
count_waitinglist=count_waitinglist):
res = resp
if res[0] <= Quota.AVAILABILITY_ORDERED and self.close_when_sold_out and not self.closed:
self.closed = True
self.save(update_fields=['closed'])
self.log_action('pretix.event.quota.closed')
self.event.cache.delete('item_quota_cache') self.event.cache.delete('item_quota_cache')
rewrite_cache = count_waitinglist and ( rewrite_cache = count_waitinglist and (
@@ -1392,11 +1317,8 @@ class Quota(LoggedModel):
_cache['_count_waitinglist'] = count_waitinglist _cache['_count_waitinglist'] = count_waitinglist
return res return res
def _availability(self, now_dt: datetime=None, count_waitinglist=True, ignore_closed=False): def _availability(self, now_dt: datetime=None, count_waitinglist=True):
now_dt = now_dt or now() now_dt = now_dt or now()
if self.closed and not ignore_closed:
return Quota.AVAILABILITY_ORDERED, 0
size_left = self.size size_left = self.size
if size_left is None: if size_left is None:
return Quota.AVAILABILITY_OK, None return Quota.AVAILABILITY_OK, None

View File

@@ -1,5 +1,4 @@
import copy import copy
import hashlib
import json import json
import logging import logging
import os import os
@@ -15,7 +14,7 @@ from django.db import models, transaction
from django.db.models import ( from django.db.models import (
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When, Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
) )
from django.db.models.functions import Coalesce, Greatest from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete from django.db.models.signals import post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@@ -26,7 +25,6 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField from jsonfallback.fields import FallbackJSONField
@@ -182,12 +180,6 @@ class Order(LockModel, LoggedModel):
default=False default=False
) )
sales_channel = models.CharField(max_length=190, default="web") sales_channel = models.CharField(max_length=190, default="web")
email_known_to_work = models.BooleanField(
default=False,
verbose_name=_('E-mail address verified')
)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Order") verbose_name = _("Order")
@@ -198,8 +190,6 @@ class Order(LockModel, LoggedModel):
return self.full_code return self.full_code
def gracefully_delete(self, user=None, auth=None): def gracefully_delete(self, user=None, auth=None):
from . import Voucher
if not self.testmode: if not self.testmode:
raise TypeError("Only test mode orders can be deleted.") raise TypeError("Only test mode orders can be deleted.")
self.event.log_action( self.event.log_action(
@@ -208,12 +198,6 @@ class Order(LockModel, LoggedModel):
'code': self.code, 'code': self.code,
} }
) )
if self.status != Order.STATUS_CANCELED:
for position in self.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete() OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete()
@@ -222,9 +206,6 @@ class Order(LockModel, LoggedModel):
self.event.cache.delete('complain_testmode_orders') self.event.cache.delete('complain_testmode_orders')
self.delete() self.delete()
def email_confirm_hash(self):
return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
@property @property
def fees(self): def fees(self):
""" """
@@ -234,7 +215,6 @@ class Order(LockModel, LoggedModel):
return self.all_fees(manager='objects') return self.all_fees(manager='objects')
@cached_property @cached_property
@scopes_disabled()
def count_positions(self): def count_positions(self):
if hasattr(self, 'pcnt'): if hasattr(self, 'pcnt'):
return self.pcnt or 0 return self.pcnt or 0
@@ -258,7 +238,6 @@ class Order(LockModel, LoggedModel):
return None return None
@property @property
@scopes_disabled()
def payment_refund_sum(self): def payment_refund_sum(self):
payment_sum = self.payments.filter( payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
@@ -270,7 +249,6 @@ class Order(LockModel, LoggedModel):
return payment_sum - refund_sum return payment_sum - refund_sum
@property @property
@scopes_disabled()
def pending_sum(self): def pending_sum(self):
total = self.total total = self.total
if self.status == Order.STATUS_CANCELED: if self.status == Order.STATUS_CANCELED:
@@ -445,7 +423,6 @@ class Order(LockModel, LoggedModel):
return round_decimal(fee, self.event.currency) return round_decimal(fee, self.event.currency)
@property @property
@scopes_disabled()
def user_cancel_allowed(self) -> bool: def user_cancel_allowed(self) -> bool:
""" """
Returns whether or not this order can be canceled by the user. Returns whether or not this order can be canceled by the user.
@@ -630,7 +607,7 @@ class Order(LockModel, LoggedModel):
), tz) ), tz)
return term_last return term_last
def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]: def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
error_messages = { error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " 'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."), "payment settings is over."),
@@ -638,7 +615,6 @@ class Order(LockModel, LoggedModel):
"payments should be accepted in the payment settings."), "payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.') 'require_approval': _('This order is not yet approved by the event organizer.')
} }
if not force:
if self.require_approval: if self.require_approval:
return error_messages['require_approval'] return error_messages['require_approval']
term_last = self.payment_term_last term_last = self.payment_term_last
@@ -648,27 +624,20 @@ class Order(LockModel, LoggedModel):
if self.status == self.STATUS_PENDING: if self.status == self.STATUS_PENDING:
return True return True
if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force: if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
return error_messages['late'] return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist, force=force) return self._is_still_available(count_waitinglist=count_waitinglist)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]: def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]:
error_messages = { error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'), 'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
} }
now_dt = now_dt or now() now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation', 'seat') positions = self.positions.all().select_related('item', 'variation')
quota_cache = {} quota_cache = {}
try: try:
for i, op in enumerate(positions): for i, op in enumerate(positions):
if op.seat:
if not op.seat.is_available(ignore_orderpos=op):
raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat))
if force:
continue
quotas = list(op.quotas) quotas = list(op.quotas)
if len(quotas) == 0: if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format( raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -696,7 +665,7 @@ class Order(LockModel, LoggedModel):
def send_mail(self, subject: str, template: Union[str, LazyI18nString], def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent', context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None, user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None): auth=None, attach_tickets=False):
""" """
Sends an email to the user that placed this order. Basically, this method does two things: Sends an email to the user that placed this order. Basically, this method does two things:
@@ -713,9 +682,6 @@ class Order(LockModel, LoggedModel):
:param headers: Dictionary with additional mail headers :param headers: Dictionary with additional mail headers
:param sender: Custom email sender. :param sender: Custom email sender.
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download :param attach_tickets: Attach tickets of this order, if they are existing and ready to download
:param position: An order position this refers to. If given, no invoices will be attached, the tickets will
only be attached for this position and child positions, the link will only point to the
position and the attendee email will be used if available.
""" """
from pretix.base.services.mail import SendMailException, mail, render_mail from pretix.base.services.mail import SendMailException, mail, render_mail
@@ -727,16 +693,12 @@ class Order(LockModel, LoggedModel):
with language(self.locale): with language(self.locale):
recipient = self.email recipient = self.email
if position and position.attendee_email:
recipient = position.attendee_email
try: try:
email_content = render_mail(template, context) email_content = render_mail(template, context)
mail( mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender, self.event, self.locale, self, headers, sender,
invoices=invoices, attach_tickets=attach_tickets, invoices=invoices, attach_tickets=attach_tickets
position=position
) )
except SendMailException: except SendMailException:
raise raise
@@ -748,7 +710,6 @@ class Order(LockModel, LoggedModel):
data={ data={
'subject': subject, 'subject': subject,
'message': email_content, 'message': email_content,
'position': position.positionid if position else None,
'recipient': recipient, 'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [], 'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets, 'attach_tickets': attach_tickets,
@@ -768,10 +729,9 @@ class Order(LockModel, LoggedModel):
email_template = self.event.settings.mail_text_resend_link email_template = self.event.settings.mail_text_resend_link
email_context = { email_context = {
'event': self.event.name, 'event': self.event.name,
'url': build_absolute_uri(self.event, 'presale:event.order.open', kwargs={ 'url': build_absolute_uri(self.event, 'presale:event.order', kwargs={
'order': self.code, 'order': self.code,
'secret': self.secret, 'secret': self.secret
'hash': self.email_confirm_hash()
}), }),
'invoice_name': invoice_name, 'invoice_name': invoice_name,
'invoice_company': invoice_company, 'invoice_company': invoice_company,
@@ -837,8 +797,6 @@ class QuestionAnswer(models.Model):
max_length=255 max_length=255
) )
objects = ScopedManager(organizer='question__event__organizer')
@property @property
def backend_file_url(self): def backend_file_url(self):
if self.file: if self.file:
@@ -870,10 +828,6 @@ class QuestionAnswer(models.Model):
return url return url
return "" return ""
@property
def is_image(self):
return any(self.file.name.endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg'))
@property @property
def file_name(self): def file_name(self):
return self.file.name.split('.', 1)[-1] return self.file.name.split('.', 1)[-1]
@@ -950,8 +904,6 @@ class AbstractPosition(models.Model):
:type voucher: Voucher :type voucher: Voucher
:param meta_info: Additional meta information on the position, JSON-encoded. :param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str :type meta_info: str
:param seat: Seat, if reserved seating is used.
:type seat: Seat
""" """
subevent = models.ForeignKey( subevent = models.ForeignKey(
SubEvent, SubEvent,
@@ -998,9 +950,6 @@ class AbstractPosition(models.Model):
verbose_name=_("Meta information"), verbose_name=_("Meta information"),
null=True, blank=True null=True, blank=True
) )
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
class Meta: class Meta:
abstract = True abstract = True
@@ -1012,10 +961,6 @@ class AbstractPosition(models.Model):
else: else:
return {} return {}
@meta_info_data.setter
def meta_info_data(self, d):
self.meta_info = json.dumps(d)
def cache_answers(self, all=True): def cache_answers(self, all=True):
""" """
Creates two properties on the object. Creates two properties on the object.
@@ -1042,17 +987,18 @@ class AbstractPosition(models.Model):
q.pk: q for q in questions q.pk: q for q in questions
} }
def question_is_visible(parentid, qvals): def question_is_visible(parentid, qval):
parentq = question_cache[parentid] parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values): if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value):
return False return False
if parentid not in self.answ: if parentid not in self.answ:
return False return False
return ( if qval == 'True':
('True' in qvals and self.answ[parentid].answer == 'True') return self.answ[parentid].answer == 'True'
or ('False' in qvals and self.answ[parentid].answer == 'False') elif qval == 'False':
or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals)) return self.answ[parentid].answer == 'False'
) else:
return qval in [o.identifier for o in self.answ[parentid].options.all()]
self.questions = [] self.questions = []
for q in questions: for q in questions:
@@ -1061,7 +1007,7 @@ class AbstractPosition(models.Model):
q.answer.question = q # cache object q.answer.question = q # cache object
else: else:
q.answer = "" q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values): if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value):
self.questions.append(q) self.questions.append(q)
@property @property
@@ -1170,8 +1116,6 @@ class OrderPayment(models.Model):
) )
migrated = models.BooleanField(default=False) migrated = models.BooleanField(default=False)
objects = ScopedManager(organizer='order__event__organizer')
class Meta: class Meta:
ordering = ('local_id',) ordering = ('local_id',)
@@ -1188,7 +1132,7 @@ class OrderPayment(models.Model):
@info_data.setter @info_data.setter
def info_data(self, d): def info_data(self, d):
self.info = json.dumps(d, sort_keys=True) self.info = json.dumps(d)
@cached_property @cached_property
def payment_provider(self): def payment_provider(self):
@@ -1199,8 +1143,8 @@ class OrderPayment(models.Model):
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False): def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force) can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
if can_be_paid is not True: if not force and can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', { self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid 'message': can_be_paid
}, user=user, auth=auth) }, user=user, auth=auth)
@@ -1219,8 +1163,7 @@ class OrderPayment(models.Model):
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth) self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order) order_paid.send(self.order.event, order=self.order)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', ignore_date=False, lock=True):
ignore_date=False, lock=True, payment_date=None):
""" """
Marks the payment as complete. If possible, this also marks the order as paid if no further Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required payment is required
@@ -1241,6 +1184,8 @@ class OrderPayment(models.Model):
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
""" """
from pretix.base.services.invoices import generate_invoice, invoice_qualified from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with transaction.atomic(): with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk) locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
@@ -1249,7 +1194,7 @@ class OrderPayment(models.Model):
return return
locked_instance.state = self.PAYMENT_STATE_CONFIRMED locked_instance.state = self.PAYMENT_STATE_CONFIRMED
locked_instance.payment_date = payment_date or now() locked_instance.payment_date = now()
locked_instance.info = self.info # required for backwards compatibility locked_instance.info = self.info # required for backwards compatibility
locked_instance.save(update_fields=['state', 'payment_date', 'info']) locked_instance.save(update_fields=['state', 'payment_date', 'info'])
@@ -1304,47 +1249,6 @@ class OrderPayment(models.Model):
) )
if send_mail: if send_mail:
self._send_paid_mail(invoice, user, mail_text)
if self.order.event.settings.mail_send_order_paid_attendee:
for p in self.order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
self._send_paid_mail_attendee(p, user)
def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.order.locale):
name_scheme = PERSON_NAME_SCHEMES[self.order.event.settings.name_scheme]
email_template = self.order.event.settings.mail_text_order_paid_attendee
email_context = {
'event': self.order.event.name,
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
'url': build_absolute_uri(self.order.event, 'presale:event.order.position', kwargs={
'order': self.order.code,
'secret': position.web_secret,
'position': position.positionid
}),
'attendee_name': position.attendee_name,
}
for f, l, w in name_scheme['fields']:
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
try:
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[], position=position,
attach_tickets=True
)
except SendMailException:
logger.exception('Order paid email could not be sent')
def _send_paid_mail(self, invoice, user, mail_text):
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.order.locale): with language(self.order.locale):
try: try:
invoice_name = self.order.invoice_address.name invoice_name = self.order.invoice_address.name
@@ -1355,10 +1259,9 @@ class OrderPayment(models.Model):
email_template = self.order.event.settings.mail_text_order_paid email_template = self.order.event.settings.mail_text_order_paid
email_context = { email_context = {
'event': self.order.event.name, 'event': self.order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={ 'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
'order': self.order.code, 'order': self.order.code,
'secret': self.order.secret, 'secret': self.order.secret
'hash': self.order.email_confirm_hash()
}), }),
'downloads': self.order.event.settings.get('ticket_download', as_type=bool), 'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
'invoice_name': invoice_name, 'invoice_name': invoice_name,
@@ -1528,8 +1431,6 @@ class OrderRefund(models.Model):
null=True, blank=True null=True, blank=True
) )
objects = ScopedManager(organizer='order__event__organizer')
class Meta: class Meta:
ordering = ('local_id',) ordering = ('local_id',)
@@ -1546,7 +1447,7 @@ class OrderRefund(models.Model):
@info_data.setter @info_data.setter
def info_data(self, d): def info_data(self, d):
self.info = json.dumps(d, sort_keys=True) self.info = json.dumps(d)
@cached_property @cached_property
def payment_provider(self): def payment_provider(self):
@@ -1591,7 +1492,7 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__): class ActivePositionManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(canceled=False) return super().get_queryset().filter(canceled=False)
@@ -1668,7 +1569,7 @@ class OrderFee(models.Model):
) )
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer') all = models.Manager()
objects = ActivePositionManager() objects = ActivePositionManager()
@property @property
@@ -1765,7 +1666,6 @@ class OrderPosition(AbstractPosition):
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True) secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
pseudonymization_id = models.CharField( pseudonymization_id = models.CharField(
max_length=16, max_length=16,
unique=True, unique=True,
@@ -1773,7 +1673,7 @@ class OrderPosition(AbstractPosition):
) )
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer') all = models.Manager()
objects = ActivePositionManager() objects = ActivePositionManager()
class Meta: class Meta:
@@ -1873,7 +1773,6 @@ class OrderPosition(AbstractPosition):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@scopes_disabled()
def assign_pseudonymization_id(self): def assign_pseudonymization_id(self):
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0) # This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in # and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
@@ -1890,60 +1789,6 @@ class OrderPosition(AbstractPosition):
def event(self): def event(self):
return self.order.event return self.order.event
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, ``recipient`` and
``order`` parameters.
* Create a ``LogEntry`` with the email contents.
:param subject: Subject of the email
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
:param context: Dictionary to use for rendering the template
:param log_entry_type: Key to be used for the log entry
:param user: Administrative user who triggered this mail to be sent
:param headers: Dictionary with additional mail headers
:param sender: Custom email sender.
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
"""
from pretix.base.services.mail import SendMailException, mail, render_mail
if not self.email:
return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
with language(self.locale):
recipient = self.email
try:
email_content = render_mail(template, context)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers, sender,
invoices=invoices, attach_tickets=attach_tickets
)
except SendMailException:
raise
else:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
}
)
class CartPosition(AbstractPosition): class CartPosition(AbstractPosition):
""" """
@@ -1981,8 +1826,6 @@ class CartPosition(AbstractPosition):
) )
is_bundled = models.BooleanField(default=False) is_bundled = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Cart position") verbose_name = _("Cart position")
verbose_name_plural = _("Cart positions") verbose_name_plural = _("Cart positions")
@@ -2032,8 +1875,6 @@ class InvoiceAddress(models.Model):
blank=True blank=True
) )
objects = ScopedManager(organizer='order__event__organizer')
def save(self, **kwargs): def save(self, **kwargs):
if self.order: if self.order:
self.order.touch() self.order.touch()

View File

@@ -1,124 +0,0 @@
import json
from collections import namedtuple
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.deconstruct import deconstructible
from django.utils.timezone import now
from django.utils.translation import gettext, ugettext_lazy as _
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
@deconstructible
class SeatingPlanLayoutValidator:
def __call__(self, value):
if not isinstance(value, dict):
try:
val = json.loads(value)
except ValueError:
raise ValidationError(_('Your layout file is not a valid JSON file.'))
else:
val = value
with open(finders.find('seating/seating-plan.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(str(e)))
class SeatingPlan(LoggedModel):
"""
Represents an abstract seating plan, without relation to any event.
"""
name = models.CharField(max_length=190, verbose_name=_('Name'))
organizer = models.ForeignKey(Organizer, related_name='seating_plans', on_delete=models.CASCADE)
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
Category = namedtuple('Categrory', 'name')
RawSeat = namedtuple('Seat', 'name guid number row category zone')
def __str__(self):
return self.name
@property
def layout_data(self):
return json.loads(self.layout)
@layout_data.setter
def layout_data(self, v):
self.layout = json.dumps(v)
def get_categories(self):
return [
self.Category(name=c['name'])
for c in self.layout_data['categories']
]
def iter_all_seats(self):
for z in self.layout_data['zones']:
for r in z['rows']:
for s in r['seats']:
yield self.RawSeat(
number=s['seat_number'],
guid=s['seat_guid'],
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
row=r['row_number'],
zone=z['name'],
category=s['category']
)
class SeatCategoryMapping(models.Model):
"""
Input seating plans have abstract "categories", such as "Balcony seat", etc. This model maps them to actual
pretix product on a per-(sub)event level.
"""
event = models.ForeignKey(Event, related_name='seat_category_mappings', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seat_category_mappings', on_delete=models.CASCADE)
layout_category = models.CharField(max_length=190)
product = models.ForeignKey(Item, related_name='seat_category_mappings', on_delete=models.CASCADE)
class Seat(models.Model):
"""
This model is used to represent every single specific seat within an (sub)event that can be selected. It's mainly
used for internal bookkeeping and not to be modified by users directly.
"""
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
name = models.CharField(max_length=190)
zone_name = models.CharField(max_length=190, blank=True, default="")
row_name = models.CharField(max_length=190, blank=True, default="")
seat_number = models.CharField(max_length=190, blank=True, default="")
seat_guid = models.CharField(max_length=190, db_index=True)
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
blocked = models.BooleanField(default=False)
def __str__(self):
parts = []
if self.zone_name:
parts.append(self.zone_name)
if self.row_name:
parts.append(gettext('Row {number}').format(number=self.row_name))
if self.seat_number:
parts.append(gettext('Seat {number}').format(number=self.seat_number))
if not parts:
return self.name
return ', '.join(parts)
def is_available(self, ignore_cart=None, ignore_orderpos=None):
from .orders import Order
if self.blocked:
return False
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
cpqs = self.cartposition_set.filter(expires__gte=now())
if ignore_cart:
cpqs = cpqs.exclude(pk=ignore_cart.pk)
if ignore_orderpos:
opqs = opqs.exclude(pk=ignore_orderpos.pk)
return not opqs.exists() and not cpqs.exists()

View File

@@ -8,9 +8,6 @@ from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import SeatCategoryMapping
from ..decimal import round_decimal from ..decimal import round_decimal
from .base import LoggedModel from .base import LoggedModel
@@ -26,7 +23,6 @@ def _generate_random_code(prefix=None):
return get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset) return get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset)
@scopes_disabled()
def generate_code(prefix=None): def generate_code(prefix=None):
while True: while True:
code = _generate_random_code(prefix=prefix) code = _generate_random_code(prefix=prefix)
@@ -142,26 +138,22 @@ class Voucher(LoggedModel):
item = models.ForeignKey( item = models.ForeignKey(
Item, related_name='vouchers', Item, related_name='vouchers',
verbose_name=_("Product"), verbose_name=_("Product"),
null=True, blank=True, null=True, blank=True, on_delete=models.CASCADE,
on_delete=models.PROTECT, # We use a fake version of SET_NULL in Item.delete()
help_text=_( help_text=_(
"This product is added to the user's cart if the voucher is redeemed." "This product is added to the user's cart if the voucher is redeemed."
) )
) )
variation = models.ForeignKey( variation = models.ForeignKey(
ItemVariation, related_name='vouchers', ItemVariation, related_name='vouchers',
null=True, blank=True, null=True, blank=True, on_delete=models.CASCADE,
on_delete=models.PROTECT, # We use a fake version of SET_NULL in ItemVariation.delete() to avoid the semantic change
# that would happen if we just set variation to None
verbose_name=_("Product variation"), verbose_name=_("Product variation"),
help_text=_( help_text=_(
"This variation of the product select above is being used." "This variation of the product select above is being used."
) )
) )
quota = models.ForeignKey( quota = models.ForeignKey(
Quota, related_name='vouchers', Quota, related_name='quota',
null=True, blank=True, null=True, blank=True, on_delete=models.CASCADE,
on_delete=models.PROTECT, # We use a fake version of SET_NULL in Quota.delete()
verbose_name=_("Quota"), verbose_name=_("Quota"),
help_text=_( help_text=_(
"If enabled, the voucher is valid for any product affected by this quota." "If enabled, the voucher is valid for any product affected by this quota."
@@ -180,12 +172,6 @@ class Voucher(LoggedModel):
help_text=_("The text entered in this field will not be visible to the user and is available for your " help_text=_("The text entered in this field will not be visible to the user and is available for your "
"convenience.") "convenience.")
) )
show_hidden_items = models.BooleanField(
verbose_name=_("Shows hidden products that match this voucher"),
default=True
)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Voucher") verbose_name = _("Voucher")
@@ -405,14 +391,3 @@ class Voucher(LoggedModel):
""" """
return Order.objects.filter(all_positions__voucher__in=[self]).distinct() return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
def seating_available(self):
kwargs = {}
if self.subevent:
kwargs['subevent'] = self.subevent
if self.quota_id:
return SeatCategoryMapping.objects.filter(product__quotas__pk=self.quota_id, **kwargs).exists()
elif self.item_id:
return self.item.seat_category_mappings.filter(**kwargs).exists()
else:
return False

View File

@@ -4,7 +4,6 @@ from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Voucher from pretix.base.models import Voucher
@@ -68,8 +67,6 @@ class WaitingListEntry(LoggedModel):
) )
priority = models.IntegerField(default=0) priority = models.IntegerField(default=0)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Waiting list entry") verbose_name = _("Waiting list entry")
verbose_name_plural = _("Waiting list entries") verbose_name_plural = _("Waiting list entries")

View File

@@ -249,7 +249,9 @@ class BasePaymentProvider:
('_fee_percent', ('_fee_percent',
forms.DecimalField( forms.DecimalField(
label=_('Additional fee'), label=_('Additional fee'),
help_text=_('Percentage of the order total.'), help_text=_('Percentage of the order total. Note that this percentage will currently only '
'be calculated on the summed price of sold tickets, not on other fees like e.g. shipping '
'fees, if there are any.'),
localize=True, localize=True,
required=False, required=False,
)), )),
@@ -296,12 +298,11 @@ class BasePaymentProvider:
""" """
return "" return ""
def render_invoice_text(self, order: Order, payment: OrderPayment) -> str: def render_invoice_text(self, order: Order) -> str:
""" """
This is called when an invoice for an order with this payment provider is generated. This is called when an invoice for an order with this payment provider is generated.
The default implementation returns the content of the _invoice_text configuration The default implementation returns the content of the _invoice_text configuration
variable (an I18nString), or an empty string if unconfigured. For paid orders, the variable (an I18nString), or an empty string if unconfigured.
default implementation always renders a string stating that the invoice is already paid.
""" """
if order.status == Order.STATUS_PAID: if order.status == Order.STATUS_PAID:
return pgettext_lazy('invoice', 'The payment for this invoice has already been received.') return pgettext_lazy('invoice', 'The payment for this invoice has already been received.')
@@ -546,14 +547,13 @@ class BasePaymentProvider:
""" """
return None return None
def order_pending_mail_render(self, order: Order, payment: OrderPayment) -> str: def order_pending_mail_render(self, order: Order) -> str:
""" """
After the user has submitted their order, they will receive a confirmation After the user has submitted their order, they will receive a confirmation
email. You can return a string from this method if you want to add additional email. You can return a string from this method if you want to add additional
information to this email. information to this email.
:param order: The order object :param order: The order object
:param payment: The payment object
""" """
return "" return ""

View File

@@ -114,7 +114,7 @@ DEFAULT_VARIABLES = OrderedDict((
("event_date_range", { ("event_date_range", {
"label": _("Event date range"), "label": _("Event date range"),
"editor_sample": _("May 31st June 4th, 2017"), "editor_sample": _("May 31st June 4th, 2017"),
"evaluate": lambda op, order, ev: ev.get_date_range_display(force_show_end=True) "evaluate": lambda op, order, ev: ev.get_date_range_display()
}), }),
("event_begin", { ("event_begin", {
"label": _("Event begin date and time"), "label": _("Event begin date and time"),
@@ -238,26 +238,6 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT" "TIME_FORMAT"
) if ev.date_admission else "" ) if ev.date_admission else ""
}), }),
("seat", {
"label": _("Seat: Full name"),
"editor_sample": _("Ground floor, Row 3, Seat 4"),
"evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission'))
}),
("seat_zone", {
"label": _("Seat: zone"),
"editor_sample": _("Ground floor"),
"evaluate": lambda op, order, ev: str(op.seat.zone_name if op.seat else _('General admission'))
}),
("seat_row", {
"label": _("Seat: row"),
"editor_sample": "3",
"evaluate": lambda op, order, ev: str(op.seat.row_name if op.seat else "")
}),
("seat_number", {
"label": _("Seat: seat number"),
"editor_sample": 4,
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
}),
)) ))

View File

@@ -71,7 +71,7 @@ class RelativeDateWrapper:
else: else:
base_date = getattr(event, self.data.base_date_name) or event.date_from base_date = getattr(event, self.data.base_date_name) or event.date_from
oldoffset = base_date.astimezone(tz).utcoffset() oldoffset = base_date.utcoffset()
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before) new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
if self.data.time: if self.data.time:
new_date = new_date.replace( new_date = new_date.replace(

View File

@@ -6,16 +6,15 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, OuterRef, Q from django.db.models import Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _ from django.utils.translation import pgettext_lazy, ugettext as _
from django_scopes import scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat, CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
SeatCategoryMapping, Voucher, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee from pretix.base.models.orders import OrderFee
@@ -24,9 +23,8 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.celery_app import app from pretix.celery_app import app
from pretix.presale.signals import ( from pretix.presale.signals import (
@@ -92,20 +90,15 @@ error_messages = {
'product %(base)s.'), 'product %(base)s.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'), 'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'), 'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
'seat_required': _('You need to select a specific seat.'),
'seat_invalid': _('Please select a valid seat.'),
'seat_forbidden': _('You can not select a seat for this position.'),
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
'seat_multiple': _('You can not select the same seat multiple times.'),
} }
class CartManager: class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas', AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat')) 'addon_to', 'subevent', 'includes_tax', 'bundled'))
RemoveOperation = namedtuple('RemoveOperation', ('position',)) RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent', 'seat')) 'quotas', 'subevent'))
order = { order = {
RemoveOperation: 10, RemoveOperation: 10,
ExtendOperation: 20, ExtendOperation: 20,
@@ -123,7 +116,6 @@ class CartManager:
self._items_cache = {} self._items_cache = {}
self._subevents_cache = {} self._subevents_cache = {}
self._variations_cache = {} self._variations_cache = {}
self._seated_cache = {}
self._expiry = None self._expiry = None
self.invoice_address = invoice_address self.invoice_address = invoice_address
self._widget_data = widget_data or {} self._widget_data = widget_data or {}
@@ -135,11 +127,6 @@ class CartManager:
Q(cart_id=self.cart_id) & Q(event=self.event) Q(cart_id=self.cart_id) & Q(event=self.event)
).select_related('item', 'subevent') ).select_related('item', 'subevent')
def _is_seated(self, item, subevent):
if (item, subevent) not in self._seated_cache:
self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists()
return self._seated_cache[item, subevent]
def _calculate_expiry(self): def _calculate_expiry(self):
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
@@ -200,8 +187,6 @@ class CartManager:
i.pk: i i.pk: i
for i in self.event.items.select_related('category').prefetch_related( for i in self.event.items.select_related('category').prefetch_related(
'addons', 'bundles', 'addons__addon_category', 'quotas' 'addons', 'bundles', 'addons__addon_category', 'quotas'
).annotate(
has_variations=Count('variations'),
).filter( ).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache] id__in=[i for i in item_ids if i and i not in self._items_cache]
) )
@@ -229,7 +214,7 @@ class CartManager:
if op.item.require_voucher and op.voucher is None: if op.item.require_voucher and op.voucher is None:
raise CartError(error_messages['voucher_required']) raise CartError(error_messages['voucher_required'])
if op.item.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 op.voucher.item is None or op.voucher.item.pk != op.item.pk):
raise CartError(error_messages['voucher_required']) raise CartError(error_messages['voucher_required'])
if not op.item.is_available() or (op.variation and not op.variation.active): if not op.item.is_available() or (op.variation and not op.variation.active):
@@ -238,12 +223,6 @@ class CartManager:
if self._sales_channel not in op.item.sales_channels: if self._sales_channel not in op.item.sales_channels:
raise CartError(error_messages['unavailable']) raise CartError(error_messages['unavailable'])
if op.item.has_variations and not op.variation:
raise CartError(error_messages['not_for_sale'])
if op.variation and op.variation.item_id != op.item.pk:
raise CartError(error_messages['not_for_sale'])
if op.voucher and not op.voucher.applies_to(op.item, op.variation): if op.voucher and not op.voucher.applies_to(op.item, op.variation):
raise CartError(error_messages['voucher_invalid_item']) raise CartError(error_messages['voucher_invalid_item'])
@@ -259,16 +238,6 @@ class CartManager:
if op.subevent and op.subevent.presale_has_ended: if op.subevent and op.subevent.presale_has_ended:
raise CartError(error_messages['ended']) raise CartError(error_messages['ended'])
seated = self._is_seated(op.item, op.subevent)
if seated and (not op.seat or op.seat.blocked):
raise CartError(error_messages['seat_invalid'])
elif op.seat and not seated:
raise CartError(error_messages['seat_forbidden'])
elif op.seat and op.seat.product != op.item:
raise CartError(error_messages['seat_invalid'])
elif op.seat and op.count > 1:
raise CartError('Invalid request: A seat can only be bought once.')
if op.subevent: if op.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv: if tlv:
@@ -331,13 +300,6 @@ class CartManager:
def extend_expired_positions(self): def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related( expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item' 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).annotate(
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True))
)
)
).prefetch_related( ).prefetch_related(
'item__quotas', 'item__quotas',
'variation__quotas', 'variation__quotas',
@@ -350,8 +312,6 @@ class CartManager:
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions): if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
continue continue
cp.item.requires_seat = cp.requires_seat
if cp.is_bundled: if cp.is_bundled:
try: try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation) bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
@@ -398,7 +358,7 @@ class CartManager:
op = self.ExtendOperation( op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1, position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat price=price, quotas=quotas, subevent=cp.subevent
) )
self._check_item_constraints(op) self._check_item_constraints(op)
@@ -417,6 +377,12 @@ class CartManager:
operations = [] operations = []
for i in items: for i in items:
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
if self.event.has_subevents: if self.event.has_subevents:
if not i.get('subevent'): if not i.get('subevent'):
raise CartError(error_messages['subevent_required']) raise CartError(error_messages['subevent_required'])
@@ -424,24 +390,6 @@ class CartManager:
else: else:
subevent = None subevent = None
# When a seat is given, we ignore the item that was given, since we can infer it from the
# seat. The variation is still relevant, though!
seat = None
if i.get('seat'):
try:
seat = (subevent or self.event).seats.get(seat_guid=i.get('seat'))
except Seat.DoesNotExist:
raise CartError(error_messages['seat_invalid'])
i['item'] = seat.product_id
if i['item'] not in self._items_cache:
self._update_items_cache([i['item']], [i['variation']])
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
item = self._items_cache[i['item']] item = self._items_cache[i['item']]
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
voucher = None voucher = None
@@ -497,7 +445,7 @@ class CartManager:
bop = self.AddOperation( bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice, count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent, voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[], seat=None includes_tax=bool(bprice.rate), bundled=[]
) )
self._check_item_constraints(bop) self._check_item_constraints(bop)
bundled.append(bop) bundled.append(bop)
@@ -506,7 +454,7 @@ class CartManager:
op = self.AddOperation( op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas, count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled
) )
self._check_item_constraints(op) self._check_item_constraints(op)
operations.append(op) operations.append(op)
@@ -612,7 +560,7 @@ class CartManager:
op = self.AddOperation( op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=cp.seat addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[]
) )
self._check_item_constraints(op) self._check_item_constraints(op)
operations.append(op) operations.append(op)
@@ -644,15 +592,6 @@ class CartManager:
'cat': str(iao.addon_category.name), 'cat': str(iao.addon_category.name),
} }
) )
validate_cart_addons.send(
sender=self.event,
addons={
(self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None)
for s in selected
},
base_position=cp,
iao=iao
)
# Detect removed add-ons and create RemoveOperations # Detect removed add-ons and create RemoveOperations
for cp, al in current_addons.items(): for cp, al in current_addons.items():
@@ -695,7 +634,7 @@ class CartManager:
Q(voucher=voucher) & Q(event=self.event) & Q(voucher=voucher) & Q(event=self.event) &
Q(expires__gte=self.now_dt) Q(expires__gte=self.now_dt)
).exclude(pk__in=[ ).exclude(pk__in=[
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation) op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
]) ])
cart_count = redeemed_in_carts.count() cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count v_avail = voucher.max_usages - voucher.redeemed - cart_count
@@ -747,7 +686,6 @@ class CartManager:
err = err or self._check_min_per_product() err = err or self._check_min_per_product()
self._operations.sort(key=lambda a: self.order[type(a)]) self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set()
for op in self._operations: for op in self._operations:
if isinstance(op, self.RemoveOperation): if isinstance(op, self.RemoveOperation):
@@ -761,11 +699,6 @@ class CartManager:
# Create a CartPosition for as much items as we can # Create a CartPosition for as much items as we can
requested_count = quota_available_count = voucher_available_count = op.count requested_count = quota_available_count = voucher_available_count = op.count
if op.seat:
if op.seat in seats_seen:
err = err or error_messages['seat_multiple']
seats_seen.add(op.seat)
if op.quotas: if op.quotas:
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas)) quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
@@ -789,19 +722,14 @@ class CartManager:
if isinstance(op, self.AddOperation): if isinstance(op, self.AddOperation):
for b in op.bundled: for b in op.bundled:
b_quotas = list(b.quotas) b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b.quotas))
if not b_quotas:
err = err or error_messages['unavailable']
available_count = 0
continue
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas))
if b_quota_available_count < b.count: if b_quota_available_count < b.count:
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
available_count = 0 available_count = 0
elif b_quota_available_count < available_count * b.count: elif b_quota_available_count < available_count * b.count:
err = err or error_messages['in_part'] err = err or error_messages['in_part']
available_count = b_quota_available_count // b.count available_count = b_quota_available_count // b.count
for q in b_quotas: for q in b.quotas:
quotas_ok[q] -= available_count * b.count quotas_ok[q] -= available_count * b.count
# TODO: is this correct? # TODO: is this correct?
@@ -816,16 +744,12 @@ class CartManager:
available_count = 0 available_count = 0
if isinstance(op, self.AddOperation): if isinstance(op, self.AddOperation):
if op.seat and not op.seat.is_available():
available_count = 0
err = err or error_messages['seat_unavailable']
for k in range(available_count): for k in range(available_count):
cp = CartPosition( cp = CartPosition(
event=self.event, item=op.item, variation=op.variation, event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id, price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None, voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat subevent=op.subevent, includes_tax=op.includes_tax
) )
if self.event.settings.attendee_names_asked: if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme) scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -864,11 +788,7 @@ class CartManager:
new_cart_positions.append(cp) new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation): elif isinstance(op, self.ExtendOperation):
if op.seat and not op.seat.is_available(ignore_cart=op.position): if available_count == 1:
err = err or error_messages['seat_unavailable']
op.position.addons.all().delete()
op.position.delete()
elif available_count == 1:
op.position.expires = self._expiry op.position.expires = self._expiry
op.position.price = op.price.gross op.position.price = op.price.gross
try: try:
@@ -899,9 +819,6 @@ class CartManager:
# If any quotas are affected that are not unlimited, we lock # If any quotas are affected that are not unlimited, we lock
return True return True
if any(getattr(o, 'seat', False) for o in self._operations):
return True
return False return False
def commit(self): def commit(self):
@@ -953,12 +870,6 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
def get_fees(event, request, total, invoice_address, provider): def get_fees(event, request, total, invoice_address, provider):
fees = [] fees = []
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total):
if resp:
fees += resp
total = total + sum(f.value for f in fees)
if provider and total != 0: if provider and total != 0:
provider = event.get_payment_providers().get(provider) provider = event.get_payment_providers().get(provider)
if provider: if provider:
@@ -984,24 +895,29 @@ def get_fees(event, request, total, invoice_address, provider):
tax_rule=payment_fee_tax_rule tax_rule=payment_fee_tax_rule
)) ))
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total):
fees += resp
return fees return fees
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en', def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, widget_data=None, sales_channel='web') -> None: invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
""" """
Adds a list of items to a user's cart. Adds a list of items to a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID :param items: A list of dicts with the keys item, variation, count, custom_price, voucher
:param cart_id: Session ID of a guest :param cart_id: Session ID of a guest
:raises CartError: On any error that occured :raises CartError: On any error that occured
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
ia = False ia = False
if invoice_address: if invoice_address:
try: try:
with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address) ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
@@ -1018,8 +934,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en') -> None: def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
@@ -1027,6 +943,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id) cm = CartManager(event=event, cart_id=cart_id)
@@ -1038,14 +955,15 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None: def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id) cm = CartManager(event=event, cart_id=cart_id)
@@ -1057,8 +975,8 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en', def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web') -> None: invoice_address: int=None, sales_channel='web') -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
@@ -1067,10 +985,11 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
ia = False ia = False
if invoice_address: if invoice_address:
try: try:
with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address) ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass

View File

@@ -60,7 +60,7 @@ def _save_answers(op, answers, given_answers):
@transaction.atomic @transaction.atomic
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False): user=None, auth=None):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -90,10 +90,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'answers' 'answers'
).get(pk=op.pk) ).get(pk=op.pk)
if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING): if op.canceled:
raise CheckInError( raise CheckInError(
_('This order position has been canceled.'), _('This order position has been canceled.'),
'canceled' if canceled_supported else 'unpaid' 'unpaid'
) )
answers = {a.question: a for a in op.answers.all()} answers = {a.question: a for a in op.answers.all()}

View File

@@ -2,7 +2,6 @@ from datetime import timedelta
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import CachedCombinedTicket, CachedTicket from pretix.base.models import CachedCombinedTicket, CachedTicket
@@ -11,7 +10,6 @@ from ..signals import periodic_task
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def clean_cart_positions(sender, **kwargs): def clean_cart_positions(sender, **kwargs):
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False): for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False):
cp.delete() cp.delete()
@@ -22,14 +20,12 @@ def clean_cart_positions(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_files(sender, **kwargs): def clean_cached_files(sender, **kwargs):
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()): for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
cf.delete() cf.delete()
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_tickets(sender, **kwargs): def clean_cached_tickets(sender, **kwargs):
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)): for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
cf.delete() cf.delete()

View File

@@ -2,33 +2,24 @@ from typing import Any, Dict
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.timezone import override from django.utils.timezone import override
from django.utils.translation import ugettext
from pretix.base.i18n import LazyLocaleException, language from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, cachedfile_name from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledTask
from pretix.base.signals import register_data_exporters from pretix.base.signals import register_data_exporters
from pretix.celery_app import app from pretix.celery_app import app
class ExportError(LazyLocaleException): @app.task(base=ProfiledTask)
pass def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
event = Event.objects.get(id=event)
@app.task(base=ProfiledEventTask, throws=(ExportError,))
def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
file = CachedFile.objects.get(id=fileid) file = CachedFile.objects.get(id=fileid)
with language(event.settings.locale), override(event.settings.timezone): with language(event.settings.locale), override(event.settings.timezone):
responses = register_data_exporters.send(event) responses = register_data_exporters.send(event)
for receiver, response in responses: for receiver, response in responses:
ex = response(event) ex = response(event)
if ex.identifier == provider: if ex.identifier == provider:
d = ex.render(form_data) file.filename, file.type, data = ex.render(form_data)
if d is None:
raise ExportError(
ugettext('Your export did not contain any data.')
)
file.filename, file.type, data = d
file.file.save(cachedfile_name(file, file.filename), ContentFile(data)) file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
file.save() file.save()
return file.pk return file.pk

View File

@@ -1,4 +1,3 @@
import inspect
import json import json
import logging import logging
import urllib.error import urllib.error
@@ -16,7 +15,6 @@ from django.utils import timezone
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _ from django.utils.translation import pgettext, ugettext as _
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language from pretix.base.i18n import language
@@ -54,9 +52,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString) additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString) footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if open_payment and open_payment.payment_provider: if open_payment and open_payment.payment_provider:
if 'payment' in inspect.signature(open_payment.payment_provider.render_invoice_text).parameters:
payment = open_payment.payment_provider.render_invoice_text(invoice.order, open_payment)
else:
payment = open_payment.payment_provider.render_invoice_text(invoice.order) payment = open_payment.payment_provider.render_invoice_text(invoice.order)
elif invoice.order.status == Order.STATUS_PAID: elif invoice.order.status == Order.STATUS_PAID:
payment = pgettext('invoice', 'The payment for this invoice has already been received.') payment = pgettext('invoice', 'The payment for this invoice has already been received.')
@@ -249,9 +244,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
@app.task(base=TransactionAwareTask) @app.task(base=TransactionAwareTask)
def invoice_pdf_task(invoice: int): def invoice_pdf_task(invoice: int):
with scopes_disabled():
i = Invoice.objects.get(pk=invoice) i = Invoice.objects.get(pk=invoice)
with scope(organizer=i.order.event.organizer):
if i.shredded: if i.shredded:
return None return None
if i.file: if i.file:
@@ -264,8 +257,7 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order): def invoice_qualified(order: Order):
if order.total == Decimal('0.00') or order.require_approval or \ if order.total == Decimal('0.00') or order.require_approval:
order.sales_channel not in order.event.settings.get('invoice_generate_sales_channels'):
return False return False
return True return True

View File

@@ -1,31 +1,19 @@
import inspect
import logging import logging
import os
import re
import smtplib import smtplib
import warnings
from email.encoders import encode_noop
from email.mime.image import MIMEImage
from email.utils import formataddr from email.utils import formataddr
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
from urllib.parse import urljoin, urlparse
import cssutils import cssutils
import requests
from bs4 import BeautifulSoup
from celery import chain from celery import chain
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.email import ClassicMailRenderer from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import Event, Invoice, InvoiceAddress, Order
Event, Invoice, InvoiceAddress, Order, OrderPosition,
)
from pretix.base.services.invoices import invoice_pdf_task from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tasks import TransactionAwareTask from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order from pretix.base.services.tickets import get_tickets_for_order
@@ -50,8 +38,8 @@ class SendMailException(Exception):
def mail(email: str, subject: str, template: Union[str, LazyI18nString], def mail(email: str, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, event: Event=None, locale: str=None, context: Dict[str, Any]=None, event: Event=None, locale: str=None,
order: Order=None, position: OrderPosition=None, headers: dict=None, sender: str=None, order: Order=None, headers: dict=None, sender: str=None, invoices: list=None,
invoices: list=None, attach_tickets=False): attach_tickets=False):
""" """
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -72,9 +60,6 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
:param order: The order this email is related to (optional). If set, this will be used to include a link to the :param order: The order this email is related to (optional). If set, this will be used to include a link to the
order below the email. order below the email.
:param order: The order position this email is related to (optional). If set, this will be used to include a link
to the order position instead of the order below the email.
:param headers: A dict of custom mail headers to add to the mail :param headers: A dict of custom mail headers to add to the mail
:param locale: The locale to be used while evaluating the subject and the template :param locale: The locale to be used while evaluating the subject and the template
@@ -115,8 +100,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
subject = str(subject).format_map(context) subject = str(subject).format_map(context)
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
if event: if event:
sender_name = event.settings.mail_from_name or str(event.name) sender = formataddr((str(event.name), sender))
sender = formataddr((sender_name, sender))
else: else:
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender)) sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
@@ -127,8 +111,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
if event: if event:
renderer = event.get_html_mail_renderer() renderer = event.get_html_mail_renderer()
if event.settings.mail_bcc: if event.settings.mail_bcc:
for bcc_mail in event.settings.mail_bcc.split(','): bcc.append(event.settings.mail_bcc)
bcc.append(bcc_mail.strip())
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'): if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = event.settings.contact_mail headers['Reply-To'] = event.settings.contact_mail
@@ -147,26 +130,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
body_plain += signature body_plain += signature
body_plain += "\r\n\r\n-- \r\n" body_plain += "\r\n\r\n-- \r\n"
if order and order.testmode: if order:
if order.testmode:
subject = "[TESTMODE] " + subject subject = "[TESTMODE] " + subject
if order and position:
body_plain += _(
"You are receiving this email because someone placed an order for {event} for you."
).format(event=event.name)
body_plain += "\r\n"
body_plain += _(
"You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri(
order.event, 'presale:event.order.position', kwargs={
'order': order.code,
'secret': position.web_secret,
'position': position.positionid,
}
)
)
elif order:
body_plain += _( body_plain += _(
"You are receiving this email because you placed an order for {event}." "You are receiving this email because you placed an order for {event}."
).format(event=event.name) ).format(event=event.name)
@@ -175,23 +141,15 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
"You can view your order details at the following URL:\n{orderurl}." "You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format( ).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri( event=event.name, orderurl=build_absolute_uri(
order.event, 'presale:event.order.open', kwargs={ order.event, 'presale:event.order', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret
'hash': order.email_confirm_hash()
} }
) )
) )
body_plain += "\r\n" body_plain += "\r\n"
try: try:
if 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, str(subject), order, position)
else:
# Backwards compatibility
warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, str(subject), order) body_html = renderer.render(content_plain, signature, str(subject), order)
except: except:
logger.exception('Could not render HTML body') logger.exception('Could not render HTML body')
@@ -206,9 +164,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
sender=sender, sender=sender,
event=event.id if event else None, event=event.id if event else None,
headers=headers, headers=headers,
invoices=[i.pk for i in invoices] if invoices and not position else [], invoices=[i.pk for i in invoices] if invoices else [],
order=order.pk if order else None, order=order.pk if order else None,
position=position.pk if position else None,
attach_tickets=attach_tickets attach_tickets=attach_tickets
) )
@@ -223,24 +180,11 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
@app.task(base=TransactionAwareTask, bind=True) @app.task(base=TransactionAwareTask, bind=True)
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str, def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int=None, position: int=None, headers: dict=None, bcc: List[str]=None, event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
invoices: List[int]=None, order: int=None, attach_tickets=False) -> bool: order: int=None, attach_tickets=False) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers) email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None: if html is not None:
html_with_cid, cid_images = replace_images_with_cid_paths(html) email.attach_alternative(html, "text/html")
email = attach_cid_images(email, cid_images, verify_ssl=True)
email.attach_alternative(html_with_cid, "text/html")
if event:
with scopes_disabled():
event = Event.objects.get(id=event)
backend = event.get_mail_backend()
cm = lambda: scope(organizer=event.organizer) # noqa
else:
backend = get_connection(fail_silently=False)
cm = lambda: scopes_disabled() # noqa
with cm():
if invoices: if invoices:
invoices = Invoice.objects.filter(pk__in=invoices) invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices: for inv in invoices:
@@ -254,6 +198,13 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
except: except:
logger.exception('Could not attach invoice to email') logger.exception('Could not attach invoice to email')
pass pass
if event:
event = Event.objects.get(id=event)
backend = event.get_mail_backend()
else:
backend = get_connection(fail_silently=False)
if event: if event:
if order: if order:
try: try:
@@ -261,15 +212,10 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
except Order.DoesNotExist: except Order.DoesNotExist:
order = None order = None
else: else:
if position:
try:
position = order.positions.get(pk=position)
except OrderPosition.DoesNotExist:
attach_tickets = False
if attach_tickets: if attach_tickets:
args = [] args = []
attach_size = 0 attach_size = 0
for name, ct in get_tickets_for_order(order, base_position=position): for name, ct in get_tickets_for_order(order):
content = ct.file.read() content = ct.file.read()
args.append((name, content, ct.type)) args.append((name, content, ct.type))
attach_size += len(content) attach_size += len(content)
@@ -341,92 +287,3 @@ def render_mail(template, context):
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) body = tpl.render(context)
return body return body
def replace_images_with_cid_paths(body_html):
if body_html:
email = BeautifulSoup(body_html, "lxml")
cid_images = []
for image in email.findAll('img'):
original_image_src = image['src']
try:
cid_id = "image_%s" % cid_images.index(original_image_src)
except ValueError:
cid_images.append(original_image_src)
cid_id = "image_%s" % (len(cid_images) - 1)
image['src'] = "cid:%s" % cid_id
return email.prettify(), cid_images
else:
return body_html, []
def attach_cid_images(msg, cid_images, verify_ssl=True):
if cid_images and len(cid_images) > 0:
msg.mixed_subtype = 'related'
for key, image in enumerate(cid_images):
cid = 'image_%s' % key
try:
mime_image = convert_image_to_cid(
image, cid, verify_ssl)
if mime_image:
msg.attach(mime_image)
except:
logger.exception("ERROR attaching CID image %s[%s]" % (cid, image))
return msg
def convert_image_to_cid(image_src, cid_id, verify_ssl=True):
try:
if image_src.startswith('data:image/'):
image_type, image_content = image_src.split(',', 1)
image_type = re.findall(r'data:image/(\w+);base64', image_type)[0]
mime_image = MIMEImage(image_content, _subtype=image_type, _encoder=encode_noop)
mime_image.add_header('Content-Transfer-Encoding', 'base64')
elif image_src.startswith('data:'):
logger.exception("ERROR creating MIME element %s[%s]" % (cid_id, image_src))
return None
else:
image_src = normalize_image_url(image_src)
path = urlparse(image_src).path
guess_subtype = os.path.splitext(path)[1][1:]
response = requests.get(image_src, verify=verify_ssl)
mime_image = MIMEImage(
response.content, _subtype=guess_subtype)
mime_image.add_header('Content-ID', '<%s>' % cid_id)
return mime_image
except:
logger.exception("ERROR creating mime_image %s[%s]" % (cid_id, image_src))
return None
def normalize_image_url(url):
if '://' not in url:
"""
If we see a relative URL in an email, we can't know if it is meant to be a media file
or a static file, so we need to guess. If it is a static file included with the
``{% static %}`` template tag (as it should be), then ``STATIC_URL`` is already prepended.
If ``STATIC_URL`` is absolute, then ``url`` should already be absolute and this
function should not be triggered. Thus, if we see a relative URL and ``STATIC_URL``
is absolute *or* ``url`` does not start with ``STATIC_URL``, we can be sure this
is a media file (or a programmer error …).
Constructing the URL of either a static file or a media file from settings is still
not clean, since custom storage backends might very well use more complex approaches
to build those URLs. However, this is good enough as a best-effort approach. Complex
storage backends (such as cloud storages) will return absolute URLs anyways so this
function is not needed in that case.
"""
if '://' not in settings.STATIC_URL and url.startswith(settings.STATIC_URL):
url = urljoin(settings.SITE_URL, url)
else:
url = urljoin(settings.MEDIA_URL, url)
return url

View File

@@ -1,6 +1,5 @@
from django.conf import settings from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
from django_scopes import scope, scopes_disabled
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
from pretix.base.i18n import language from pretix.base.i18n import language
@@ -13,7 +12,6 @@ from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask) @app.task(base=TransactionAwareTask)
@scopes_disabled()
def notify(logentry_id: int): def notify(logentry_id: int):
logentry = LogEntry.all.get(id=logentry_id) logentry = LogEntry.all.get(id=logentry_id)
if not logentry.event: if not logentry.event:
@@ -32,7 +30,7 @@ def notify(logentry_id: int):
# All users that have the permission to get the notification # All users that have the permission to get the notification
users = logentry.event.get_users_with_permission( users = logentry.event.get_users_with_permission(
notification_type.required_permission notification_type.required_permission
).filter(notifications_send=True, is_active=True) ).filter(notifications_send=True)
if logentry.user: if logentry.user:
users = users.exclude(pk=logentry.user.pk) users = users.exclude(pk=logentry.user.pk)
@@ -68,11 +66,6 @@ def notify(logentry_id: int):
@app.task(base=ProfiledTask) @app.task(base=ProfiledTask)
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str): def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id) logentry = LogEntry.all.get(id=logentry_id)
if logentry.event:
sm = lambda: scope(organizer=logentry.event.organizer) # noqa
else:
sm = lambda: scopes_disabled() # noqa
with sm():
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
types = get_all_notification_types(logentry.event) types = get_all_notification_types(logentry.event)
notification_type = types.get(action_type) notification_type = types.get(action_type)
@@ -111,11 +104,7 @@ def send_notification_mail(notification: Notification, user: User):
mail_send_task.apply_async(kwargs={ mail_send_task.apply_async(kwargs={
'to': [user.email], 'to': [user.email],
'subject': '[{}] {}: {}'.format( 'subject': '[{}] {}'.format(settings.PRETIX_INSTANCE_NAME, notification.title),
settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(),
notification.title
),
'body': body_plain, 'body': body_plain,
'html': body_html, 'html': body_html,
'sender': settings.MAIL_FROM, 'sender': settings.MAIL_FROM,

View File

@@ -1,4 +1,3 @@
import inspect
import json import json
import logging import logging
from collections import Counter, namedtuple from collections import Counter, namedtuple
@@ -17,7 +16,6 @@ from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication from pretix.api.models import OAuthApplication
from pretix.base.i18n import ( from pretix.base.i18n import (
@@ -25,7 +23,7 @@ from pretix.base.i18n import (
) )
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment, CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, OrderPosition, Quota, User, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle from pretix.base.models.items import ItemBundle
@@ -44,12 +42,11 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask from pretix.base.services.tasks import ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import ( from pretix.base.signals import (
allow_ticket_download, order_approved, order_canceled, order_changed, allow_ticket_download, order_approved, order_canceled, order_changed,
order_denied, order_expired, order_fee_calculation, order_placed, order_denied, order_expired, order_fee_calculation, order_placed,
order_split, periodic_task, validate_order, periodic_task,
) )
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
@@ -83,8 +80,6 @@ error_messages = {
'affected positions have been removed from your cart.'), 'affected positions have been removed from your cart.'),
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected ' 'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
'positions have been removed from your cart.'), 'positions have been removed from your cart.'),
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
} }
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -227,10 +222,9 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires), 'date': LazyDate(order.expires),
'event': order.event.name, 'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret
'hash': order.email_confirm_hash()
}), }),
'invoice_name': invoice_name, 'invoice_name': invoice_name,
'invoice_company': invoice_company, 'invoice_company': invoice_company,
@@ -288,10 +282,9 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires), 'date': LazyDate(order.expires),
'event': order.event.name, 'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret
'hash': order.email_confirm_hash()
}), }),
'comment': comment, 'comment': comment,
'invoice_name': invoice_name, 'invoice_name': invoice_name,
@@ -382,10 +375,9 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
email_context = { email_context = {
'event': order.event.name, 'event': order.event.name,
'code': order.code, 'code': order.code,
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={ 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret
'hash': order.email_confirm_hash()
}) })
} }
with language(order.locale): with language(order.locale):
@@ -431,7 +423,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter() products_seen = Counter()
changed_prices = {} changed_prices = {}
deleted_positions = set() deleted_positions = set()
seats_seen = set()
def delete(cp): def delete(cp):
# Delete a cart position, including parents and children, if applicable # Delete a cart position, including parents and children, if applicable
@@ -494,32 +485,17 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp) delete(cp)
break break
if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen:
err = err or error_messages['seat_invalid']
delete(cp)
break
if cp.seat:
seats_seen.add(cp.seat)
if cp.item.require_voucher and cp.voucher is None: if cp.item.require_voucher and cp.voucher is None:
delete(cp) delete(cp)
err = err or error_messages['voucher_required'] err = err or error_messages['voucher_required']
break break
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)): if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
or cp.voucher.item.pk != cp.item.pk):
delete(cp) delete(cp)
cp.delete()
err = error_messages['voucher_required'] err = error_messages['voucher_required']
break break
if cp.seat:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely
# can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked:
err = err or error_messages['seat_unavailable']
cp.delete()
continue
if cp.expires >= now_dt and not cp.voucher: if cp.expires >= now_dt and not cp.voucher:
# Other checks are not necessary # Other checks are not necessary
continue continue
@@ -555,6 +531,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
continue continue
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross): if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
positions[i] = cp
cp.price = price.gross cp.price = price.gross
cp.includes_tax = bool(price.rate) cp.includes_tax = bool(price.rate)
cp.save() cp.save()
@@ -578,6 +555,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break break
if quota_ok: if quota_ok:
positions[i] = cp
cp.expires = now_dt + timedelta( cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int)) minutes=event.settings.get('reservation_time', as_type=int))
cp.save() cp.save()
@@ -592,13 +570,6 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
meta_info: dict, event: Event): meta_info: dict, event: Event):
fees = [] fees = []
total = sum([c.price for c in positions]) total = sum([c.price for c in positions])
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions):
if resp:
fees += resp
total += sum(f.value for f in fees)
if payment_provider: if payment_provider:
payment_fee = payment_provider.calculate_fee(total) payment_fee = payment_provider.calculate_fee(total)
else: else:
@@ -609,6 +580,9 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
internal_type=payment_provider.identifier) internal_type=payment_provider.identifier)
fees.append(pf) fees.append(pf)
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions):
fees += resp
return fees, pf return fees, pf
@@ -670,80 +644,10 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
return order, p return order, p
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str, def _perform_order(event: str, payment_provider: str, position_ids: List[str],
invoice, payment: OrderPayment):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
if pprov:
if 'payment' in inspect.signature(pprov.order_pending_mail_render).parameters:
payment_info = str(pprov.order_pending_mail_render(order, payment))
else:
payment_info = str(pprov.order_pending_mail_render(order))
else:
payment_info = None
email_context = {
'total': LazyNumber(order.total),
'currency': event.currency,
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
'date': LazyDate(order.expires),
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'payment_info': payment_info,
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order received email could not be sent')
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str):
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
email_context = {
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order.position', kwargs={
'order': order.code,
'secret': position.web_secret,
'position': position.positionid
}),
'attendee_name': position.attendee_name,
}
for f, l, w in name_scheme['fields']:
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[],
attach_tickets=True,
position=position
)
except SendMailException:
logger.exception('Order received email could not be sent to attendee')
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'): email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
event = Event.objects.get(id=event)
if payment_provider: if payment_provider:
pprov = event.get_payment_providers().get(payment_provider) pprov = event.get_payment_providers().get(payment_provider)
if not pprov: if not pprov:
@@ -757,35 +661,22 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
addr = None addr = None
if address is not None: if address is not None:
try: try:
with scopes_disabled():
addr = InvoiceAddress.objects.get(pk=address) addr = InvoiceAddress.objects.get(pk=address)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
positions = CartPosition.objects.annotate( positions = CartPosition.objects.filter(id__in=position_ids, event=event)
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True))
)
)
).filter(
id__in=position_ids, event=event
)
validate_order.send(event, payment_provider=pprov, email=email, positions=positions,
locale=locale, invoice_address=addr, meta_info=meta_info)
lockfn = NoLockManager lockfn = NoLockManager
locked = False locked = False
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists(): if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists():
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date, # Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event. # creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True locked = True
lockfn = event.lock lockfn = event.lock
with lockfn() as now_dt: with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')) positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
if len(positions) == 0: if len(positions) == 0:
raise OrderError(error_messages['empty']) raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions): if len(position_ids) != len(positions):
@@ -814,32 +705,54 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
if order.require_approval: if order.require_approval:
email_template = event.settings.mail_text_order_placed_require_approval email_template = event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval' log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_order_flow: elif free_order_flow:
email_template = event.settings.mail_text_order_free email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free' log_entry = 'pretix.event.order.email.order_free'
email_attendees = event.settings.mail_send_order_free_attendee
email_attendees_template = event.settings.mail_text_order_free_attendee
else: else:
email_template = event.settings.mail_text_order_placed email_template = event.settings.mail_text_order_placed
log_entry = 'pretix.event.order.email.order_placed' log_entry = 'pretix.event.order.email.order_placed'
email_attendees = event.settings.mail_send_order_placed_attendee try:
email_attendees_template = event.settings.mail_text_order_placed_attendee invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment) if pprov:
if email_attendees: payment_info = str(pprov.order_pending_mail_render(order))
for p in order.positions.all(): else:
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: payment_info = None
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry)
email_context = {
'total': LazyNumber(order.total),
'currency': event.currency,
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
'date': LazyDate(order.expires),
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'payment_info': payment_info,
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order received email could not be sent')
return order.id return order.id
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def expire_orders(sender, **kwargs): def expire_orders(sender, **kwargs):
eventcache = {} eventcache = {}
@@ -854,7 +767,6 @@ def expire_orders(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def send_expiry_warnings(sender, **kwargs): def send_expiry_warnings(sender, **kwargs):
eventcache = {} eventcache = {}
today = now().replace(hour=0, minute=0, second=0) today = now().replace(hour=0, minute=0, second=0)
@@ -888,10 +800,9 @@ def send_expiry_warnings(sender, **kwargs):
email_template = eventsettings.mail_text_order_expire_warning email_template = eventsettings.mail_text_order_expire_warning
email_context = { email_context = {
'event': o.event.name, 'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={ 'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code, 'order': o.code,
'secret': o.secret, 'secret': o.secret
'hash': o.email_confirm_hash()
}), }),
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'), 'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
'invoice_name': invoice_name, 'invoice_name': invoice_name,
@@ -912,7 +823,6 @@ def send_expiry_warnings(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def send_download_reminders(sender, **kwargs): def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0) today = now().replace(hour=0, minute=0, second=0, microsecond=0)
@@ -941,10 +851,9 @@ def send_download_reminders(sender, **kwargs):
email_template = e.settings.mail_text_download_reminder email_template = e.settings.mail_text_download_reminder
email_context = { email_context = {
'event': o.event.name, 'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={ 'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code, 'order': o.code,
'secret': o.secret, 'secret': o.secret
'hash': o.email_confirm_hash()
}), }),
} }
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code} email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
@@ -957,60 +866,6 @@ def send_download_reminders(sender, **kwargs):
except SendMailException: except SendMailException:
logger.exception('Reminder email could not be sent') logger.exception('Reminder email could not be sent')
if e.settings.mail_send_download_reminder_attendee:
name_scheme = PERSON_NAME_SCHEMES[e.settings.name_scheme]
for p in o.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = e.settings.mail_text_download_reminder_attendee
email_context = {
'event': e.name,
'url': build_absolute_uri(e, 'presale:event.order.position', kwargs={
'order': o.code,
'secret': p.web_secret,
'position': p.positionid
}),
'attendee_name': p.attendee_name,
}
for f, l, w in name_scheme['fields']:
email_context['attendee_name_%s' % f] = p.attendee_name_parts.get(f, '')
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
except SendMailException:
logger.exception('Reminder email could not be sent to attendee')
def notify_user_changed_order(order, user=None, auth=None):
with language(order.locale):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = order.event.settings.mail_text_order_changed
email_context = {
'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', user, auth=auth
)
except SendMailException:
logger.exception('Order changed email could not be sent')
class OrderChangeManager: class OrderChangeManager:
error_messages = { error_messages = {
@@ -1025,17 +880,12 @@ class OrderChangeManager:
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'), 'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'), 'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'), 'subevent_required': _('You need to choose a subevent for the new position.'),
'seat_unavailable': _('The selected seat "{seat}" is not available.'),
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
'seat_required': _('The selected product requires you to select a seat.'),
'seat_forbidden': _('The selected product does not allow to select a seat.'),
} }
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation')) ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price')) PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
CancelOperation = namedtuple('CancelOperation', ('position',)) CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat')) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',)) RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
@@ -1048,7 +898,6 @@ class OrderChangeManager:
self._committed = False self._committed = False
self._totaldiff = 0 self._totaldiff = 0
self._quotadiff = Counter() self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = [] self._operations = []
self.notify = notify self.notify = notify
self._invoice_dirty = False self._invoice_dirty = False
@@ -1066,13 +915,6 @@ class OrderChangeManager:
self._quotadiff.subtract(position.quotas) self._quotadiff.subtract(position.quotas)
self._operations.append(self.ItemOperation(position, item, variation)) self._operations.append(self.ItemOperation(position, item, variation))
def change_seat(self, position: OrderPosition, seat: Seat):
if position.seat:
self._seatdiff.subtract([position.seat])
if seat:
self._seatdiff.update([seat])
self._operations.append(self.SeatOperation(position, seat))
def change_subevent(self, position: OrderPosition, subevent: SubEvent): def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent, price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address) invoice_address=self._invoice_address)
@@ -1128,14 +970,12 @@ class OrderChangeManager:
self._totaldiff += -position.price self._totaldiff += -position.price
self._quotadiff.subtract(position.quotas) self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position)) self._operations.append(self.CancelOperation(position))
if position.seat:
self._seatdiff.subtract([position.seat])
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None, seat: Seat = None): subevent: SubEvent = None):
if price is None: if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else: else:
@@ -1154,14 +994,6 @@ class OrderChangeManager:
if self.order.event.has_subevents and not subevent: if self.order.event.has_subevents and not subevent:
raise OrderError(self.error_messages['subevent_required']) raise OrderError(self.error_messages['subevent_required'])
seated = item.seat_category_mappings.filter(subevent=subevent).exists()
if seated and not seat:
raise OrderError(self.error_messages['seat_required'])
elif not seated and seat:
raise OrderError(self.error_messages['seat_forbidden'])
if seat and subevent and seat.subevent_id != subevent:
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name))
new_quotas = (variation.quotas.filter(subevent=subevent) new_quotas = (variation.quotas.filter(subevent=subevent)
if variation else item.quotas.filter(subevent=subevent)) if variation else item.quotas.filter(subevent=subevent))
if not new_quotas: if not new_quotas:
@@ -1172,9 +1004,7 @@ class OrderChangeManager:
self._totaldiff += price.gross self._totaldiff += price.gross
self._quotadiff.update(new_quotas) self._quotadiff.update(new_quotas)
if seat: self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent))
self._seatdiff.update([seat])
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat))
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1182,26 +1012,6 @@ class OrderChangeManager:
self._operations.append(self.SplitOperation(position)) self._operations.append(self.SplitOperation(position))
def _check_seats(self):
for seat, diff in self._seatdiff.items():
if diff <= 0:
continue
if not seat.is_available() or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents:
state = {}
for p in self.order.positions.all():
state[p] = {'seat': p.seat, 'subevent': p.subevent}
for op in self._operations:
if isinstance(op, self.SeatOperation):
state[op.position]['seat'] = op.seat
elif isinstance(op, self.SubeventOperation):
state[op.position]['subevent'] = op.subevent
for v in state.values():
if v['seat'] and v['seat'].subevent_id != v['subevent'].pk:
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name))
def _check_quotas(self): def _check_quotas(self):
for quota, diff in self._quotadiff.items(): for quota, diff in self._quotadiff.items():
if diff <= 0: if diff <= 0:
@@ -1268,7 +1078,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['paid_to_free_exceeded']) raise OrderError(self.error_messages['paid_to_free_exceeded'])
def _perform_operations(self): def _perform_operations(self):
nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1 nextposid = self.order.positions.aggregate(m=Max('positionid'))['m'] + 1
split_positions = [] split_positions = []
for op in self._operations: for op in self._operations:
@@ -1288,17 +1098,6 @@ class OrderChangeManager:
op.position.variation = op.variation op.position.variation = op.variation
op.position._calculate_tax() op.position._calculate_tax()
op.position.save() op.position.save()
elif isinstance(op, self.SeatOperation):
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_seat': op.position.seat.name if op.position.seat else "-",
'new_seat': op.seat.name if op.seat else "-",
'old_seat_id': op.position.seat.pk if op.position.seat else None,
'new_seat_id': op.seat.pk if op.seat else None,
})
op.position.seat = op.seat
op.position.save()
elif isinstance(op, self.SubeventOperation): elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
'position': op.position.pk, 'position': op.position.pk,
@@ -1352,7 +1151,7 @@ class OrderChangeManager:
item=op.item, variation=op.variation, addon_to=op.addon_to, item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule, tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat positionid=nextposid, subevent=op.subevent
) )
nextposid += 1 nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -1363,7 +1162,6 @@ class OrderChangeManager:
'price': op.price.gross, 'price': op.price.gross,
'positionid': pos.positionid, 'positionid': pos.positionid,
'subevent': op.subevent.pk if op.subevent else None, 'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
}) })
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
split_positions.append(op.position) split_positions.append(op.position)
@@ -1414,14 +1212,6 @@ class OrderChangeManager:
pass pass
split_order.total = sum([p.price for p in split_positions if not p.canceled]) split_order.total = sum([p.price for p in split_positions if not p.canceled])
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
split_order.total += new_fee.value
new_fee.save()
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider() pp = self._get_payment_provider()
if pp: if pp:
@@ -1437,6 +1227,13 @@ class OrderChangeManager:
fee.delete() fee.delete()
split_order.total += fee.value split_order.total += fee.value
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
split_order.total += new_fee.value
new_fee.save()
split_order.save() split_order.save()
if split_order.status == Order.STATUS_PAID: if split_order.status == Order.STATUS_PAID:
@@ -1457,8 +1254,6 @@ class OrderChangeManager:
if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last(): if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last():
generate_invoice(split_order) generate_invoice(split_order)
order_split.send(sender=self.order.event, original=self.order, split_order=split_order)
return split_order return split_order
@cached_property @cached_property
@@ -1544,6 +1339,33 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
return None return None
def _notify_user(self, order):
with language(order.locale):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = order.event.settings.mail_text_order_changed
email_context = {
'event': order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', self.user, auth=self.auth
)
except SendMailException:
logger.exception('Order changed email could not be sent')
def commit(self, check_quotas=True): def commit(self, check_quotas=True):
if self._committed: if self._committed:
# an order change can only be committed once # an order change can only be committed once
@@ -1563,7 +1385,6 @@ class OrderChangeManager:
raise OrderError(self.error_messages['not_pending_or_paid']) raise OrderError(self.error_messages['not_pending_or_paid'])
if check_quotas: if check_quotas:
self._check_quotas() self._check_quotas()
self._check_seats()
self._check_complete_cancel() self._check_complete_cancel()
self._perform_operations() self._perform_operations()
self._recalculate_total_and_payment_fee() self._recalculate_total_and_payment_fee()
@@ -1574,9 +1395,9 @@ class OrderChangeManager:
self._check_paid_to_free() self._check_paid_to_free()
if self.notify: if self.notify:
notify_user_changed_order(self.order, self.user, self.auth) self._notify_user(self.order)
if self.split_order: if self.split_order:
notify_user_changed_order(self.split_order, self.user, self.auth) self._notify_user(self.split_order)
order_changed.send(self.order.event, order=self.order) order_changed.send(self.order.event, order=self.order)
@@ -1597,8 +1418,8 @@ class OrderChangeManager:
return pprov return pprov
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: Event, payment_provider: str, positions: List[str], def perform_order(self, event: str, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None, email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web'): sales_channel: str='web'):
with language(locale): with language(locale):
@@ -1613,7 +1434,6 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
@scopes_disabled()
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None, cancellation_fee=None, try_auto_refund=False): device=None, cancellation_fee=None, try_auto_refund=False):
try: try:

View File

@@ -1,12 +1,11 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.db import models
from django.db.models import Max, Q from django.db.models import F, Max, OuterRef, Q, Subquery
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Event, LogEntry from pretix.base.models import LogEntry, Quota
from pretix.celery_app import app from pretix.celery_app import app
from ..signals import periodic_task from ..signals import periodic_task
@@ -18,27 +17,20 @@ def build_all_quota_caches(sender, **kwargs):
@app.task @app.task
@scopes_disabled()
def refresh_quota_caches(): def refresh_quota_caches():
# Active events last_activity = LogEntry.objects.filter(
active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter( event=OuterRef('event_id'),
datetime__gt=now() - timedelta(days=7)
).order_by().values('event').annotate( ).order_by().values('event').annotate(
last_activity=Max('datetime') m=Max('datetime')
).values(
'm'
) )
for a in active: quotas = Quota.objects.annotate(
try: last_activity=Subquery(last_activity, output_field=models.DateTimeField())
e = Event.objects.using(settings.DATABASE_REPLICA).get(pk=a['event'])
except Event.DoesNotExist:
continue
quotas = e.quotas.filter(
Q(cached_availability_time__isnull=True) |
Q(cached_availability_time__lt=a['last_activity']) |
Q(cached_availability_time__lt=now() - timedelta(hours=2))
).filter( ).filter(
Q(subevent__isnull=True) | Q(cached_availability_time__isnull=True) |
Q(subevent__date_to__isnull=False, subevent__date_to__gte=now() - timedelta(days=14)) | Q(cached_availability_time__lt=F('last_activity')) |
Q(subevent__date_from__gte=now() - timedelta(days=14)) Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
) ).select_related('subevent')
for q in quotas: for q in quotas:
q.availability() q.availability()

View File

@@ -1,73 +0,0 @@
from django.db.models import Count
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CartPosition, Seat
class SeatProtected(Exception):
pass
def validate_plan_change(event, subevent, plan):
current_taken_seats = set(
event.seats.select_related('product')
.annotate(has_op=Count('orderposition'))
.filter(subevent=subevent, has_op=True)
.values_list('seat_guid', flat=True)
)
new_seats = {
ss.guid for ss in plan.iter_all_seats()
} if plan else set()
leftovers = list(current_taken_seats - new_seats)
if leftovers:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(leftovers[0]))
def generate_seats(event, subevent, plan, mapping):
current_seats = {
s.seat_guid: s for s in
event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent)
}
def update(o, a, v):
if getattr(o, a) != v:
setattr(o, a, v)
return True
return False
create_seats = []
if plan:
for ss in plan.iter_all_seats():
p = mapping.get(ss.category)
if ss.guid in current_seats:
seat = current_seats.pop(ss.guid)
updated = any([
update(seat, 'product', p),
update(seat, 'name', ss.name),
update(seat, 'row_name', ss.row),
update(seat, 'seat_number', ss.number),
update(seat, 'zone_name', ss.zone),
])
if updated:
seat.save()
else:
create_seats.append(Seat(
event=event,
subevent=subevent,
seat_guid=ss.guid,
name=ss.name,
row_name=ss.row,
seat_number=ss.number,
zone_name=ss.zone,
product=p,
))
for s in current_seats.values():
if s.has_op:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(s.name))
Seat.objects.bulk_create(create_seats)
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()

View File

@@ -11,13 +11,14 @@ from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CachedFile, Event, cachedfile_name from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledTask
from pretix.base.shredder import ShredError from pretix.base.shredder import ShredError
from pretix.celery_app import app from pretix.celery_app import app
@app.task(base=ProfiledEventTask) @app.task(base=ProfiledTask)
def export(event: Event, shredders: List[str]) -> None: def export(event: str, shredders: List[str]) -> None:
event = Event.objects.get(id=event)
known_shredders = event.get_data_shredders() known_shredders = event.get_data_shredders()
with NamedTemporaryFile() as rawfile: with NamedTemporaryFile() as rawfile:
@@ -62,8 +63,9 @@ def export(event: Event, shredders: List[str]) -> None:
return cf.pk return cf.pk
@app.task(base=ProfiledEventTask, throws=(ShredError,)) @app.task(base=ProfiledTask, throws=(ShredError,))
def shred(event: Event, fileid: str, confirm_code: str) -> None: def shred(event: str, fileid: str, confirm_code: str) -> None:
event = Event.objects.get(id=event)
known_shredders = event.get_data_shredders() known_shredders = event.get_data_shredders()
try: try:
cf = CachedFile.objects.get(pk=fileid) cf = CachedFile.objects.get(pk=fileid)

View File

@@ -1,16 +1,12 @@
from datetime import date, datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from django.db.models import ( from django.db.models import Case, Count, F, Sum, Value, When
Case, Count, DateTimeField, F, Max, OuterRef, Subquery, Sum, Value, When,
)
from django.utils.timezone import make_aware
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee, OrderPayment from pretix.base.models.orders import OrderFee
from pretix.base.signals import order_fee_type_name from pretix.base.signals import order_fee_type_name
@@ -75,9 +71,8 @@ def dictsum(*dicts) -> dict:
return res return res
def order_overview( def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[ItemCategory, List[Item]]],
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None Dict[str, Tuple[Decimal, Decimal]]]:
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related( items = event.items.all().select_related(
'category', # for re-grouping 'category', # for re-grouping
).prefetch_related( ).prefetch_related(
@@ -87,38 +82,6 @@ def order_overview(
qs = OrderPosition.all qs = OrderPosition.all
if subevent: if subevent:
qs = qs.filter(subevent=subevent) qs = qs.filter(subevent=subevent)
if date_from and isinstance(date_from, date):
date_from = make_aware(datetime.combine(
date_from,
time(hour=0, minute=0, second=0, microsecond=0)
), event.timezone)
if date_until and isinstance(date_until, date):
date_until = make_aware(datetime.combine(
date_until + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), event.timezone)
if date_filter == 'order_date':
if date_from:
qs = qs.filter(order__datetime__gte=date_from)
if date_until:
qs = qs.filter(order__datetime__lt=date_until)
elif date_filter == 'last_payment_date':
p_date = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED],
payment_date__isnull=False
).values('order').annotate(
m=Max('payment_date')
).values('m').order_by()
qs = qs.annotate(payment_date=Subquery(p_date, output_field=DateTimeField()))
if date_from:
qs = qs.filter(payment_date__gte=date_from)
if date_until:
qs = qs.filter(payment_date__lt=date_until)
counters = qs.filter( counters = qs.filter(
order__event=event order__event=event
).annotate( ).annotate(
@@ -190,26 +153,14 @@ def order_overview(
payment_items = [] payment_items = []
if not subevent: if not subevent:
qs = OrderFee.all.filter( counters = OrderFee.all.filter(
order__event=event order__event=event
).annotate( ).annotate(
status=Case( status=Case(
When(canceled=True, then=Value('c')), When(canceled=True, then=Value('c')),
default=F('order__status') default=F('order__status')
) )
) ).values(
if date_filter == 'order_date':
if date_from:
qs = qs.filter(order__datetime__gte=date_from)
if date_until:
qs = qs.filter(order__datetime__lt=date_until)
elif date_filter == 'last_payment_date':
qs = qs.annotate(payment_date=Subquery(p_date, output_field=DateTimeField()))
if date_from:
qs = qs.filter(payment_date__gte=date_from)
if date_until:
qs = qs.filter(payment_date__lt=date_until)
counters = qs.values(
'fee_type', 'internal_type', 'status' 'fee_type', 'internal_type', 'status'
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by() ).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()

View File

@@ -14,12 +14,10 @@ import time
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django_scopes import scope, scopes_disabled
from pretix.base.metrics import ( from pretix.base.metrics import (
pretix_task_duration_seconds, pretix_task_runs_total, pretix_task_duration_seconds, pretix_task_runs_total,
) )
from pretix.base.models import Event
from pretix.celery_app import app from pretix.celery_app import app
@@ -63,35 +61,6 @@ class ProfiledTask(app.Task):
return super().on_success(retval, task_id, args, kwargs) return super().on_success(retval, task_id, args, kwargs)
class EventTask(app.Task):
def __call__(self, *args, **kwargs):
if 'event_id' in kwargs:
event_id = kwargs.get('event_id')
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
del kwargs['event_id']
kwargs['event'] = event
elif 'event' in kwargs:
event_id = kwargs.get('event')
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
kwargs['event'] = event
else:
args = list(args)
event_id = args[0]
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
args[0] = event
with scope(organizer=event.organizer):
ret = super().__call__(*args, **kwargs)
return ret
class ProfiledEventTask(ProfiledTask, EventTask):
pass
class TransactionAwareTask(ProfiledTask): class TransactionAwareTask(ProfiledTask):
""" """
Task class which is aware of django db transactions and only executes tasks Task class which is aware of django db transactions and only executes tasks

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