Compare commits

..

53 Commits

Author SHA1 Message Date
Raphael Michel
8bbe605439 Fix voucher bulk form 2019-07-07 12:42:38 +02:00
Raphael Michel
8267da66e4 Add test 2019-07-07 12:25:08 +02:00
Raphael Michel
481db3cb88 Backwards-compatible implementation 2019-07-07 12:20:41 +02:00
Raphael Michel
435d90474a Changes in checks 2019-07-06 15:21:32 +02:00
Raphael Michel
fcc4170a4a Add shell_scoped command. Thanks @rixx! 2019-06-27 11:39:12 +02:00
Raphael Michel
c7f345e98e Allow to filter order list by variations 2019-06-26 14:27:02 +02:00
Raphael Michel
d30fbf4e6a Event front page: Show calendar by default when a month is selected 2019-06-25 13:02:38 +02:00
Raphael Michel
5326aa7486 Merge pull request #1325 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-06-25 11:33:19 +02:00
Raphael Michel
53147c0f0c Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3152 of 3152 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2019-06-25 09:32:44 +00:00
Raphael Michel
fe31318413 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3152 of 3152 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2019-06-25 09:32:43 +00:00
Raphael Michel
bb4821eeb5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-25 11:08:47 +02:00
Raphael Michel
003d958cc5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-25 11:04:17 +02:00
Raphael Michel
93089d87e3 Add support for reserved seating (#1228)
* Initial work on seating

* Add seat guids

* Add product_list_top

* CartAdd: Ignore item when a seat is passed

* Cart display

* product_list_top → render_seating_plan

* Render seating plan in voucher redemption

* Fix failing tests

* Add tests for extending cart positions with seats

* Add subevent_forms to docs

* Update schema, migrations

* Dealing with expired orders

* steps to order change

* Change order positions

* Allow to add seats

* tests for ocm

* Fix things after rebase

* Seating plans API

* Add more tests for cart behaviour

* Widget support

* Adjust widget tests

* Re-enable CSP

* Update schema

* Api: position.seat

* Add guid to word list

* API: (sub)event.seating_plan

* Vali fixes

* Fix api

* Fix reference in test

* Fix test for real
2019-06-25 11:00:03 +02:00
Raphael Michel
f79d17cb6a Navigation: Only show orders/vouchers with a search query 2019-06-24 11:41:36 +02:00
Raphael Michel
0e8db3181c OrderChangeManager: Allow to add positions to empty orders 2019-06-21 14:33:10 +02:00
Raphael Michel
23031642bd Fix crash when re-using logged emails
Fix PRETIXEU-16Q
2019-06-21 12:01:51 +02:00
Raphael Michel
93cca34eab PayPal: Add scopes decorator to oauth_return 2019-06-20 19:29:23 +02:00
Raphael Michel
e29c8a1708 Stripe: disable scopes for oauth return 2019-06-20 13:57:15 +02:00
Raphael Michel
acfec59abc Fix ineffective permission check in typeahead 2019-06-19 09:32:30 +02:00
Raphael Michel
7adf203863 Make order search search in used voucher codes 2019-06-19 09:17:46 +02:00
Raphael Michel
3c2de09216 Integrate orders and vouchers into navigation typeahead 2019-06-19 09:16:33 +02:00
Raphael Michel
26a96f107f Add signal quota_availability 2019-06-18 16:52:01 +02:00
Raphael Michel
819dd7eee6 Correctly show infinite quotas in backend 2019-06-18 16:29:36 +02:00
Raphael Michel
ccc662228c Force evaluation of template responses in frontend 2019-06-17 22:59:45 +02:00
Raphael Michel
99a2fde373 Voucher form: Move product above price mode 2019-06-17 22:56:51 +02:00
Raphael Michel
dda48d92c6 Update from Weblate (#1317)
Update from Weblate
2019-06-17 17:08:44 +02:00
Martin Gross
0a1429ed60 Add setting for enforcing 2FA (#1259)
* Add setting for enforcing 2FA

* Changes after code-review

* Add Test-Cases for Obligatory 2FA
2019-06-17 17:08:27 +02:00
Mattias Axell
8487a5446d Translated on translate.pretix.eu (Swedish)
Currently translated at 100.0% (99 of 99 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/sv/

powered by weblate
2019-06-17 12:57:56 +00:00
Vlad
64833c0bab Translated on translate.pretix.eu (Russian)
Currently translated at 63.6% (63 of 99 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/ru/

powered by weblate
2019-06-17 12:57:56 +00:00
Vlad
3f40525af5 Translated on translate.pretix.eu (Russian)
Currently translated at 0.6% (19 of 3125 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ru/

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
7b6b3b1348 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (99 of 99 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl_Informal/

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
0b9f4cd739 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (99 of 99 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl/

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
885eefbcb0 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3125 of 3125 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_Informal/

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
573757e2bf Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3125 of 3125 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2019-06-17 12:57:56 +00:00
Raphael Michel
c5a2bd35b7 Devices list: Correctly use revoked parameter 2019-06-17 14:41:23 +02:00
Raphael Michel
4b65b94bd5 Disable scopes for all unique ID generation 2019-06-17 14:05:05 +02:00
Raphael Michel
d716f7e014 Fix scoping issue in mail_send_task 2019-06-17 11:07:05 +02:00
Raphael Michel
d85ddb5bda Integrate django-scopes (#1319)
* Install django-scopes

* Fix tests.api

* Update tasks and cronjobs

* Fix remaining tests

* Remove unused import

* Fix tests after rebase

* Disable scopes for get_Events_with_any_permission

* Disable scopes for a management command
2019-06-17 10:46:55 +02:00
Martin Gross
b1db5dbb3e Decrement voucher usage counter when deleting testmode orders (#1321)
* Decrement voucher usage counter when deleting testmode orders

* Only decrement voucher usage counter for uncancelled orders and on uncancelled positions

* Have the tests actually test something
2019-06-14 12:41:07 +02:00
Raphael Michel
fed389b990 Remove plugins task from travis 2019-06-14 12:21:34 +02:00
Raphael Michel
1ce613ff89 Add new signal nav_item 2019-06-14 12:20:27 +02:00
Raphael Michel
44bef85b66 Require recent django-localflavor 2019-06-12 12:49:22 +02:00
Raphael Michel
49c4acefd0 Fix critical error in previous commit 2019-06-10 17:18:08 +02:00
Raphael Michel
61e111742d Avoid unneccesary logs in some highly-used API endpoints 2019-06-09 23:54:48 +02:00
Raphael Michel
b2274039b3 Sendmail: Fix using old log entries 2019-06-06 11:40:21 +02:00
Raphael Michel
4913190730 Update from Weblate (#1312)
Update from Weblate
2019-06-06 11:25:58 +02:00
Raphael Michel
276a087fdb Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3125 of 3125 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2019-06-06 09:24:50 +00:00
Raphael Michel
3639f2cea1 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3125 of 3125 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2019-06-06 09:24:49 +00:00
Raphael Michel
692e9c38f1 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-06 11:11:46 +02:00
Raphael Michel
dd4075b2cc Clarify UX around subevent selection 2019-06-06 11:10:51 +02:00
Raphael Michel
b549cb451a Fix invalid signature 2019-06-05 16:44:49 +02:00
Raphael Michel
576132b2d0 Bump to 2.9.0.dev0 2019-06-05 16:28:49 +02:00
Raphael Michel
e0c432d014 [SECURITY] Do not allow to enumerate organizers 2019-06-05 16:27:21 +02:00
250 changed files with 39673 additions and 30228 deletions

View File

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

View File

@@ -78,6 +78,10 @@ Example::
Enables or disables nagging staff users for leaving comments on their sessions for auditability.
Defaults to ``off``.
``obligatory_2fa``
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
Defaults to ``False``
Locale settings
---------------

View File

@@ -36,12 +36,20 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field
├ 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
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
This resource has been added.
.. versionchanged:: 3.0
This ``seat`` attribute has been added.
Cart position endpoints
-----------------------
@@ -87,6 +95,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"seat": null,
"answers": []
}
]
@@ -132,6 +141,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"seat": null,
"answers": []
}
@@ -178,6 +188,7 @@ Cart position endpoints
* ``item``
* ``variation`` (optional)
* ``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_email`` (optional)
* ``subevent`` (optional)

View File

@@ -396,6 +396,7 @@ Order position endpoints
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 1,
@@ -505,6 +506,7 @@ Order position endpoints
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 1,

View File

@@ -27,9 +27,13 @@ presale_end datetime The date at whi
location multi-lingual string The event location (or ``null``)
has_subevents boolean ``true`` if the event series feature is active for this
event. Cannot change after event is created.
meta_data dict Values set for organizer-specific meta data parameters.
meta_data object Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this
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,6 +58,10 @@ plugins list A list of packa
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
---------
@@ -99,6 +107,8 @@ Endpoints
"location": null,
"has_subevents": false,
"meta_data": {},
"seating_plan": null,
"seat_category_mapping": {},
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
@@ -160,6 +170,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer"
@@ -205,6 +217,8 @@ Endpoints
"is_public": false,
"presale_start": null,
"presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null,
"has_subevents": false,
"meta_data": {},
@@ -235,6 +249,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"plugins": [
@@ -284,6 +300,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"plugins": [
@@ -314,6 +332,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
@@ -375,6 +395,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer",

View File

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

View File

@@ -176,6 +176,10 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field
├ 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
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,
this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request.
@@ -197,6 +201,10 @@ pdf_data object Data object req
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
@@ -328,6 +336,7 @@ List of all orders
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,
@@ -470,6 +479,7 @@ Fetching individual orders
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,
@@ -737,7 +747,7 @@ Creating orders
then call the ``mark_paid`` API method.
* ``testmode`` (optional) Defaults to ``false``
* ``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 that become free by this operation will be credited to your order
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
creation.
* ``email``
* ``locale``
@@ -771,6 +781,7 @@ Creating orders
* ``item``
* ``variation``
* ``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_email``
* ``secret`` (optional)
@@ -1287,6 +1298,7 @@ List of all order positions
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null,
"subevent": null,
"checkins": [
@@ -1389,6 +1401,7 @@ Fetching individual positions
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,

View File

@@ -0,0 +1,209 @@
.. _`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: 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,7 +36,11 @@ variation_price_overrides list of objects List of variati
the default price
├ variation integer The internal variation ID
└ price money (string) The price or ``null`` for the default price
meta_data dict Values set for organizer-specific meta data parameters.
meta_data object 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
@@ -54,6 +58,10 @@ meta_data dict Values set for
The attribute ``is_public`` has been added.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints
---------
@@ -93,6 +101,8 @@ Endpoints
"date_admission": null,
"presale_start": null,
"presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null,
"item_price_overrides": [
{
@@ -142,6 +152,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -172,6 +184,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -223,6 +237,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -287,6 +303,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -371,6 +389,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,

View File

@@ -41,6 +41,7 @@ quota integer An ID of a quot
tag string A string that is used for grouping vouchers
comment string An internal comment on the voucher
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``.
===================================== ========================== =======================================================
@@ -48,6 +49,10 @@ subevent integer ID of the date
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attribute ``show_hidden_items`` has been added.
Endpoints
---------

View File

@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels, register_global_settings
item_copy_data, register_sales_channels, register_global_settings, quota_availability
Order events
""""""""""""
@@ -26,7 +26,11 @@ Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, checkout_flow_steps, order_info, order_meta_from_request, 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, render_seating_plan, checkout_flow_steps, position_info
.. automodule:: pretix.presale.signals
:members: order_info, order_meta_from_request
Request flow
""""""""""""
@@ -45,7 +49,7 @@ Backend
.. automodule:: pretix.control.signals
: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
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, nav_item, subevent_forms
.. automodule:: pretix.base.signals

View File

@@ -40,6 +40,7 @@ frontend
frontpage
gettext
gunicorn
guid
hardcoded
hostname
idempotency

View File

@@ -1 +1 @@
__version__ = "2.8.1"
__version__ = "2.9.0.dev0"

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.models import AnonymousUser
from django_scopes import scopes_disabled
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@@ -12,7 +13,8 @@ class DeviceTokenAuthentication(TokenAuthentication):
def authenticate_credentials(self, key):
model = self.get_model()
try:
device = model.objects.select_related('organizer').get(api_token=key)
with scopes_disabled():
device = model.objects.select_related('organizer').get(api_token=key)
except model.DoesNotExist:
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.base.models import Device, Event, User
from pretix.base.models.auth import SuperuserPermissionSet
from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
)
@@ -50,9 +50,6 @@ class EventPermission(BasePermission):
return False
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):
return False
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):

View File

@@ -4,10 +4,13 @@ from hashlib import sha1
from django.conf import settings
from django.db import transaction
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import resolve
from django.utils.timezone import now
from django_scopes import scope
from rest_framework import status
from pretix.api.models import ApiCall
from pretix.base.models import Organizer
class IdempotencyMiddleware:
@@ -89,3 +92,21 @@ class IdempotencyMiddleware:
for k, v in json.loads(call.response_headers).values():
r[k] = v
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,31 +8,33 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer,
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
from pretix.base.models import Quota
from pretix.base.models import Quota, Seat
from pretix.base.models.orders import CartPosition
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
class Meta:
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers',)
'answers', 'seat')
class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers',)
'subevent', 'expires', 'includes_tax', 'answers', 'seat')
def create(self, validated_data):
answers_data = validated_data.pop('answers')
@@ -71,6 +73,22 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
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)
for answ_data in answers_data:

View File

@@ -11,6 +11,9 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event, TaxRule
from pretix.base.models.event import SubEvent
from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
class MetaDataField(Field):
@@ -26,6 +29,22 @@ 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):
def to_representation(self, obj):
@@ -45,12 +64,14 @@ class PluginsField(Field):
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
'presale_end', 'location', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping')
def validate(self, data):
data = super().validate(data)
@@ -61,6 +82,9 @@ class EventSerializer(I18nAwareModelSerializer):
Event.clean_dates(data.get('date_from'), data.get('date_to'))
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
def validate_has_subevents(self, value):
@@ -92,6 +116,27 @@ class EventSerializer(I18nAwareModelSerializer):
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
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 (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):
from pretix.base.plugins import get_all_plugins
@@ -109,6 +154,7 @@ class EventSerializer(I18nAwareModelSerializer):
@transaction.atomic
def create(self, validated_data):
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(','))
event = super().create(validated_data)
@@ -120,6 +166,10 @@ class EventSerializer(I18nAwareModelSerializer):
value=value
)
# Seats
if event.seating_plan:
generate_seats(event, None, event.seating_plan, {})
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
@@ -131,6 +181,7 @@ class EventSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
event = super().update(instance, validated_data)
# Meta data
@@ -151,6 +202,29 @@ class EventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data:
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
if plugins is not None:
event.set_active_plugins(plugins)
@@ -196,14 +270,15 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
event = SlugRelatedField(slug_field='slug', read_only=True)
meta_data = MetaDataField(source='*')
class Meta:
model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'event', 'is_public',
'item_price_overrides', 'variation_price_overrides', 'meta_data')
'presale_start', 'presale_end', 'location', 'event', 'is_public', 'seating_plan',
'item_price_overrides', 'variation_price_overrides', 'meta_data', 'seat_category_mapping')
def validate(self, data):
data = super().validate(data)
@@ -225,6 +300,25 @@ class SubEventSerializer(I18nAwareModelSerializer):
def validate_variation_price_overrides(self, 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
def meta_properties(self):
return {
@@ -242,6 +336,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().create(validated_data)
for item_price_override_data in item_price_overrides_data:
@@ -257,6 +352,18 @@ class SubEventSerializer(I18nAwareModelSerializer):
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
@transaction.atomic
@@ -264,6 +371,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().update(instance, validated_data)
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
@@ -300,6 +408,31 @@ class SubEventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data:
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

View File

@@ -1,5 +1,4 @@
import json
from collections import Counter
from decimal import Decimal
from django.utils.timezone import now
@@ -15,7 +14,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, SubEvent,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -71,6 +70,13 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta:
model = Seat
fields = ('id', 'name', 'seat_guid')
class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -166,12 +172,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -430,11 +437,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers')
'secret', 'addon_to', 'subevent', 'answers', 'seat')
def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -615,8 +623,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
ia = None
with self.context['event'].lock() as now_dt:
quotadiff = Counter()
free_seats = set()
seats_seen = set()
consume_carts = validated_data.pop('consume_carts', [])
delete_cps = []
quota_avail_cache = {}
@@ -630,7 +638,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1
if cp.expires > now_dt:
quotadiff.subtract(quotas)
if cp.seat:
free_seats.add(cp.seat)
delete_cps.append(cp)
errs = [{} for p in positions_data]
@@ -658,7 +667,22 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
)
]
quotadiff.update(new_quotas)
for i, pos_data in enumerate(positions_data):
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):
raise ValidationError({'positions': errs})

View File

@@ -1,8 +1,20 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Organizer
from pretix.api.serializers.order import CompatibleJSONField
from pretix.base.models import Organizer, SeatingPlan
from pretix.base.models.seating import SeatingPlanLayoutValidator
class OrganizerSerializer(I18nAwareModelSerializer):
class Meta:
model = Organizer
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
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent')
'tag', 'comment', 'subevent', 'show_hidden_items')
read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.fields import DateTimeField
@@ -24,11 +25,11 @@ from pretix.base.services.checkin import (
)
from pretix.helpers.database import FixedOrderBy
class CheckinListFilter(FilterSet):
class Meta:
model = CheckinList
fields = ['subevent']
with scopes_disabled():
class CheckinListFilter(FilterSet):
class Meta:
model = CheckinList
fields = ['subevent']
class CheckinListViewSet(viewsets.ModelViewSet):
@@ -146,15 +147,16 @@ class CheckinListViewSet(viewsets.ModelViewSet):
return Response(response)
class CheckinOrderPositionFilter(OrderPositionFilter):
with scopes_disabled():
class CheckinOrderPositionFilter(OrderPositionFilter):
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value)
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.objects.none()
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid')
ordering_fields = (
@@ -229,7 +231,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
)
else:
qs = qs.prefetch_related(
@@ -239,7 +241,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order')
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))

View File

@@ -3,6 +3,7 @@ from django.db import transaction
from django.db.models import ProtectedError, Q
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
@@ -18,51 +19,51 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class EventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class EventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta:
model = Event
fields = ['is_public', 'live', 'has_subevents']
class Meta:
model = Event
fields = ['is_public', 'live', 'has_subevents']
def ends_after_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
def ends_after_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
)
)
)
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
def is_past_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
)
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class EventViewSet(viewsets.ModelViewSet):
@@ -85,7 +86,7 @@ class EventViewSet(viewsets.ModelViewSet):
)
return qs.prefetch_related(
'meta_values', 'meta_values__property'
'meta_values', 'meta_values__property', 'seat_category_mappings'
)
def perform_update(self, serializer):
@@ -182,41 +183,42 @@ class CloneEventViewSet(viewsets.ModelViewSet):
)
class SubEventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
with scopes_disabled():
class SubEventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta:
model = SubEvent
fields = ['active', 'event__live']
class Meta:
model = SubEvent
fields = ['active', 'event__live']
def ends_after_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
)
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
if value:
def ends_after_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
)
return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_past_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -240,12 +242,18 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
event__in=self.request.user.get_events_with_any_permission()
)
return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set'
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
)
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
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(
'pretix.subevent.changed',
user=self.request.user,

View File

@@ -3,6 +3,7 @@ from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -21,19 +22,19 @@ from pretix.base.models import (
)
from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
def tax_rate_qs(self, queryset, name, value):
if value in ("0", "None", "0.00"):
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
else:
return queryset.filter(tax_rule__rate=value)
def tax_rate_qs(self, queryset, name, value):
if value in ("0", "None", "0.00"):
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
else:
return queryset.filter(tax_rule__rate=value)
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -65,7 +66,14 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
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(
'pretix.event.item.changed',
user=self.request.user,
@@ -312,10 +320,11 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
with scopes_disabled():
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -411,10 +420,11 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuotaFilter(FilterSet):
class Meta:
model = Quota
fields = ['subevent']
with scopes_disabled():
class QuotaFilter(FilterSet):
class Meta:
model = Quota
fields = ['subevent']
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -452,9 +462,17 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
current_subevent = serializer.instance.subevent
serializer.save(event=self.request.event)
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
serializer.instance.log_action(
'pretix.event.quota.changed',
user=self.request.user,

View File

@@ -11,6 +11,7 @@ from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
@@ -50,17 +51,17 @@ from pretix.base.signals import (
)
from pretix.base.templatetags.money import money_filter
with scopes_disabled():
class OrderFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class OrderFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class OrderViewSet(viewsets.ModelViewSet):
@@ -92,8 +93,8 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
'item__category', 'addon_to', 'seat',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
)
)
)
@@ -102,7 +103,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
)
)
)
@@ -531,48 +532,49 @@ 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)
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
with scopes_disabled():
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value)
)
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value)
)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value)
def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
class Meta:
model = OrderPosition
fields = {
'item': ['exact', 'in'],
'variation': ['exact', 'in'],
'secret': ['exact'],
'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'],
'voucher__code': ['exact'],
'voucher': ['exact'],
}
class Meta:
model = OrderPosition
fields = {
'item': ['exact', 'in'],
'variation': ['exact', 'in'],
'secret': ['exact'],
'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'],
'voucher__code': ['exact'],
'voucher': ['exact'],
}
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none()
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
@@ -609,13 +611,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to'
'item', 'variation', 'item__category', 'addon_to', 'seat'
)
else:
qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question'
).select_related(
'item', 'order', 'order__event', 'order__event__organizer'
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
)
return qs
@@ -960,22 +962,23 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer.save()
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs')
number = django_filters.CharFilter(method='nr_qs')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
with scopes_disabled():
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs')
number = django_filters.CharFilter(method='nr_qs')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
def refers_qs(self, queryset, name, value):
return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value)
def refers_qs(self, queryset, name, value):
return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value)
def nr_qs(self, queryset, name, value):
return queryset.filter(nr__iexact=value)
def nr_qs(self, queryset, name, value):
return queryset.filter(nr__iexact=value)
class Meta:
model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
class Meta:
model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
class RetryException(APIException):

View File

@@ -1,8 +1,12 @@
from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import OrganizerSerializer
from pretix.base.models import Organizer
from pretix.api.serializers.organizer import (
OrganizerSerializer, SeatingPlanSerializer,
)
from pretix.base.models import Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -30,3 +34,50 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else:
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,6 +6,7 @@ from django.utils.timezone import now
from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet,
)
from django_scopes import scopes_disabled
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -15,22 +16,22 @@ from rest_framework.response import Response
from pretix.api.serializers.voucher import VoucherSerializer
from pretix.base.models import Voucher
with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
class Meta:
model = Voucher
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
class Meta:
model = Voucher
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
def filter_active(self, queryset, name, value):
if value:
return queryset.filter(Q(redeemed__lt=F('max_usages')) &
(Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
else:
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
def filter_active(self, queryset, name, value):
if value:
return queryset.filter(Q(redeemed__lt=F('max_usages')) &
(Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
else:
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
class VoucherViewSet(viewsets.ModelViewSet):

View File

@@ -1,5 +1,6 @@
import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -10,16 +11,16 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.models import WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException
with scopes_disabled():
class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
def has_voucher_qs(self, queryset, name, value):
return queryset.filter(voucher__isnull=not value)
def has_voucher_qs(self, queryset, name, value):
return queryset.filter(voucher__isnull=not value)
class Meta:
model = WaitingListEntry
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
class Meta:
model = WaitingListEntry
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
class WaitingListViewSet(viewsets.ModelViewSet):

View File

@@ -8,6 +8,7 @@ from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from django_scopes import scope, scopes_disabled
from requests import RequestException
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
@@ -203,51 +204,52 @@ def notify_webhooks(logentry_id: int):
@app.task(base=ProfiledTask, bind=True, max_retries=9)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours
logentry = LogEntry.all.get(id=logentry_id)
webhook = WebHook.objects.get(id=webhook_id)
with scopes_disabled():
webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer):
logentry = LogEntry.all.get(id=logentry_id)
types = get_all_webhook_events()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
types = get_all_webhook_events()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
t = time.time()
payload = event_type.build_payload(logentry)
t = time.time()
try:
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
)
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
)
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2))
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2))
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2))
except MaxRetriesExceededError:
pass
except MaxRetriesExceededError:
pass

View File

@@ -0,0 +1,39 @@
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", *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", *args, **options)

View File

@@ -0,0 +1,70 @@
# 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

@@ -0,0 +1,19 @@
# 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

@@ -0,0 +1,26 @@
# 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

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel
@@ -335,6 +336,10 @@ class Event(EventMixin, LoggedModel):
verbose_name=_('Event series'),
default=False
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
objects = ScopedManager(organizer='organizer')
class Meta:
verbose_name = _("Event")
@@ -345,6 +350,26 @@ class Event(EventMixin, LoggedModel):
def __str__(self):
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
def presale_has_ended(self):
if self.has_subevents:
@@ -528,6 +553,24 @@ class Event(EventMixin, LoggedModel):
for i in items:
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():
s.object = self
s.pk = None
@@ -871,10 +914,14 @@ class SubEvent(EventMixin, LoggedModel):
null=True, blank=True,
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')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Date in event series")
verbose_name_plural = _("Dates in event series")
@@ -883,6 +930,28 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self):
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
def settings(self):
return self.event.settings
@@ -941,6 +1010,7 @@ class SubEvent(EventMixin, LoggedModel):
raise ValidationError(_('One or more variations do not belong to this event.'))
@scopes_disabled()
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)

View File

@@ -9,6 +9,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import pgettext
from django_countries.fields import CountryField
from django_scopes import ScopedManager
def invoice_filename(instance, filename: str) -> str:
@@ -107,6 +108,8 @@ class Invoice(models.Model):
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
internal_reference = models.TextField(blank=True)
objects = ScopedManager(organizer='event__organizer')
@staticmethod
def _to_numeric_invoice_number(number):
return '{:05d}'.format(int(number))

View File

@@ -17,11 +17,13 @@ from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
from pretix.base.models.tax import TaxedPrice
from pretix.base.signals import quota_availability
from .event import Event, SubEvent
@@ -155,28 +157,40 @@ class SubEventItemVariation(models.Model):
self.subevent.event.cache.clear()
def filter_available(qs, channel='web', voucher=None, allow_addons=False):
q = (
# IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated()
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
qs = qs.filter(q)
vouchq = Q(hide_without_voucher=False)
if voucher and voucher.show_hidden_items:
if voucher.item_id:
vouchq = Q(pk=voucher.item_id)
elif voucher.quota_id:
vouchq = Q(quotas__in=[voucher.quota_id])
return qs.filter(vouchq)
class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False):
q = (
# IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated()
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
qs = self.filter(q)
return filter_available(self, channel, voucher, allow_addons)
vouchq = Q(hide_without_voucher=False)
if voucher:
if voucher.item_id:
vouchq |= Q(pk=voucher.item_id)
qs = qs.filter(pk=voucher.item_id)
elif voucher.quota_id:
qs = qs.filter(quotas__in=[voucher.quota_id])
return qs.filter(vouchq)
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):
@@ -226,7 +240,7 @@ class Item(LoggedModel):
:type sales_channels: bool
"""
objects = ItemQuerySet.as_manager()
objects = ItemQuerySetManager()
event = models.ForeignKey(
Event,
@@ -328,7 +342,7 @@ class Item(LoggedModel):
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
default=False,
help_text=_('This product will be hidden from the event page until the user enters a voucher '
'code that is specifically tied to this product (and not via a quota).')
'that unlocks this product.')
)
require_bundling = models.BooleanField(
verbose_name=_('Only sell this product as part of a bundle'),
@@ -591,6 +605,8 @@ class ItemVariation(models.Model):
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
objects = ScopedManager(organizer='item__event__organizer')
class Meta:
verbose_name = _("Product variation")
verbose_name_plural = _("Product variations")
@@ -985,6 +1001,8 @@ class Question(LoggedModel):
)
dependency_value = models.TextField(null=True, blank=True)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Question")
verbose_name_plural = _("Questions")
@@ -1234,6 +1252,8 @@ class Quota(LoggedModel):
cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True)
cached_availability_time = models.DateTimeField(null=True, blank=True)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Quota")
verbose_name_plural = _("Quotas")
@@ -1292,6 +1312,9 @@ class Quota(LoggedModel):
return _cache[self.pk]
now_dt = now_dt or now()
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
self.event.cache.delete('item_quota_cache')
rewrite_cache = count_waitinglist and (

View File

@@ -15,7 +15,7 @@ from django.db import models, transaction
from django.db.models import (
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, Greatest
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.urls import reverse
@@ -26,6 +26,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
@@ -186,6 +187,8 @@ class Order(LockModel, LoggedModel):
verbose_name=_('E-mail address verified')
)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Order")
verbose_name_plural = _("Orders")
@@ -195,6 +198,8 @@ class Order(LockModel, LoggedModel):
return self.full_code
def gracefully_delete(self, user=None, auth=None):
from . import Voucher
if not self.testmode:
raise TypeError("Only test mode orders can be deleted.")
self.event.log_action(
@@ -203,6 +208,12 @@ class Order(LockModel, LoggedModel):
'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).delete()
OrderFee.all.filter(order=self).delete()
@@ -223,6 +234,7 @@ class Order(LockModel, LoggedModel):
return self.all_fees(manager='objects')
@cached_property
@scopes_disabled()
def count_positions(self):
if hasattr(self, 'pcnt'):
return self.pcnt or 0
@@ -246,6 +258,7 @@ class Order(LockModel, LoggedModel):
return None
@property
@scopes_disabled()
def payment_refund_sum(self):
payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
@@ -257,6 +270,7 @@ class Order(LockModel, LoggedModel):
return payment_sum - refund_sum
@property
@scopes_disabled()
def pending_sum(self):
total = self.total
if self.status == Order.STATUS_CANCELED:
@@ -431,6 +445,7 @@ class Order(LockModel, LoggedModel):
return round_decimal(fee, self.event.currency)
@property
@scopes_disabled()
def user_cancel_allowed(self) -> bool:
"""
Returns whether or not this order can be canceled by the user.
@@ -615,7 +630,7 @@ class Order(LockModel, LoggedModel):
), tz)
return term_last
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]:
error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."),
@@ -623,29 +638,37 @@ class Order(LockModel, LoggedModel):
"payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.')
}
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if not force:
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force:
return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist)
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]:
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
}
now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation')
positions = self.positions.all().select_related('item', 'variation', 'seat')
quota_cache = {}
try:
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)
if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -814,6 +837,8 @@ class QuestionAnswer(models.Model):
max_length=255
)
objects = ScopedManager(organizer='question__event__organizer')
@property
def backend_file_url(self):
if self.file:
@@ -921,6 +946,8 @@ class AbstractPosition(models.Model):
:type voucher: Voucher
:param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str
:param seat: Seat, if reserved seating is used.
:type seat: Seat
"""
subevent = models.ForeignKey(
SubEvent,
@@ -967,6 +994,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Meta information"),
null=True, blank=True
)
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
class Meta:
abstract = True
@@ -1137,6 +1167,8 @@ class OrderPayment(models.Model):
)
migrated = models.BooleanField(default=False)
objects = ScopedManager(organizer='order__event__organizer')
class Meta:
ordering = ('local_id',)
@@ -1164,8 +1196,8 @@ class OrderPayment(models.Model):
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
if not force and can_be_paid is not True:
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
if can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid
}, user=user, auth=auth)
@@ -1493,6 +1525,8 @@ class OrderRefund(models.Model):
null=True, blank=True
)
objects = ScopedManager(organizer='order__event__organizer')
class Meta:
ordering = ('local_id',)
@@ -1554,7 +1588,7 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs)
class ActivePositionManager(models.Manager):
class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
@@ -1631,7 +1665,7 @@ class OrderFee(models.Model):
)
canceled = models.BooleanField(default=False)
all = models.Manager()
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
@property
@@ -1736,7 +1770,7 @@ class OrderPosition(AbstractPosition):
)
canceled = models.BooleanField(default=False)
all = models.Manager()
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
class Meta:
@@ -1836,6 +1870,7 @@ class OrderPosition(AbstractPosition):
return super().save(*args, **kwargs)
@scopes_disabled()
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)
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
@@ -1943,6 +1978,8 @@ class CartPosition(AbstractPosition):
)
is_bundled = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Cart position")
verbose_name_plural = _("Cart positions")
@@ -1992,6 +2029,8 @@ class InvoiceAddress(models.Model):
blank=True
)
objects = ScopedManager(organizer='order__event__organizer')
def save(self, **kwargs):
if self.order:
self.order.touch()

View File

@@ -0,0 +1,111 @@
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 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')
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'],
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)
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):
return self.name
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,6 +8,9 @@ from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.timezone import now
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 .base import LoggedModel
@@ -23,6 +26,7 @@ def _generate_random_code(prefix=None):
return get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset)
@scopes_disabled()
def generate_code(prefix=None):
while True:
code = _generate_random_code(prefix=prefix)
@@ -172,6 +176,12 @@ class Voucher(LoggedModel):
help_text=_("The text entered in this field will not be visible to the user and is available for your "
"convenience.")
)
show_hidden_items = models.BooleanField(
verbose_name=_("Shows hidden products that match this voucher"),
default=True
)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Voucher")
@@ -391,3 +401,11 @@ class Voucher(LoggedModel):
"""
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()
return self.item.seat_category_mappings.filter(**kwargs).exists()

View File

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

View File

@@ -238,6 +238,11 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
) if ev.date_admission else ""
}),
("seat", {
"label": _("Seat name"),
"editor_sample": _("3, 4-5"),
"evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission'))
}),
))

View File

@@ -6,15 +6,16 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
from django.db.models import Q
from django.db.models import Count, Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _
from django_scopes import scopes_disabled
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
@@ -23,7 +24,7 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.templatetags.rich_text import rich_text
from pretix.celery_app import app
@@ -90,15 +91,20 @@ error_messages = {
'product %(base)s.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
'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:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled'))
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent'))
'quotas', 'subevent', 'seat'))
order = {
RemoveOperation: 10,
ExtendOperation: 20,
@@ -116,6 +122,7 @@ class CartManager:
self._items_cache = {}
self._subevents_cache = {}
self._variations_cache = {}
self._seated_cache = {}
self._expiry = None
self.invoice_address = invoice_address
self._widget_data = widget_data or {}
@@ -127,6 +134,11 @@ class CartManager:
Q(cart_id=self.cart_id) & Q(event=self.event)
).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):
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
@@ -187,6 +199,8 @@ class CartManager:
i.pk: i
for i in self.event.items.select_related('category').prefetch_related(
'addons', 'bundles', 'addons__addon_category', 'quotas'
).annotate(
has_variations=Count('variations'),
).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)
@@ -211,10 +225,7 @@ class CartManager:
def _check_item_constraints(self, op):
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
if op.item.require_voucher and op.voucher is None:
raise CartError(error_messages['voucher_required'])
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):
if (op.item.require_voucher or op.item.hide_without_voucher) and (op.voucher is None or not op.voucher.show_hidden_items):
raise CartError(error_messages['voucher_required'])
if not op.item.is_available() or (op.variation and not op.variation.active):
@@ -223,6 +234,12 @@ class CartManager:
if self._sales_channel not in op.item.sales_channels:
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):
raise CartError(error_messages['voucher_invalid_item'])
@@ -238,6 +255,16 @@ class CartManager:
if op.subevent and op.subevent.presale_has_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:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
@@ -300,6 +327,13 @@ class CartManager:
def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).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(
'item__quotas',
'variation__quotas',
@@ -312,6 +346,8 @@ class CartManager:
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
continue
cp.item.requires_seat = cp.requires_seat
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
@@ -358,7 +394,7 @@ class CartManager:
op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
)
self._check_item_constraints(op)
@@ -377,12 +413,6 @@ class CartManager:
operations = []
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 not i.get('subevent'):
raise CartError(error_messages['subevent_required'])
@@ -390,6 +420,24 @@ class CartManager:
else:
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']]
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
voucher = None
@@ -445,7 +493,7 @@ class CartManager:
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[]
includes_tax=bool(bprice.rate), bundled=[], seat=None
)
self._check_item_constraints(bop)
bundled.append(bop)
@@ -454,7 +502,7 @@ class CartManager:
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
)
self._check_item_constraints(op)
operations.append(op)
@@ -560,7 +608,7 @@ class CartManager:
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[]
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=cp.seat
)
self._check_item_constraints(op)
operations.append(op)
@@ -686,6 +734,7 @@ class CartManager:
err = err or self._check_min_per_product()
self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set()
for op in self._operations:
if isinstance(op, self.RemoveOperation):
@@ -699,6 +748,11 @@ class CartManager:
# Create a CartPosition for as much items as we can
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:
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
@@ -744,12 +798,16 @@ class CartManager:
available_count = 0
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):
cp = CartPosition(
event=self.event, item=op.item, variation=op.variation,
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,
subevent=op.subevent, includes_tax=op.includes_tax
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
)
if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -788,7 +846,11 @@ class CartManager:
new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
if op.seat and not op.seat.is_available(ignore_cart=op.position):
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.price = op.price.gross
try:
@@ -819,6 +881,9 @@ class CartManager:
# If any quotas are affected that are not unlimited, we lock
return True
if any(getattr(o, 'seat', False) for o in self._operations):
return True
return False
def commit(self):
@@ -902,23 +967,22 @@ def get_fees(event, request, total, invoice_address, provider):
return fees
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
@app.task(base=ProfiledEventTask, 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',
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID
:param cart_id: Session ID of a guest
:raises CartError: On any error that occured
"""
with language(locale):
event = Event.objects.get(id=event)
ia = False
if invoice_address:
try:
ia = InvoiceAddress.objects.get(pk=invoice_address)
with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist:
pass
@@ -934,8 +998,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None:
@app.task(base=ProfiledEventTask, 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:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
@@ -943,7 +1007,6 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
@@ -955,15 +1018,14 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
@@ -975,8 +1037,8 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
@app.task(base=ProfiledEventTask, 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',
invoice_address: int=None, sales_channel='web') -> None:
"""
Removes a list of items from a user's cart.
@@ -985,12 +1047,11 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
ia = False
if invoice_address:
try:
ia = InvoiceAddress.objects.get(pk=invoice_address)
with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist:
pass
try:

View File

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

View File

@@ -6,7 +6,7 @@ from django.utils.translation import ugettext
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.signals import register_data_exporters
from pretix.celery_app import app
@@ -15,9 +15,8 @@ class ExportError(LazyLocaleException):
pass
@app.task(base=ProfiledTask, throws=(ExportError,))
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)
with language(event.settings.locale), override(event.settings.timezone):
responses = register_data_exporters.send(event)

View File

@@ -15,6 +15,7 @@ from django.utils import timezone
from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _
from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
@@ -244,16 +245,18 @@ def generate_invoice(order: Order, trigger_pdf=True):
@app.task(base=TransactionAwareTask)
def invoice_pdf_task(invoice: int):
i = Invoice.objects.get(pk=invoice)
if i.shredded:
return None
if i.file:
i.file.delete()
with language(i.locale):
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
i.file.save(fname, ContentFile(fcontent))
i.save()
return i.file.name
with scopes_disabled():
i = Invoice.objects.get(pk=invoice)
with scope(organizer=i.order.event.organizer):
if i.shredded:
return None
if i.file:
i.file.delete()
with language(i.locale):
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
i.file.save(fname, ContentFile(fcontent))
i.save()
return i.file.name
def invoice_qualified(order: Order):

View File

@@ -10,6 +10,7 @@ from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import get_template
from django.utils.translation import ugettext as _
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.email import ClassicMailRenderer
@@ -219,98 +220,102 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
email.attach_alternative(html, "text/html")
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
if inv.file:
try:
email.attach(
'{}.pdf'.format(inv.number),
inv.file.file.read(),
'application/pdf'
)
except:
logger.exception('Could not attach invoice to email')
pass
if event:
event = Event.objects.get(id=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
if event:
if order:
try:
order = event.orders.get(pk=order)
except Order.DoesNotExist:
order = None
else:
if position:
with cm():
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
if inv.file:
try:
position = order.positions.get(pk=position)
except OrderPosition.DoesNotExist:
attach_tickets = False
if attach_tickets:
args = []
attach_size = 0
for name, ct in get_tickets_for_order(order, base_position=position):
content = ct.file.read()
args.append((name, content, ct.type))
attach_size += len(content)
if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
email.attach(
'{}.pdf'.format(inv.number),
inv.file.file.read(),
'application/pdf'
)
except:
logger.exception('Could not attach invoice to email')
pass
if event:
if order:
try:
order = event.orders.get(pk=order)
except Order.DoesNotExist:
order = None
else:
if position:
try:
position = order.positions.get(pk=position)
except OrderPosition.DoesNotExist:
attach_tickets = False
if attach_tickets:
args = []
attach_size = 0
for name, ct in get_tickets_for_order(order, base_position=position):
content = ct.file.read()
args.append((name, content, ct.type))
attach_size += len(content)
email = email_filter.send_chained(event, 'message', message=email, order=order)
if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
)
try:
backend.send_messages([email])
except smtplib.SMTPResponseException as e:
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
logger.exception('Error sending email')
email = email_filter.send_chained(event, 'message', message=email, order=order)
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'SMTP code {}'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
'recipient': '',
'invoices': [],
}
)
try:
backend.send_messages([email])
except smtplib.SMTPResponseException as e:
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
logger.exception('Error sending email')
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'Internal error',
'message': str(e),
'recipient': '',
'invoices': [],
}
)
logger.exception('Error sending email')
raise SendMailException('Failed to send an email to {}.'.format(to))
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'SMTP code {}'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
'recipient': '',
'invoices': [],
}
)
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'Internal error',
'message': str(e),
'recipient': '',
'invoices': [],
}
)
logger.exception('Error sending email')
raise SendMailException('Failed to send an email to {}.'.format(to))
def mail_send(*args, **kwargs):

View File

@@ -1,5 +1,6 @@
from django.conf import settings
from django.template.loader import get_template
from django_scopes import scope, scopes_disabled
from inlinestyler.utils import inline_css
from pretix.base.i18n import language
@@ -12,6 +13,7 @@ from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask)
@scopes_disabled()
def notify(logentry_id: int):
logentry = LogEntry.all.get(id=logentry_id)
if not logentry.event:
@@ -66,17 +68,22 @@ def notify(logentry_id: int):
@app.task(base=ProfiledTask)
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id)
user = User.objects.get(id=user_id)
types = get_all_notification_types(logentry.event)
notification_type = types.get(action_type)
if not notification_type:
return # Ignore, e.g. plugin not active for this event
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)
types = get_all_notification_types(logentry.event)
notification_type = types.get(action_type)
if not notification_type:
return # Ignore, e.g. plugin not active for this event
with language(user.locale):
notification = notification_type.build_notification(logentry)
with language(user.locale):
notification = notification_type.build_notification(logentry)
if method == "mail":
send_notification_mail(notification, user)
if method == "mail":
send_notification_mail(notification, user)
def send_notification_mail(notification: Notification, user: User):

View File

@@ -16,6 +16,7 @@ from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _
from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication
from pretix.base.i18n import (
@@ -23,7 +24,7 @@ from pretix.base.i18n import (
)
from pretix.base.models import (
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
OrderPosition, Quota, User, Voucher,
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
@@ -42,7 +43,7 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.mail import SendMailException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import (
allow_ticket_download, order_approved, order_canceled, order_changed,
@@ -81,6 +82,8 @@ error_messages = {
'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 '
'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__)
@@ -427,6 +430,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter()
changed_prices = {}
deleted_positions = set()
seats_seen = set()
def delete(cp):
# Delete a cart position, including parents and children, if applicable
@@ -489,17 +493,32 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
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:
delete(cp)
err = err or error_messages['voucher_required']
break
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
or cp.voucher.item.pk != cp.item.pk):
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.pk, cp.variation.pk)):
delete(cp)
cp.delete()
err = error_messages['voucher_required']
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:
# Other checks are not necessary
continue
@@ -715,10 +734,8 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
logger.exception('Order received email could not be sent to attendee')
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
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'):
event = Event.objects.get(id=event)
if payment_provider:
pprov = event.get_payment_providers().get(payment_provider)
if not pprov:
@@ -732,25 +749,35 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
addr = None
if address is not None:
try:
addr = InvoiceAddress.objects.get(pk=address)
with scopes_disabled():
addr = InvoiceAddress.objects.get(pk=address)
except InvoiceAddress.DoesNotExist:
pass
positions = CartPosition.objects.filter(id__in=position_ids, event=event)
positions = CartPosition.objects.annotate(
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
locked = False
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists():
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists():
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True
lockfn = event.lock
with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons'))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
@@ -804,6 +831,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
@receiver(signal=periodic_task)
@scopes_disabled()
def expire_orders(sender, **kwargs):
eventcache = {}
@@ -818,6 +846,7 @@ def expire_orders(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def send_expiry_warnings(sender, **kwargs):
eventcache = {}
today = now().replace(hour=0, minute=0, second=0)
@@ -875,6 +904,7 @@ def send_expiry_warnings(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
@@ -958,12 +988,17 @@ class OrderChangeManager:
'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.'),
'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'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
SplitOperation = namedtuple('SplitOperation', ('position',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
@@ -976,6 +1011,7 @@ class OrderChangeManager:
self._committed = False
self._totaldiff = 0
self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = []
self.notify = notify
self._invoice_dirty = False
@@ -993,6 +1029,13 @@ class OrderChangeManager:
self._quotadiff.subtract(position.quotas)
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):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
@@ -1048,12 +1091,14 @@ class OrderChangeManager:
self._totaldiff += -position.price
self._quotadiff.subtract(position.quotas)
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'):
self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None):
subevent: SubEvent = None, seat: Seat = None):
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
@@ -1072,6 +1117,14 @@ class OrderChangeManager:
if self.order.event.has_subevents and not subevent:
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)
if variation else item.quotas.filter(subevent=subevent))
if not new_quotas:
@@ -1082,7 +1135,9 @@ class OrderChangeManager:
self._totaldiff += price.gross
self._quotadiff.update(new_quotas)
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent))
if seat:
self._seatdiff.update([seat])
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat))
def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1090,6 +1145,26 @@ class OrderChangeManager:
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):
for quota, diff in self._quotadiff.items():
if diff <= 0:
@@ -1156,7 +1231,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['paid_to_free_exceeded'])
def _perform_operations(self):
nextposid = self.order.positions.aggregate(m=Max('positionid'))['m'] + 1
nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1
split_positions = []
for op in self._operations:
@@ -1176,6 +1251,17 @@ class OrderChangeManager:
op.position.variation = op.variation
op.position._calculate_tax()
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):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
'position': op.position.pk,
@@ -1229,7 +1315,7 @@ class OrderChangeManager:
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent
positionid=nextposid, subevent=op.subevent, seat=op.seat
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -1240,6 +1326,7 @@ class OrderChangeManager:
'price': op.price.gross,
'positionid': pos.positionid,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
})
elif isinstance(op, self.SplitOperation):
split_positions.append(op.position)
@@ -1464,6 +1551,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['not_pending_or_paid'])
if check_quotas:
self._check_quotas()
self._check_seats()
self._check_complete_cancel()
self._perform_operations()
self._recalculate_total_and_payment_fee()
@@ -1497,8 +1585,8 @@ class OrderChangeManager:
return pprov
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: str, payment_provider: str, positions: List[str],
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web'):
with language(locale):
@@ -1513,6 +1601,7 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
@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,
device=None, cancellation_fee=None, try_auto_refund=False):
try:

View File

@@ -4,6 +4,7 @@ from django.conf import settings
from django.db.models import Max, Q
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Event, LogEntry
from pretix.celery_app import app
@@ -17,6 +18,7 @@ def build_all_quota_caches(sender, **kwargs):
@app.task
@scopes_disabled()
def refresh_quota_caches():
# Active events
active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter(

View File

@@ -0,0 +1,57 @@
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)
}
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)
if seat.product != p:
seat.product = p
seat.save()
else:
create_seats.append(Seat(
event=event,
subevent=subevent,
seat_guid=ss.guid,
name=ss.name,
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,14 +11,13 @@ from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.shredder import ShredError
from pretix.celery_app import app
@app.task(base=ProfiledTask)
def export(event: str, shredders: List[str]) -> None:
event = Event.objects.get(id=event)
@app.task(base=ProfiledEventTask)
def export(event: Event, shredders: List[str]) -> None:
known_shredders = event.get_data_shredders()
with NamedTemporaryFile() as rawfile:
@@ -63,9 +62,8 @@ def export(event: str, shredders: List[str]) -> None:
return cf.pk
@app.task(base=ProfiledTask, throws=(ShredError,))
def shred(event: str, fileid: str, confirm_code: str) -> None:
event = Event.objects.get(id=event)
@app.task(base=ProfiledEventTask, throws=(ShredError,))
def shred(event: Event, fileid: str, confirm_code: str) -> None:
known_shredders = event.get_data_shredders()
try:
cf = CachedFile.objects.get(pk=fileid)

View File

@@ -14,10 +14,12 @@ import time
from django.conf import settings
from django.db import transaction
from django_scopes import scope, scopes_disabled
from pretix.base.metrics import (
pretix_task_duration_seconds, pretix_task_runs_total,
)
from pretix.base.models import Event
from pretix.celery_app import app
@@ -61,6 +63,35 @@ class ProfiledTask(app.Task):
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):
"""
Task class which is aware of django db transactions and only executes tasks

View File

@@ -4,13 +4,14 @@ import os
from django.core.files.base import ContentFile
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from django_scopes import scopes_disabled
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
OrderPosition,
)
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import EventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
from pretix.celery_app import app
@@ -57,10 +58,11 @@ def generate_order(order: int, provider: str):
@app.task(base=ProfiledTask)
def generate(model: str, pk: int, provider: str):
if model == 'order':
return generate_order(pk, provider)
elif model == 'orderposition':
return generate_orderposition(pk, provider)
with scopes_disabled():
if model == 'order':
return generate_order(pk, provider)
elif model == 'orderposition':
return generate_orderposition(pk, provider)
class DummyRollbackException(Exception):
@@ -165,9 +167,8 @@ def get_tickets_for_order(order, base_position=None):
return tickets
@app.task(base=ProfiledTask)
def invalidate_cache(event: int, item: int=None, provider: str=None, order: int=None, **kwargs):
event = Event.objects.get(id=event)
@app.task(base=EventTask)
def invalidate_cache(event: Event, item: int=None, provider: str=None, order: int=None, **kwargs):
qs = CachedTicket.objects.filter(order_position__order__event=event)
qsc = CachedCombinedTicket.objects.filter(order__event=event)

View File

@@ -6,6 +6,7 @@ import requests
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix import __version__
@@ -29,6 +30,7 @@ def run_update_check(sender, **kwargs):
@app.task
@scopes_disabled()
def update_check():
gs = GlobalSettingsObject()

View File

@@ -1,17 +1,17 @@
import sys
from django.dispatch import receiver
from django_scopes import scopes_disabled
from pretix.base.models import Event, User, WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import EventTask
from pretix.base.signals import periodic_task
from pretix.celery_app import app
@app.task(base=ProfiledTask)
def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None):
event = Event.objects.get(id=event_id)
@app.task(base=EventTask)
def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None):
if user_id:
user = User.objects.get(id=user_id)
else:
@@ -69,6 +69,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
@receiver(signal=periodic_task)
@scopes_disabled()
def process_waitinglist(sender, **kwargs):
qs = Event.objects.filter(
live=True

View File

@@ -524,3 +524,20 @@ a ``subevent`` argument which might be none and you are expected to return a lis
``pretix.base.timeline.TimelineEvent``, which is a ``namedtuple`` with the fields ``event``, ``subevent``,
``datetime``, ``description`` and ``edit_url``.
"""
quota_availability = EventPluginSignal(
providing_args=['quota', 'result', 'count_waitinglist']
)
"""
This signal allows you to modify the availability of a quota. You are passed the ``quota`` and an
``availability`` result calculated by pretix code or other plugins. ``availability`` is a tuple
with the first entry being one of the ``Quota.AVAILABILITY_*`` constants and the second entry being
the number of available tickets (or ``None`` for unlimited). You are expected to return a value
of the same time. The parameter ``count_waitinglists`` specifies whether waiting lists should be taken
into account.
**Warning: Use this signal with great caution, it allows you to screw up the performance of the
system really bad.** Also, keep in mind that your response is subject to caching and out-of-date
quotas might be used for display (not for actual order processing).
"""

View File

@@ -3,6 +3,7 @@ import hmac
from django.conf import settings
from django.http import HttpResponse
from django_scopes import scopes_disabled
from .. import metrics
@@ -15,6 +16,7 @@ def unauthed_response():
return response
@scopes_disabled()
def serve_metrics(request):
if not settings.METRICS_ENABLED:
return unauthed_response()

View File

@@ -5,6 +5,7 @@ from django.conf import settings
from django.db.models import Q
from django.urls import Resolver404, get_script_prefix, resolve
from django.utils.translation import get_language
from django_scopes import scope
from pretix.base.models.auth import StaffSession
from pretix.base.settings import GlobalSettingsObject
@@ -53,10 +54,11 @@ def contextprocessor(request):
ctx['has_domain'] = request.event.organizer.domains.exists()
if not request.event.testmode:
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
if complain_testmode_orders is None:
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
with scope(organizer=request.organizer):
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
if complain_testmode_orders is None:
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
ctx['complain_testmode_orders'] = complain_testmode_orders
else:
ctx['complain_testmode_orders'] = False

View File

@@ -1,6 +1,9 @@
from django import forms
from django.urls import reverse
from django.utils.translation import pgettext_lazy
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from pretix.base.models.checkin import CheckinList
from pretix.control.forms.widgets import Select2
@@ -44,3 +47,7 @@ class CheckinListForm(forms.ModelForm):
'data-inverse-dependency': '<[name$=all_products]'
}),
}
field_classes = {
'limit_products': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField,
}

View File

@@ -132,7 +132,7 @@ class OrderFilterForm(FilterForm):
matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q(
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u)
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
)
).values('id')
@@ -177,11 +177,9 @@ class EventOrderFilterForm(OrderFilterForm):
orders = {'code': 'code', 'email': 'email', 'total': 'total',
'datetime': 'datetime', 'status': 'status'}
item = forms.ModelChoiceField(
item = forms.ChoiceField(
label=_('Products'),
queryset=Item.objects.none(),
required=False,
empty_label=_('All products')
)
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
@@ -241,12 +239,28 @@ class EventOrderFilterForm(OrderFilterForm):
elif 'subevent':
del self.fields['subevent']
choices = [('', _('All products'))]
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
else:
choices.append((str(i.pk), i.name))
self.fields['item'].choices = choices
def filter_qs(self, qs):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
if fdata.get('item'):
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False).distinct()
item = fdata.get('item')
if item:
if '-' in item:
var = item.split('-')[1]
qs = qs.filter(all_positions__variation_id=var, all_positions__canceled=False).distinct()
else:
qs = qs.filter(all_positions__item_id=fdata.get('item'), all_positions__canceled=False).distinct()
if fdata.get('subevent'):
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()

View File

@@ -6,6 +6,9 @@ from django.urls import reverse
from django.utils.translation import (
pgettext_lazy, ugettext as __, ugettext_lazy as _,
)
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.channels import get_all_sales_channels
@@ -94,6 +97,10 @@ class QuestionForm(I18nModelForm):
),
'dependency_value': forms.Select,
}
field_classes = {
'items': SafeModelMultipleChoiceField,
'dependency_question': SafeModelChoiceField,
}
class QuestionOptionForm(I18nModelForm):
@@ -159,6 +166,9 @@ class QuotaForm(I18nModelForm):
'size',
'subevent'
]
field_classes = {
'subevent': SafeModelChoiceField,
}
def save(self, *args, **kwargs):
creating = not self.instance.pk

View File

@@ -10,7 +10,9 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import InvoiceAddress, ItemAddOn, Order, OrderPosition
from pretix.base.models import (
InvoiceAddress, ItemAddOn, Order, OrderPosition, Seat,
)
from pretix.base.models.event import SubEvent
from pretix.base.services.pricing import get_price
from pretix.control.forms.widgets import Select2
@@ -192,10 +194,16 @@ class OrderPositionAddForm(forms.Form):
label=_('Product')
)
addon_to = forms.ModelChoiceField(
OrderPosition.objects.none(),
OrderPosition.all.none(),
required=False,
label=_('Add-on to'),
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
required=False,
label=_('Seat'),
empty_label=_('General admission')
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -241,6 +249,19 @@ class OrderPositionAddForm(forms.Form):
else:
del self.fields['addon_to']
self.fields['seat'].queryset = order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': order.event.slug,
'organizer': order.event.organizer.slug,
}),
'data-placeholder': _('General admission')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
if order.event.has_subevents:
self.fields['subevent'].queryset = order.event.subevents.all()
self.fields['subevent'].widget = Select2(
@@ -269,6 +290,11 @@ class OrderPositionChangeForm(forms.Form):
required=False,
empty_label=_('(Unchanged)')
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
required=False,
empty_label=_('(Unchanged)')
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -312,6 +338,22 @@ class OrderPositionChangeForm(forms.Form):
else:
del self.fields['subevent']
if instance.seat:
self.fields['seat'].queryset = instance.order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': instance.order.event.slug,
'organizer': instance.order.event.organizer.slug,
}),
'data-placeholder': _('(Unchanged)')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
else:
del self.fields['seat']
choices = [
('', _('(Unchanged)'))
]

View File

@@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.api.models import WebHook
@@ -149,6 +150,9 @@ class TeamForm(forms.ModelForm):
'data-inverse-dependency': '#id_all_events'
}),
}
field_classes = {
'limit_events': SafeModelMultipleChoiceField
}
def clean(self):
data = super().clean()
@@ -177,6 +181,9 @@ class DeviceForm(forms.ModelForm):
'data-inverse-dependency': '#id_all_events'
}),
}
field_classes = {
'limit_events': SafeModelMultipleChoiceField
}
class OrganizerSettingsForm(SettingsForm):
@@ -307,3 +314,6 @@ class WebHookForm(forms.ModelForm):
'data-inverse-dependency': '#id_all_events'
}),
}
field_classes = {
'limit_events': SafeModelMultipleChoiceField
}

View File

@@ -36,7 +36,7 @@ class SubEventForm(I18nModelForm):
'presale_start',
'presale_end',
'location',
'frontpage_text'
'frontpage_text',
]
field_classes = {
'date_from': SplitDateTimeField,

View File

@@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models.functions import Lower
from django.urls import reverse
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes.forms import SafeModelChoiceField
from pretix.base.forms import I18nModelForm
from pretix.base.models import Item, Voucher
@@ -31,10 +32,11 @@ class VoucherForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'price_mode', 'subevent'
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
]
field_classes = {
'valid_until': SplitDateTimeField,
'subevent': SafeModelChoiceField,
}
widgets = {
'valid_until': SplitDateTimePickerWidget(),
@@ -195,10 +197,11 @@ class VoucherBulkForm(VoucherForm):
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'price_mode', 'subevent'
'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
]
field_classes = {
'valid_until': SplitDateTimeField,
'subevent': SafeModelChoiceField,
}
widgets = {
'valid_until': SplitDateTimePickerWidget(),

View File

@@ -42,6 +42,12 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.seat':
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
'to "{new_seat}".').format(
posid=data.get('positionid', '?'),
old_seat=data.get('old_seat'), new_seat=data.get('new_seat'),
)
elif logentry.action_type == 'pretix.event.order.changed.subevent':
old_se = str(event.subevents.get(pk=data['old_subevent']))
new_se = str(event.subevents.get(pk=data['new_subevent']))

View File

@@ -4,10 +4,11 @@ from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.template.response import TemplateResponse
from django.urls import get_script_prefix, resolve, reverse
from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import force_str
from django.utils.translation import ugettext as _
from django_scopes import scope
from hijack.templatetags.hijack_tags import is_hijacked
from pretix.base.models import Event, Organizer
@@ -17,7 +18,7 @@ from pretix.helpers.security import (
)
class PermissionMiddleware(MiddlewareMixin):
class PermissionMiddleware:
"""
This middleware enforces all requests to the control app to require login.
Additionally, it enforces all requests to "control:event." URLs
@@ -34,6 +35,23 @@ class PermissionMiddleware(MiddlewareMixin):
"user.settings.notifications.off",
)
EXCEPTIONS_2FA = (
"user.settings.2fa",
"user.settings.2fa.add",
"user.settings.2fa.enable",
"user.settings.2fa.disable",
"user.settings.2fa.regenemergency",
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.u2f",
"user.settings.2fa.delete",
"auth.logout",
"user.reauth"
)
def __init__(self, get_response=None):
self.get_response = get_response
super().__init__()
def _login_redirect(self, request):
# Taken from django/contrib/auth/decorators.py
path = request.build_absolute_uri()
@@ -52,19 +70,19 @@ class PermissionMiddleware(MiddlewareMixin):
return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME)
def process_request(self, request):
def __call__(self, request):
url = resolve(request.path_info)
url_name = url.url_name
if not request.path.startswith(get_script_prefix() + 'control'):
# This middleware should only touch the /control subpath
return
return self.get_response(request)
if hasattr(request, 'organizer'):
# If the user is on a organizer's subdomain, he should be redirected to pretix
return redirect(urljoin(settings.SITE_URL, request.get_full_path()))
if url_name in self.EXCEPTIONS:
return
return self.get_response(request)
if not request.user.is_authenticated:
return self._login_redirect(request)
@@ -78,11 +96,16 @@ class PermissionMiddleware(MiddlewareMixin):
if url_name not in ('user.reauth', 'auth.logout'):
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
and url_name not in self.EXCEPTIONS_2FA:
return redirect(reverse('control:user.settings.2fa'))
if 'event' in url.kwargs and 'organizer' in url.kwargs:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer').first()
with scope(organizer=None):
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer').first()
if not request.event or not request.user.has_event_permission(request.event.organizer, request.event,
request=request):
raise Http404(_("The selected event was not found or you "
@@ -104,6 +127,12 @@ class PermissionMiddleware(MiddlewareMixin):
else:
request.orgapermset = request.user.get_organizer_permission_set(request.organizer)
with scope(organizer=getattr(request, 'organizer', None)):
r = self.get_response(request)
if isinstance(r, TemplateResponse):
r = r.render()
return r
class AuditLogMiddleware:

View File

@@ -237,6 +237,24 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
A second keyword argument ``request`` will contain the request object.
"""
nav_item = EventPluginSignal(
providing_args=['request', 'item']
)
"""
This signal is sent out to include tab links on the settings page of an item.
Receivers are expected to return a list of dictionaries. The dictionaries
should contain at least the keys ``label`` and ``url``. You should also return
an ``active`` key with a boolean set to ``True``, when this item should be marked
as active.
If your linked view should stay in the tab-like context of this page, we recommend
that you use ``pretix.control.views.item.ItemDetailMixin`` for your view
and your template inherits from ``pretixcontrol/item/base.html``.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
A second keyword argument ``request`` will contain the request object.
"""
event_settings_widget = EventPluginSignal(
providing_args=['request']
)
@@ -261,6 +279,22 @@ styles. It is advisable to set a prefix for your form to avoid clashes with othe
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
subevent_forms = EventPluginSignal(
providing_args=['request', 'subevent']
)
"""
This signal allows you to return additional forms that should be rendered on the subevent creation
or modification page. You are passed ``request`` and ``subevent`` arguments and are expected to return
an instance of a form class that you bind yourself when appropriate. Your form will be executed
as part of the standard validation and rendering cycle and rendered using default bootstrap
styles. It is advisable to set a prefix for your form to avoid clashes with other plugins.
``subevent`` can be ``None`` during creation. Before ``save()`` is called, a ``subevent`` property of
your form instance will automatically being set to the subevent that has just been created.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
oauth_application_registered = Signal(
providing_args=["user", "application"]
)

View File

@@ -145,7 +145,11 @@
<a href="{{ nav.url }}" title="{{ nav.title }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
{% if nav.icon %}
<span class="fa fa-{{ nav.icon }}"></span>
{% if "<svg" in nav.icon %}
{{ nav.icon|safe }}
{% else %}
<span class="fa fa-{{ nav.icon }}"></span>
{% endif %}
<span class="visible-xs-inline">{{ nav.label }}</span>
{% else %}
{{ nav.label }}
@@ -270,7 +274,11 @@
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon %}
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
{% if "<svg" in nav.icon %}
{{ nav.icon|safe }}
{% else %}
<span class="fa fa-fw fa-{{ nav.icon }}"></span>
{% endif %}
{% endif %}
{{ nav.label }}
</a>

View File

@@ -27,6 +27,13 @@
{% trans "Bundled products" %}
</a>
</li>
{% for n in extra_nav %}
<li {% if n.active %}class="active"{% endif %}>
<a href="{{ n.url }}">
{{ n.label }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<h1>{% trans "Create product" %}</h1>

View File

@@ -51,6 +51,13 @@
</div>
</div>
{% endfor %}
{% if has_plugins > 0 %}
<div class="alert alert-warning">
{% blocktrans trimmed with num=quota_overbooked %}
A plugin is active that might modify the actual result of this quota from what you see here.
{% endblocktrans %}
</div>
{% endif %}
{% if quota_overbooked > 0 %}
<div class="alert alert-warning">
{% blocktrans trimmed with num=quota_overbooked %}

View File

@@ -72,7 +72,7 @@
</h3>
</div>
<div class="panel-body">
<div class="form-order-change" data-pricecalc-endpoint="{% url "api-v1:orderposition-price_calc" organizer=order.event.organizer.slug event=order.event.slug pk=position.pk %}">
<div class="form-order-change" data-pricecalc-endpoint="{% url "api-v1:orderposition-price_calc" organizer=order.event.organizer.slug event=order.event.slug pk=position.pk %}" {% if position.subevent %}data-subevent="{{ position.subevent.id }}{% endif %}">
{% bootstrap_form_errors position.form %}
{% if position.custom_error %}
<div class="alert alert-danger">
@@ -87,20 +87,6 @@
<strong>{% trans "Change to" %}</strong>
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Product" %}</strong>
</div>
<div class="col-sm-5">
{{ position.item }}
{% if position.variation %}
{{ position.variation }}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.itemvar layout='inline' %}
</div>
</div>
{% if request.event.has_subevents %}
<div class="row">
@@ -116,6 +102,34 @@
</div>
{% endif %}
{% if position.seat %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Seat" %}</strong>
</div>
<div class="col-sm-5">
{{ position.seat }}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.seat layout='inline' %}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Product" %}</strong>
</div>
<div class="col-sm-5">
{{ position.item }}
{% if position.variation %}
{{ position.variation }}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.itemvar layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Price" %}</strong>
@@ -182,6 +196,7 @@
{% if add_form.subevent %}
{% bootstrap_field add_form.subevent layout="control" %}
{% endif %}
{% bootstrap_field add_form.seat layout="control" %}
</div>
</div>
</div>

View File

@@ -258,6 +258,15 @@
<span class="fa fa-fw fa-check" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}First scanned: {{ date }}{% endblocktrans %}"></span>
{% endfor %}
{% endif %}
{% if line.seat %}
<br />
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668" class="svg-icon">
<path
style="fill:black"
d="m 1.9592032,1.8522629e-4 c -0.21468,0 -0.38861,0.17394000371 -0.38861,0.38861000371 0,0.21466 0.17393,0.38861 0.38861,0.38861 0.21468,0 0.3886001,-0.17395 0.3886001,-0.38861 0,-0.21467 -0.1739201,-0.38861000371 -0.3886001,-0.38861000371 z m 0.1049,0.84543000371 c -0.20823,-0.0326 -0.44367,0.12499 -0.39998,0.40462997 l 0.20361,1.01854 c 0.0306,0.15316 0.15301,0.28732 0.3483,0.28732 h 0.8376701 v 0.92708 c 0,0.29313 0.41187,0.29447 0.41187,0.005 v -1.19115 c 0,-0.14168 -0.0995,-0.29507 -0.29094,-0.29507 l -0.65578,-10e-4 -0.1757,-0.87644 C 2.3042533,0.95300523 2.1890432,0.86500523 2.0641032,0.84547523 Z m -0.58549,0.44906997 c -0.0946,-0.0134 -0.20202,0.0625 -0.17829,0.19172 l 0.18759,0.91054 c 0.0763,0.33956 0.36802,0.55914 0.66042,0.55914 h 0.6015201 c 0.21356,0 0.21448,-0.32143 -0.003,-0.32143 H 2.1954632 c -0.19911,0 -0.36364,-0.11898 -0.41341,-0.34107 l -0.17777,-0.87126 c -0.0165,-0.0794 -0.0688,-0.11963 -0.12557,-0.12764 z"/>
</svg>
{{ line.seat }}
{% endif %}
{% if line.voucher %}
<br/><span class="fa fa-tags"></span> {% trans "Voucher code used:" %}
<a href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">

View File

@@ -86,11 +86,29 @@
{% for var in item.all_variations %}
<tr class="variation {% if tup.0 %}categorized{% endif %}">
<td>{{ var }}</td>
<td>{{ var.num.canceled|togglesum:request.event.currency }}</td>
<td>{{ var.num.expired|togglesum:request.event.currency }}</td>
<td>{{ var.num.pending|togglesum:request.event.currency }}</td>
<td>{{ var.num.paid|togglesum:request.event.currency }}</td>
<td>{{ var.num.total|togglesum:request.event.currency }}</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=c&amp;provider={{ item.provider }}">
{{ var.num.canceled|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=e&amp;provider={{ item.provider }}">
{{ var.num.expired|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=n&amp;provider={{ item.provider }}">
{{ var.num.pending|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=p&amp;provider={{ item.provider }}">
{{ var.num.paid|togglesum:request.event.currency }}
</a>
</td>
<td>
{{ var.num.total|togglesum:request.event.currency }}
</td>
</tr>
{% endfor %}
{% endif %}

View File

@@ -58,9 +58,9 @@
{{ d.device_id }}
</td>
<td>
{% if d.initialized and not d.api_token %}<del>{% endif %}
{% if d.revoked %}<del>{% endif %}
{{ d.name }}
{% if d.initialized and not d.api_token %}</del>{% endif %}
{% if d.revoked %}</del>{% endif %}
</td>
<td>
{{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }}
@@ -74,7 +74,7 @@
{% else %}
<em>{% trans "Not yet initialized" %}</em>
{% endif %}
{% if d.initialized and not d.api_token %}
{% if d.revoked %}
<span class="label label-danger">{% trans "Revoked" %}</span>
{% endif %}
</td>

View File

@@ -434,6 +434,12 @@
</button>
</p>
</fieldset>
<fieldset>
<legend>{% trans "Additional settings" %}</legend>
{% for f in plugin_forms %}
{% bootstrap_form f layout="control" %}
{% endfor %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -192,6 +192,12 @@
</button>
</p>
</fieldset>
<fieldset>
<legend>{% trans "Additional settings" %}</legend>
{% for f in plugin_forms %}
{% bootstrap_form f layout="control" %}
{% endfor %}
</fieldset>
</div>
{% if subevent.pk %}
<div class="col-xs-12 col-lg-2">

View File

@@ -11,15 +11,35 @@
smartphone or a hardware token generator and that changes on a regular basis.
{% endblocktrans %}
</p>
{% if settings.PRETIX_OBLIGATORY_2FA %}
<div class="panel panel-warning">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Obligatory usage of two-factor authentication" %}</h3>
</div>
<div class="panel-body">
<p>
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
</p>
{% if not devices %}
<p>{% trans "Please set up at least one device below." %}</p>
{% elif not user.require_2fa %}
<p>{% trans "Please activate two-factor authentication using the button below." %}</p>
{% endif %}
</div>
</div>
{% endif %}
{% if user.require_2fa %}
<div class="panel panel-success">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Two-factor status" %}</h3>
</div>
<div class="panel-body">
<a href="{% url "control:user.settings.2fa.disable" %}" class="btn btn-primary pull-right">
{% trans "Disable" %}
</a>
{% if not settings.PRETIX_OBLIGATORY_2FA %}
<a href="{% url "control:user.settings.2fa.disable" %}" class="btn btn-primary pull-right">
{% trans "Disable" %}
</a>
{% endif %}
<p>
<strong>{% trans "Two-factor authentication is currently enabled." %}</strong>
</p>

View File

@@ -39,6 +39,7 @@
<legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.max_usages layout="control" %}
{% bootstrap_field form.valid_until layout="control" %}
{% bootstrap_field form.itemvar layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
@@ -48,7 +49,6 @@
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
{% bootstrap_field form.itemvar layout="control" %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="controls">
@@ -72,6 +72,7 @@
{% bootstrap_field form.allow_ignore_quota layout="control" %}
{% bootstrap_field form.tag layout="control" %}
{% bootstrap_field form.comment layout="control" %}
{% bootstrap_field form.show_hidden_items layout="control" %}
</fieldset>
{% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %}
<div class="form-group submit-group">

View File

@@ -41,6 +41,7 @@
{% endif %}
{% bootstrap_field form.max_usages layout="control" %}
{% bootstrap_field form.valid_until layout="control" %}
{% bootstrap_field form.itemvar layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
@@ -50,7 +51,6 @@
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
{% bootstrap_field form.itemvar layout="control" %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="controls">
@@ -74,6 +74,7 @@
{% bootstrap_field form.allow_ignore_quota layout="control" %}
{% bootstrap_field form.tag layout="control" %}
{% bootstrap_field form.comment layout="control" %}
{% bootstrap_field form.show_hidden_items layout="control" %}
</fieldset>
{% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %}
</div>

View File

@@ -140,6 +140,7 @@ urlpatterns = [
url(r'^pdf/editor/(?P<filename>[^/]+).pdf$', pdf.PdfView.as_view(), name='pdf.background'),
url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'),
url(r'^subevents/select2$', typeahead.subevent_select2, name='event.subevents.select2'),
url(r'^seats/select2$', typeahead.seat_select2, name='event.seats.select2'),
url(r'^subevents/(?P<subevent>\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'),
url(r'^subevents/(?P<subevent>\d+)/delete$', subevents.SubEventDelete.as_view(),
name='event.subevent.delete'),

View File

@@ -25,6 +25,7 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemUpdateForm, ItemVariationForm,
@@ -33,7 +34,7 @@ from pretix.control.forms.item import (
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
)
from pretix.control.signals import item_forms
from pretix.control.signals import item_forms, nav_item
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -640,7 +641,7 @@ class QuotaView(ChartContainingView, DetailView):
]
sum_values = sum([d['value'] for d in data if d['sum']])
s = self.object.size - sum_values if self.object.size is not None else 0
s = self.object.size - sum_values if self.object.size is not None else ugettext('Infinite')
data.append({
'label': ugettext('Available quota'),
@@ -666,6 +667,17 @@ class QuotaView(ChartContainingView, DetailView):
ctx['quota_table_rows'] = list(data)
ctx['quota_overbooked'] = sum_values - self.object.size if self.object.size is not None else 0
ctx['has_plugins'] = False
res = (
Quota.AVAILABILITY_GONE if self.object.size is not None and self.object.size - sum_values <= 0 else
Quota.AVAILABILITY_OK,
self.object.size - sum_values if self.object.size is not None else None
)
for recv, resp in quota_availability.send(sender=self.request.event, quota=self.object, result=res,
count_waitinglist=True):
if resp != res:
ctx['has_plugins'] = True
ctx['has_ignore_vouchers'] = Voucher.objects.filter(
Q(allow_ignore_quota=True) &
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now())) &
@@ -782,6 +794,18 @@ class ItemDetailMixin(SingleObjectMixin):
model = Item
context_object_name = 'item'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
nav = sorted(
sum(
(list(a[1]) for a in nav_item.send(self.request.event, request=self.request, item=self.get_object())),
[]
),
key=lambda r: str(r['label'])
)
ctx['extra_nav'] = nav
return ctx
def get_object(self, queryset=None) -> Item:
try:
if not hasattr(self, 'object') or not self.object:

View File

@@ -1236,7 +1236,8 @@ class OrderChange(OrderView):
ocm.add_position(item, variation,
self.add_form.cleaned_data['price'],
self.add_form.cleaned_data.get('addon_to'),
self.add_form.cleaned_data.get('subevent'))
self.add_form.cleaned_data.get('subevent'),
self.add_form.cleaned_data.get('seat'))
except OrderError as e:
self.add_form.custom_error = str(e)
return False
@@ -1266,6 +1267,9 @@ class OrderChange(OrderView):
if item != p.item or variation != p.variation:
ocm.change_item(p, item, variation)
if p.seat and p.form.cleaned_data['seat'] and p.form.cleaned_data['seat'] != p.seat:
ocm.change_seat(p, p.form.cleaned_data['seat'])
if self.request.event.has_subevents and p.form.cleaned_data['subevent'] and p.form.cleaned_data['subevent'] != p.subevent:
ocm.change_subevent(p, p.form.cleaned_data['subevent'])

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset
from django.contrib import messages
from django.core.files import File
from django.db import transaction
from django.db.models import F, IntegerField, OuterRef, Prefetch, Subquery, Sum
from django.db.models.functions import Coalesce
@@ -32,6 +33,7 @@ from pretix.control.forms.subevents import (
SubEventMetaValueForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import subevent_forms
from pretix.control.views import PaginationMixin
from pretix.control.views.event import MetaDataEditorMixin
from pretix.helpers.models import modelcopy
@@ -135,6 +137,16 @@ class SubEventEditorMixin(MetaDataEditorMixin):
meta_form = SubEventMetaValueForm
meta_model = SubEventMetaValue
@cached_property
def plugin_forms(self):
forms = []
for rec, resp in subevent_forms.send(sender=self.request.event, subevent=self.object, request=self.request):
if isinstance(resp, (list, tuple)):
forms.extend(resp)
else:
forms.append(resp)
return forms
def _make_meta_form(self, p, val_instances):
if not hasattr(self, '_default_meta'):
self._default_meta = self.request.event.meta_data
@@ -294,6 +306,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
ctx['cl_formset'] = self.cl_formset
ctx['itemvar_forms'] = self.itemvar_forms
ctx['meta_forms'] = self.meta_forms
ctx['plugin_forms'] = self.plugin_forms
return ctx
@cached_property
@@ -347,7 +360,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
def is_valid(self, form):
return form.is_valid() and all([f.is_valid() for f in self.itemvar_forms]) and self.formset.is_valid() and (
all([f.is_valid() for f in self.meta_forms])
) and self.cl_formset.is_valid()
) and self.cl_formset.is_valid() and all(f.is_valid() for f in self.plugin_forms)
class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateView):
@@ -361,9 +374,9 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
self.object = self.get_object()
form = self.get_form()
if self.is_valid(form):
return self.form_valid(form)
else:
return self.form_invalid(form)
r = self.form_valid(form)
return r
return self.form_invalid(form)
def get_object(self, queryset=None) -> SubEvent:
try:
@@ -384,12 +397,23 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
# TODO: LogEntry?
messages.success(self.request, _('Your changes have been saved.'))
if form.has_changed():
if form.has_changed() or any(f.has_changed() for f in self.plugin_forms):
data = {
k: form.cleaned_data.get(k) for k in form.changed_data
}
for f in self.plugin_forms:
data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
})
self.object.log_action(
'pretix.subevent.changed', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
'pretix.subevent.changed', user=self.request.user, data=data
)
for f in self.plugin_forms:
f.subevent = self.object
f.save()
return super().form_valid(form)
def get_success_url(self) -> str:
@@ -416,8 +440,7 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
form = self.get_form()
if self.is_valid(form):
return self.form_valid(form)
else:
return self.form_invalid(form)
return self.form_invalid(form)
def get_success_url(self) -> str:
return reverse('control:event.subevents', kwargs={
@@ -442,7 +465,16 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
messages.success(self.request, pgettext_lazy('subevent', 'The new date has been created.'))
ret = super().form_valid(form)
self.object = form.instance
form.instance.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user)
data = dict(form.cleaned_data)
for f in self.plugin_forms:
data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.cleaned_data
})
form.instance.log_action('pretix.subevent.added', data=dict(data), user=self.request.user)
self.save_formset(form.instance)
self.save_cl_formset(form.instance)
@@ -452,6 +484,9 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
for f in self.meta_forms:
f.instance.subevent = form.instance
self.save_meta()
for f in self.plugin_forms:
f.subevent = form.instance
f.save()
return ret
@cached_property
@@ -657,7 +692,6 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
@transaction.atomic
def form_valid(self, form):
tz = self.request.event.timezone
cnt = 0
for rdate in self.get_rrule_set():
@@ -685,7 +719,15 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
else None
)
se.save()
se.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user)
data = dict(form.cleaned_data)
for f in self.plugin_forms:
data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.cleaned_data
})
se.log_action('pretix.subevent.added', data=data, user=self.request.user)
for f in self.meta_forms:
if f.cleaned_data.get('value'):
@@ -731,6 +773,11 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
i.subevent = se
i.save()
for f in self.plugin_forms:
f.is_valid()
f.subevent = se
f.save()
cnt += 1
messages.success(self.request, pgettext_lazy('subevent', '{} new dates have been created.').format(cnt))
@@ -742,7 +789,8 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = SubEvent(event=self.request.event)
if self.is_valid(form):
return self.form_valid(form)
else:
return self.form_invalid(form)
return self.form_invalid(form)

View File

@@ -11,7 +11,7 @@ from django.utils.formats import get_format
from django.utils.timezone import make_aware
from django.utils.translation import pgettext, ugettext as _
from pretix.base.models import Organizer, User
from pretix.base.models import Order, Organizer, SubEvent, User, Voucher
from pretix.control.forms.event import EventWizardCopyForm
from pretix.control.permissions import event_permission_required
from pretix.helpers.daterange import daterange
@@ -67,6 +67,32 @@ def serialize_event(e):
}
def serialize_order(o):
return {
'type': 'order',
'event': str(o.event),
'title': _('Order {}').format(str(o.code)),
'url': reverse('control:event.order', kwargs={
'event': o.event.slug,
'organizer': o.event.organizer.slug,
'code': o.code
})
}
def serialize_voucher(v):
return {
'type': 'voucher',
'event': str(v.event),
'title': _('Voucher {}').format(str(v.code)),
'url': reverse('control:event.voucher', kwargs={
'event': v.event.slug,
'organizer': v.event.organizer.slug,
'voucher': v.pk
})
}
def event_list(request):
query = request.GET.get('query', '')
try:
@@ -132,6 +158,28 @@ def nav_context_list(request):
if query:
qs_orga = qs_orga.filter(Q(name__icontains=query) | Q(slug__icontains=query))
if query:
qs_orders = Order.objects.filter(code__icontains=query).select_related('event', 'event__organizer')
if not request.user.has_active_staff_session(request.session.session_key):
qs_orders = qs_orders.filter(
Q(event__organizer_id__in=request.user.teams.filter(
all_events=True, can_view_orders=True).values_list('organizer', flat=True))
| Q(event_id__in=request.user.teams.filter(
can_view_orders=True).values_list('limit_events__id', flat=True))
)
qs_vouchers = Voucher.objects.filter(code__icontains=query).select_related('event', 'event__organizer')
if not request.user.has_active_staff_session(request.session.session_key):
qs_vouchers = qs_vouchers.filter(
Q(event__organizer_id__in=request.user.teams.filter(
all_events=True, can_view_vouchers=True).values_list('organizer', flat=True))
| Q(event_id__in=request.user.teams.filter(
can_view_vouchers=True).values_list('limit_events__id', flat=True))
)
else:
qs_vouchers = Voucher.objects.none()
qs_orders = Order.objects.none()
show_user = not query or (
query and request.user.email and query.lower() in request.user.email.lower()
) or (
@@ -146,15 +194,20 @@ def nav_context_list(request):
serialize_orga(e) for e in qs_orga[offset:offset + (pagesize if query else 5)]
] + [
serialize_event(e) for e in qs_events.select_related('organizer')[offset:offset + (pagesize if query else 5)]
] + [
serialize_order(e) for e in qs_orders[offset:offset + (pagesize if query else 5)]
] + [
serialize_voucher(e) for e in qs_vouchers[offset:offset + (pagesize if query else 5)]
]
if show_user and organizer:
try:
organizer = serialize_orga(Organizer.objects.get(pk=organizer))
organizer = Organizer.objects.get(pk=organizer)
except Organizer.DoesNotExist:
pass
else:
if request.user.has_organizer_permission(organizer, request):
if request.user.has_organizer_permission(organizer, request=request):
organizer = serialize_orga(organizer)
if organizer in results:
results.remove(organizer)
results.insert(1, organizer)
@@ -168,6 +221,46 @@ def nav_context_list(request):
return JsonResponse(doc)
@event_permission_required("can_view_orders")
def seat_select2(request, **kwargs):
query = request.GET.get('query', '')
try:
page = int(request.GET.get('page', '1'))
except ValueError:
page = 1
if request.event.has_subevents:
try:
qs = request.event.subevents.get(active=True, pk=request.GET.get('subevent', 0)).free_seats
except SubEvent.DoesNotExist:
qs = request.event.seats.none()
else:
qs = request.event.free_seats
qs = qs.filter(
Q(name__icontains=query) | Q(seat_guid__icontains=query)
).order_by('name').select_related('product', 'subevent')
total = qs.count()
pagesize = 20
offset = (page - 1) * pagesize
doc = {
'results': [
{
'id': e.pk,
'text': '{} ({})'.format(e.name, str(e.product)),
'product': e.product_id,
'event': str(e.subevent) if e.subevent else ''
}
for e in qs[offset:offset + pagesize]
],
'pagination': {
"more": total >= (offset + pagesize)
}
}
return JsonResponse(doc)
@event_permission_required(None)
def subevent_select2(request, **kwargs):
query = request.GET.get('query', '')

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668">
<path
style="fill:black"
d="m 1.9592032,1.8522629e-4 c -0.21468,0 -0.38861,0.17394000371 -0.38861,0.38861000371 0,0.21466 0.17393,0.38861 0.38861,0.38861 0.21468,0 0.3886001,-0.17395 0.3886001,-0.38861 0,-0.21467 -0.1739201,-0.38861000371 -0.3886001,-0.38861000371 z m 0.1049,0.84543000371 c -0.20823,-0.0326 -0.44367,0.12499 -0.39998,0.40462997 l 0.20361,1.01854 c 0.0306,0.15316 0.15301,0.28732 0.3483,0.28732 h 0.8376701 v 0.92708 c 0,0.29313 0.41187,0.29447 0.41187,0.005 v -1.19115 c 0,-0.14168 -0.0995,-0.29507 -0.29094,-0.29507 l -0.65578,-10e-4 -0.1757,-0.87644 C 2.3042533,0.95300523 2.1890432,0.86500523 2.0641032,0.84547523 Z m -0.58549,0.44906997 c -0.0946,-0.0134 -0.20202,0.0625 -0.17829,0.19172 l 0.18759,0.91054 c 0.0763,0.33956 0.36802,0.55914 0.66042,0.55914 h 0.6015201 c 0.21356,0 0.21448,-0.32143 -0.003,-0.32143 H 2.1954632 c -0.19911,0 -0.36364,-0.11898 -0.41341,-0.34107 l -0.17777,-0.87126 c -0.0165,-0.0794 -0.0688,-0.11963 -0.12557,-0.12764 z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-06-05 10:21+0000\n"
"POT-Creation-Date: 2019-06-25 09:08+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
@@ -250,7 +250,7 @@ msgstr[3] ""
msgstr[4] ""
msgstr[5] ""
#: pretix/static/pretixpresale/js/ui/main.js:201
#: pretix/static/pretixpresale/js/ui/main.js:207
msgid "Please enter a quantity for one of the ticket types."
msgstr ""
@@ -410,78 +410,83 @@ msgctxt "widget"
msgid "Previous month"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:48
msgid "Mo"
#: pretix/static/pretixpresale/js/widget/widget.js:47
msgctxt "widget"
msgid "Open seat selection"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:49
msgid "Tu"
msgid "Mo"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:50
msgid "We"
msgid "Tu"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:51
msgid "Th"
msgid "We"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:52
msgid "Fr"
msgid "Th"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:53
msgid "Sa"
msgid "Fr"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:54
msgid "Sa"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:55
msgid "Su"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:57
#: pretix/static/pretixpresale/js/widget/widget.js:58
msgid "January"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:58
#: pretix/static/pretixpresale/js/widget/widget.js:59
msgid "February"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:59
#: pretix/static/pretixpresale/js/widget/widget.js:60
msgid "March"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:60
#: pretix/static/pretixpresale/js/widget/widget.js:61
msgid "April"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:61
#: pretix/static/pretixpresale/js/widget/widget.js:62
msgid "May"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:62
#: pretix/static/pretixpresale/js/widget/widget.js:63
msgid "June"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:63
#: pretix/static/pretixpresale/js/widget/widget.js:64
msgid "July"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:64
#: pretix/static/pretixpresale/js/widget/widget.js:65
msgid "August"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:65
#: pretix/static/pretixpresale/js/widget/widget.js:66
msgid "September"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:66
#: pretix/static/pretixpresale/js/widget/widget.js:67
msgid "October"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:67
#: pretix/static/pretixpresale/js/widget/widget.js:68
msgid "November"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:68
#: pretix/static/pretixpresale/js/widget/widget.js:69
msgid "December"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-06-05 10:21+0000\n"
"POT-Creation-Date: 2019-06-25 09:08+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
@@ -241,7 +241,7 @@ msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] ""
msgstr[1] ""
#: pretix/static/pretixpresale/js/ui/main.js:201
#: pretix/static/pretixpresale/js/ui/main.js:207
msgid "Please enter a quantity for one of the ticket types."
msgstr ""
@@ -401,78 +401,83 @@ msgctxt "widget"
msgid "Previous month"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:48
msgid "Mo"
#: pretix/static/pretixpresale/js/widget/widget.js:47
msgctxt "widget"
msgid "Open seat selection"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:49
msgid "Tu"
msgid "Mo"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:50
msgid "We"
msgid "Tu"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:51
msgid "Th"
msgid "We"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:52
msgid "Fr"
msgid "Th"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:53
msgid "Sa"
msgid "Fr"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:54
msgid "Sa"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:55
msgid "Su"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:57
#: pretix/static/pretixpresale/js/widget/widget.js:58
msgid "January"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:58
#: pretix/static/pretixpresale/js/widget/widget.js:59
msgid "February"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:59
#: pretix/static/pretixpresale/js/widget/widget.js:60
msgid "March"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:60
#: pretix/static/pretixpresale/js/widget/widget.js:61
msgid "April"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:61
#: pretix/static/pretixpresale/js/widget/widget.js:62
msgid "May"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:62
#: pretix/static/pretixpresale/js/widget/widget.js:63
msgid "June"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:63
#: pretix/static/pretixpresale/js/widget/widget.js:64
msgid "July"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:64
#: pretix/static/pretixpresale/js/widget/widget.js:65
msgid "August"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:65
#: pretix/static/pretixpresale/js/widget/widget.js:66
msgid "September"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:66
#: pretix/static/pretixpresale/js/widget/widget.js:67
msgid "October"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:67
#: pretix/static/pretixpresale/js/widget/widget.js:68
msgid "November"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:68
#: pretix/static/pretixpresale/js/widget/widget.js:69
msgid "December"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-06-05 10:21+0000\n"
"POT-Creation-Date: 2019-06-25 09:08+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
@@ -243,7 +243,7 @@ msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
#: pretix/static/pretixpresale/js/ui/main.js:201
#: pretix/static/pretixpresale/js/ui/main.js:207
msgid "Please enter a quantity for one of the ticket types."
msgstr ""
@@ -403,78 +403,83 @@ msgctxt "widget"
msgid "Previous month"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:48
msgid "Mo"
#: pretix/static/pretixpresale/js/widget/widget.js:47
msgctxt "widget"
msgid "Open seat selection"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:49
msgid "Tu"
msgid "Mo"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:50
msgid "We"
msgid "Tu"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:51
msgid "Th"
msgid "We"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:52
msgid "Fr"
msgid "Th"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:53
msgid "Sa"
msgid "Fr"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:54
msgid "Sa"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:55
msgid "Su"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:57
#: pretix/static/pretixpresale/js/widget/widget.js:58
msgid "January"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:58
#: pretix/static/pretixpresale/js/widget/widget.js:59
msgid "February"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:59
#: pretix/static/pretixpresale/js/widget/widget.js:60
msgid "March"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:60
#: pretix/static/pretixpresale/js/widget/widget.js:61
msgid "April"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:61
#: pretix/static/pretixpresale/js/widget/widget.js:62
msgid "May"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:62
#: pretix/static/pretixpresale/js/widget/widget.js:63
msgid "June"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:63
#: pretix/static/pretixpresale/js/widget/widget.js:64
msgid "July"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:64
#: pretix/static/pretixpresale/js/widget/widget.js:65
msgid "August"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:65
#: pretix/static/pretixpresale/js/widget/widget.js:66
msgid "September"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:66
#: pretix/static/pretixpresale/js/widget/widget.js:67
msgid "October"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:67
#: pretix/static/pretixpresale/js/widget/widget.js:68
msgid "November"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:68
#: pretix/static/pretixpresale/js/widget/widget.js:69
msgid "December"
msgstr ""

File diff suppressed because it is too large Load Diff

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