Compare commits

...

111 Commits

Author SHA1 Message Date
Raphael Michel
a1c1df3e13 Bump to 3.17.2 2021-04-01 11:02:50 +02:00
Raphael Michel
027f133b5f Fix #2015 -- Shift operation into a later migration 2021-04-01 11:02:43 +02:00
Raphael Michel
05932495f0 GitLab: Move npm install command 2021-03-31 14:35:29 +02:00
Raphael Michel
ceae4b50b9 Fix missing manifest rules 2021-03-31 13:20:26 +02:00
Raphael Michel
60dcfe2308 Hotfix: Avoid infinite loop in migration at all cost 2021-03-31 13:09:09 +02:00
Raphael Michel
a911a2076d Bump to 3.17.0 2021-03-31 11:48:30 +02:00
Raphael Michel
da8470682e Add comments to explain magic numbers 2021-03-31 11:38:14 +02:00
Raphael Michel
ab61a9b190 Guard against integrity errors when saving questions 2021-03-31 11:32:54 +02:00
Raphael Michel
e668fbf3ba Fix Keyerror when updating event comment 2021-03-31 11:00:14 +02:00
Raphael Michel
8e76642372 Update Pillow dependency 2021-03-31 10:42:07 +02:00
Raphael Michel
24e785089a Run isort on setup.py 2021-03-31 10:40:18 +02:00
Raphael Michel
a6d4e26a3b Fix import order 2021-03-31 10:38:39 +02:00
Raphael Michel
bbcb41da2b Cart action views: Improve input validation 2021-03-31 10:38:10 +02:00
Raphael Michel
8101a9d8ae CartManager: Fix a cache handling bug 2021-03-31 10:37:49 +02:00
Raphael Michel
0945e96a4e Fix settings import 2021-03-31 10:33:31 +02:00
Raphael Michel
a08272571b Fix gaps in our email error handling 2021-03-31 10:05:15 +02:00
Raphael Michel
8131ecf378 Tax rule creation: Fix crash on invalid form submission 2021-03-31 09:48:42 +02:00
Raphael Michel
0155669379 Widget: Gracefully fail if custom CSS file is not found 2021-03-31 09:48:42 +02:00
Raphael Michel
1d6ed60f37 Auto-retry failed notifications 2021-03-31 09:48:42 +02:00
pretix translation bot
6fae931e6a Translations update from Weblate (#2014)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
2021-03-30 21:47:37 +02:00
dependabot[bot]
9a797036b3 Bump pug-code-gen from 2.0.2 to 2.0.3 in /src/pretix/static/npm_dir (#2012)
Bumps [pug-code-gen](https://github.com/pugjs/pug) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/pugjs/pug/releases)
- [Commits](https://github.com/pugjs/pug/compare/pug-code-gen@2.0.2...pug@2.0.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-30 21:45:15 +02:00
Raphael Michel
33ff7c39aa Fix PRETIXEU-3V3 2021-03-30 21:24:53 +02:00
Raphael Michel
0efd4fedd5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2021-03-30 18:15:59 +02:00
Raphael Michel
267e66bf83 Install npm as part of setup.py develop 2021-03-30 18:06:14 +02:00
Raphael Michel
a6c39b144c Revert to python 3.8 on docker for now 2021-03-30 14:59:26 +02:00
Raphael Michel
e7db4e7c42 Add npminstall to pip and docker build processes 2021-03-30 14:29:18 +02:00
Richard Schreiber
ba0849ea8d Add phone number to checkin-list export (#2013)
* add phone number to check-in export and tests
2021-03-30 12:51:07 +02:00
Raphael Michel
184a45b773 Update migration for MySQL compliance 2021-03-30 10:04:09 +02:00
Raphael Michel
2d4249ab31 Device security profiles: Allow POS access to cashier list 2021-03-30 09:34:52 +02:00
Raphael Michel
92a50cb2d1 Web-based check-in interface (#1985) 2021-03-30 09:34:11 +02:00
Raphael Michel
b06cded172 Organizer update form: Do not prefill with event-level domains 2021-03-29 14:23:48 +02:00
Raphael Michel
9686fd6a83 Fix a bug displaying quota in (sub)event list 2021-03-29 14:22:17 +02:00
Raphael Michel
b5b3d3a90b Fix PRETIXEU-3TX 2021-03-29 13:10:32 +02:00
Raphael Michel
9d0e6c0056 Fix failing tests 2021-03-29 12:49:59 +02:00
Raphael Michel
701d019a85 Fix bugs in previous commits 2021-03-29 12:26:06 +02:00
Raphael Michel
7d5170155a Add filter for date for multiple exporters 2021-03-29 12:05:56 +02:00
Raphael Michel
2b660ccbf7 Allow to enter a voucher before choosing a subevent 2021-03-29 11:04:57 +02:00
Raphael Michel
127c44d699 Do not count bundled products when computing event availability 2021-03-29 10:18:40 +02:00
Raphael Michel
d43e85da6d Add setting to hide sold-out timeslots 2021-03-29 10:18:25 +02:00
Raphael Michel
d3748a6194 Move quota cache from database to redis (#2010) 2021-03-29 09:42:27 +02:00
Raphael Michel
a927b47b8b Appease isort 2021-03-26 16:58:41 +01:00
Raphael Michel
5be09accf7 Set correct timezone when rendering emails 2021-03-26 12:47:36 +01:00
Raphael Michel
2c2a7e07f0 Fix bug caused by autocompletion 2021-03-26 09:57:37 +01:00
Raphael Michel
1e193ca58e Fix another notification bug 2021-03-25 14:05:57 +01:00
Raphael Michel
2dfed06dd0 Fix TypeError in a previous commit 2021-03-25 13:14:43 +01:00
Raphael Michel
2e0ddb630b Allow to export a list of gift cards 2021-03-24 18:38:15 +01:00
Raphael Michel
a6bd00a26a Order notifications: Include net sum and subevents 2021-03-24 16:33:33 +01:00
Richard Schreiber
53070f5d4b Cart: fix call to del if attribute is unknown when rendering a form label 2021-03-22 17:48:29 +01:00
Richard Schreiber
5685a349ea fix code style issues - missing whitespace around = operator 2021-03-22 17:05:13 +01:00
Richard Schreiber
1af69d5c76 Cart: Hide attendee information if not provided 2021-03-22 16:38:10 +01:00
Richard Schreiber
adddc7a71e A11y: add role=group and labels to multi-widgets (#2006)
* add role=group aria-labelledby to multiwidgets

* remove for-attribute from parent-label for grouped inputs

* add aria-labels to PhoneNumber-fields

* add aria-label to name multi-inputs
2021-03-22 15:19:29 +01:00
Richard Schreiber
11f23c3fd2 [a11y] Improved form error messages, descriptive labels, focusable toggle-link (#2002) 2021-03-19 16:13:25 +01:00
Raphael Michel
954fece6cf Log view: Page size selector 2021-03-19 10:49:03 +01:00
Richard Schreiber
8ef6adc3d5 A11y: make toggle-link for "view other date" focusable 2021-03-19 08:00:41 +01:00
Aksh Gupta
88ba7ab53a Refactor code quality issues (#2001) 2021-03-16 19:13:02 +01:00
Raphael Michel
eae55e4b5a Widget: Support button with subevent but without items 2021-03-16 19:01:43 +01:00
Raphael Michel
5ae839f62e Security Profile: Allow badge layouts for POS 2021-03-16 19:01:43 +01:00
Raphael Michel
7314d32422 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (4019 of 4019 strings)

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

powered by weblate
2021-03-16 17:30:23 +01:00
Maarten van den Berg
97d6ae8e55 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (4019 of 4019 strings)

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

powered by weblate
2021-03-16 17:30:23 +01:00
Maarten van den Berg
13063cb9d2 Translated on translate.pretix.eu (Dutch)
Currently translated at 99.3% (3993 of 4019 strings)

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

powered by weblate
2021-03-16 17:30:23 +01:00
Raphael Michel
2792813d95 Widget: Fix possible redirect loop 2021-03-16 17:26:20 +01:00
Martin Gross
d6aeefdf09 Add force-reactivate checkbox to order (#1997) 2021-03-16 16:49:37 +01:00
Raphael Michel
13056ef477 Widget: Do not prefill field with 0 2021-03-16 16:46:39 +01:00
Raphael Michel
6e2b5eae9a Widget: Open iframe even on mobile (to prevent breakage in WkWebView) 2021-03-16 16:16:59 +01:00
Raphael Michel
4cfb10b254 Widget: Make close icon independent of system font 2021-03-16 12:50:19 +01:00
Raphael Michel
ebd336e8cb Use new red color everywhere 2021-03-16 12:17:54 +01:00
Richard Schreiber
1357b010de [a11y] add missing labels on voucher-input and fix input.focus when revealing voucher-input via JS (#1998) 2021-03-16 12:17:47 +01:00
Richard Schreiber
09b2e69178 [a11y] Increase contrast on some colors for WCAG conformance (#1996)
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2021-03-16 12:10:37 +01:00
Raphael Michel
5e34032821 Fix #256 -- Allow exact filtering of voucher tags 2021-03-15 16:16:49 +01:00
Richard Schreiber
46cee890f0 QuestionAnswer: Add UNIQUE keys on (orderposition, question) and (cartposition, question) (#1994) 2021-03-15 15:34:33 +01:00
Raphael Michel
4a2ac110b3 Voucher bulk creation: More efficient implementation and async task 2021-03-14 18:19:49 +01:00
Raphael Michel
7eefd3dc59 Recommend upper-case index on pretixbase_voucher.code 2021-03-14 18:04:19 +01:00
Raphael Michel
fdca62685c Revert "Update Django to 3.1 as well as other dependencies"
This reverts commit b3c9dca024.
2021-03-12 10:52:02 +01:00
Raphael Michel
7ae38b5e97 Fix TypeError during invoice creation 2021-03-12 10:51:50 +01:00
Raphael Michel
76e9093fea Fix email sending during tests 2021-03-11 22:46:07 +01:00
Raphael Michel
b3c9dca024 Update Django to 3.1 as well as other dependencies 2021-03-11 21:59:04 +01:00
Raphael Michel
f4710cf019 Add index to documentation 2021-03-11 21:43:27 +01:00
Raphael Michel
5f192fd0ce Remove order status from emails 2021-03-11 17:56:28 +01:00
Raphael Michel
a897f60fc5 Fix crash during copying of check-in rules 2021-03-11 12:43:33 +01:00
Raphael Michel
74107781ce Email context: Auto-set position_or_address if position is set 2021-03-10 16:18:46 +01:00
Raphael Michel
ad219df7cf Fix incorrect attribute parameter in thumbnailed_file_input 2021-03-10 16:14:06 +01:00
Raphael Michel
002ab4aa06 runperiodic: --exclude 2021-03-09 11:50:19 +01:00
Raphael Michel
a84a726185 Try to avoid race condition when sending emails 2021-03-09 09:06:14 +01:00
Raphael Michel
5f58b93c71 Translated on translate.pretix.eu (German (informal) (de_Informal))
Currently translated at 100.0% (4019 of 4019 strings)

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

powered by weblate
2021-03-08 18:09:09 +01:00
Raphael Michel
3eaaf80c0a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (4019 of 4019 strings)

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

powered by weblate
2021-03-08 18:09:09 +01:00
Raphael Michel
3b5d811b27 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2021-03-08 17:40:02 +01:00
Raphael Michel
f0da2b7233 E-mails: add additional information on order positions 2021-03-08 16:50:38 +01:00
lapor-kris
d8d7440b52 Translated on translate.pretix.eu (Slovenian)
Currently translated at 27.1% (1085 of 4002 strings)

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

powered by weblate
2021-03-08 16:50:33 +01:00
Raphael Michel
a1ec9fceb0 Quota list exporter: Add subevent information 2021-03-08 14:47:41 +01:00
Raphael Michel
27ff73255b Add new fields to invoice model and API 2021-03-08 14:28:04 +01:00
Raphael Michel
bba103156c Allow to cancel an order without creating a cancellation invoice 2021-03-08 11:26:52 +01:00
Raphael Michel
f1a98b5c30 Add event_info_text parameters 2021-03-05 17:56:29 +01:00
Raphael Michel
405b3a22e1 Fix bug when changing quotas in subevent bulk editor 2021-03-05 13:05:04 +01:00
Raphael Michel
a51c2a36a6 SubEvent search: Fix inconsistent ordering 2021-03-05 11:57:24 +01:00
Richard Schreiber
8e00970f04 Waiting list: Add name and phone number (#1987)
* add name and phone to waitinglist

* add options whether to ask for name/phone in waitinglist

* changed rendermode to checkout and added required-css-class

* changed default to original behaviour to not ask name or phone at all

* add name and phone to list-view and export

* add name and phone to Meta-class so they automagically get saved

* update shredder

* fixed isort

* Translated on translate.pretix.eu (Slovenian)

Currently translated at 19.9% (799 of 3996 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Slovenian)

Currently translated at 21.6% (865 of 3996 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Slovenian)

Currently translated at 23.8% (955 of 3996 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Slovenian)

Currently translated at 26.3% (1051 of 3996 strings)

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

powered by weblate

* add validation to WaitingListSerializer

* update API-description

* fixed test_waitinglist.py

* Revert more of de597ba86

* Paginate list of gift cards

* Change texts on order confirmation page if no attachments are sent

* Update locales

* Added translation on translate.pretix.eu (Sinhala)

* Added translation on translate.pretix.eu (Sinhala)

* Translated on translate.pretix.eu (Sinhala)

Currently translated at 0.4% (18 of 4002 strings)

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

powered by weblate

* Fix initial value of phone number

* add colon to enumeration in description

Co-authored-by: Raphael Michel <michel@rami.io>

* update API-description with null-fields

* add name and phone to waitinglist

* add options whether to ask for name/phone in waitinglist

* changed rendermode to checkout and added required-css-class

* changed default to original behaviour to not ask name or phone at all

* add name and phone to list-view and export

* add name and phone to Meta-class so they automagically get saved

* update shredder

* fixed isort

* add validation to WaitingListSerializer

* update API-description

* fixed test_waitinglist.py

* Fix initial value of phone number

* update API-description with null-fields

* add colon to enumeration in description

Co-authored-by: Raphael Michel <michel@rami.io>

* fixed isort on migration

Co-authored-by: lapor-kris <kristijan.tkalec@posteo.si>
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
Co-authored-by: helabasa <R45XvezA@pm.me>
Co-authored-by: Raphael Michel <michel@rami.io>
2021-03-05 10:02:37 +01:00
Raphael Michel
8ca2fe7707 Stripe: Deal with conflicting settings 2021-03-04 20:13:10 +01:00
Raphael Michel
b93e2307d0 CachedFileField: Prevent double upload leading to empty file 2021-03-04 18:11:11 +01:00
Raphael Michel
97f3b72254 CachedFileInput: Fix links to download file 2021-03-04 14:54:11 +01:00
Panawat Wong-kleaw
00a77d3de9 Manual payment: Add amount placeholder (#1990) 2021-03-03 15:04:44 +01:00
helabasa
35d9a0dacf Translated on translate.pretix.eu (Sinhala)
Currently translated at 0.4% (18 of 4002 strings)

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

powered by weblate
2021-03-03 12:53:23 +01:00
helabasa
d2e6320e1e Added translation on translate.pretix.eu (Sinhala) 2021-03-03 12:53:23 +01:00
helabasa
671eb902a8 Added translation on translate.pretix.eu (Sinhala) 2021-03-03 12:53:23 +01:00
Raphael Michel
be67059099 Update locales 2021-03-02 18:42:39 +01:00
Raphael Michel
6e3791a49e Change texts on order confirmation page if no attachments are sent 2021-03-02 18:36:00 +01:00
Raphael Michel
e3bd665093 Paginate list of gift cards 2021-03-02 18:31:05 +01:00
Raphael Michel
748e2bb2fa Revert more of de597ba86 2021-03-02 18:26:01 +01:00
lapor-kris
b13b34f00d Translated on translate.pretix.eu (Slovenian)
Currently translated at 26.3% (1051 of 3996 strings)

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

powered by weblate
2021-03-02 11:23:51 +01:00
lapor-kris
641e3216d9 Translated on translate.pretix.eu (Slovenian)
Currently translated at 23.8% (955 of 3996 strings)

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

powered by weblate
2021-03-02 11:23:51 +01:00
lapor-kris
c70901c129 Translated on translate.pretix.eu (Slovenian)
Currently translated at 21.6% (865 of 3996 strings)

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

powered by weblate
2021-03-02 11:23:51 +01:00
lapor-kris
460d39b8c2 Translated on translate.pretix.eu (Slovenian)
Currently translated at 19.9% (799 of 3996 strings)

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

powered by weblate
2021-03-02 11:23:51 +01:00
Raphael Michel
a9963aead1 Fix import order 2021-03-02 10:54:11 +01:00
231 changed files with 105929 additions and 64820 deletions

View File

@@ -61,6 +61,9 @@ jobs:
- name: Run checks
run: python manage.py check
working-directory: ./src
- name: Install JS dependencies
working-directory: ./src
run: make npminstall
- name: Compile
working-directory: ./src
run: make all compress

View File

@@ -28,6 +28,7 @@ pypi:
- python -m pretix migrate
- python -m pretix check
- check-manifest
- make npminstall
- python setup.py sdist bdist_wheel
- twine check dist/*
- twine upload dist/*

View File

@@ -31,7 +31,11 @@ RUN apt-get update && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static && \
mkdir /etc/supervisord
mkdir /etc/supervisord && \
curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash - && \
apt-get install -y nodejs && \
curl -qL https://www.npmjs.com/install.sh | sh
ENV LC_ALL=C.UTF-8 \
DJANGO_SETTINGS_MODULE=production_settings

View File

@@ -60,6 +60,10 @@ Here is the currently recommended set of commands::
CREATE INDEX CONCURRENTLY pretix_addidx_ia_company
ON pretixbase_invoiceaddress
USING gin (upper("company") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_email_upper
ON public.pretixbase_orderposition (upper((attendee_email)::text));
CREATE INDEX CONCURRENTLY pretix_addidx_voucher_code_upper
ON public.pretixbase_voucher (upper((code)::text));
Also, if you use our ``pretix-shipping`` plugin::

View File

@@ -49,6 +49,10 @@ exit_all_at datetime Automatically c
The ``exit_all_at`` attribute has been added.
.. versionchanged:: 3.17
The ``ends_after`` and ``expand`` query parameters have been added.
Endpoints
---------
@@ -100,6 +104,8 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query integer subevent: Only return check-in lists of the sub-event with the given ID
:query integer subevent_match: Only return check-in lists that are valid for the sub-event with the given ID (i.e. also lists valid for all subevents)
:query string ends_after: Exclude all check-in lists attached to a sub-event that is already in the past at the given time.
:query string expand: Expand a field into a full object. Currently only ``subevent`` is supported. Can be passed multiple times.
:query string exclude: Exclude a field from the output, e.g. ``checkin_count``. Can be used as a performance optimization. Can be passed multiple times.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
@@ -447,6 +453,7 @@ Order position endpoints
``attendee_name,positionid``
:query string order: Only return positions of the order with the given order code
:query string search: Fuzzy search matching the attendee name, order code, invoice address name as well as to the beginning of the secret.
:query string expand: Expand a field into a full object. Currently only ``subevent``, ``item``, and ``variation`` are supported. Can be passed multiple times.
:query integer item: Only return positions with the purchased item matching the given ID.
:query integer item__in: Only return positions with the purchased item matching one of the given comma-separated IDs.
:query integer variation: Only return positions with the purchased item variation matching the given ID.

View File

@@ -15,8 +15,24 @@ number string Invoice number
order string Order code of the order this invoice belongs to
is_cancellation boolean ``true``, if this invoice is the cancellation of a
different invoice.
invoice_from string Sender address
invoice_to string Receiver address
invoice_from_name string Sender address: Name
invoice_from string Sender address: Address lines
invoice_from_zipcode string Sender address: ZIP code
invoice_from_city string Sender address: City
invoice_from_country string Sender address: Country code
invoice_from_tax_id string Sender address: Local Tax ID
invoice_from_vat_id string Sender address: EU VAT ID
invoice_to string Full recipient address
invoice_to_company string Recipient address: Company name
invoice_to_name string Recipient address: Person name
invoice_to_street string Recipient address: Address lines
invoice_to_zipcode string Recipient address: ZIP code
invoice_to_city string Recipient address: City
invoice_to_state string Recipient address: State (only used in some countries)
invoice_to_country string Recipient address: Country code
invoice_to_vat_id string Recipient address: EU VAT ID
invoice_to_beneficiary string Invoice beneficiary
custom_field string Custom invoice address field
date date Invoice date
refers string Invoice number of an invoice this invoice refers to
(for example a cancellation refers to the invoice it
@@ -30,6 +46,31 @@ footer_text string Text to be prin
lines list of objects The actual invoice contents
├ position integer Number of the line within an invoice.
├ description string Text representing the invoice line (e.g. product name)
├ item integer Product used to create this line. Note that everything
about the product might have changed since the creation
of the invoice. Can be ``null`` for all invoice lines
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee).
├ variation integer Product variation used to create this line. Note that everything
about the product might have changed since the creation
of the invoice. Can be ``null`` for all invoice lines
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee).
├ event_date_from datetime Start date of the (sub)event this line was created for as it
was set during invoice creation. Can be ``null`` for all invoice
lines created before this was introduced as well as for lines in
an event series not created by a product (e.g. shipping or
cancellation fees).
├ event_date_to datetime End date of the (sub)event this line was created for as it
was set during invoice creation. Can be ``null`` for all invoice
lines created before this was introduced as well as for lines in
an event series not created by a product (e.g. shipping or
cancellation fees) as well as whenever the respective (sub)event
has no end date set.
├ attendee_name string Attendee name at time of invoice creation. Can be ``null`` if no
name was set or if names are configured to not be added to invoices.
├ gross_value money (string) Price including taxes
├ tax_value money (string) Tax amount included
├ tax_name string Name of used tax rate (e.g. "VAT")
@@ -50,6 +91,12 @@ internal_reference string Customer's refe
The attribute ``lines.number`` has been added.
.. versionchanged:: 3.17
The attribute ``invoice_to_*``, ``invoice_from_*``, ``custom_field``, ``lines.item``, ``lines.variation``, ``lines.event_date_from``,
``lines.event_date_to``, and ``lines.attendee_name`` have been added.
``refers`` now returns an invoice number including the prefix.
Endpoints
---------
@@ -83,8 +130,24 @@ Endpoints
"number": "SAMPLECONF-00001",
"order": "ABC12",
"is_cancellation": false,
"invoice_from": "Big Events LLC\nDemo street 12\nDemo town",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT ID: EU123456789",
"invoice_from_name": "Big Events LLC",
"invoice_from": "Demo street 12",
"invoice_from_zipcode":"",
"invoice_from_city":"Demo town",
"invoice_from_country":"US",
"invoice_from_tax_id":"",
"invoice_from_vat_id":"",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
"invoice_to_company": "Sample company",
"invoice_to_name": "John Doe",
"invoice_to_street": "Test street 12",
"invoice_to_zipcode": "12345",
"invoice_to_city": "Testington",
"invoice_to_state": null,
"invoice_to_country": "TE",
"invoice_to_vat_id": "EU123456789",
"invoice_to_beneficiary": "",
"custom_field": null,
"date": "2017-12-01",
"refers": null,
"locale": "en",
@@ -97,6 +160,11 @@ Endpoints
{
"position": 1,
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"event_date_from": "2017-12-27T10:00:00Z",
"event_date_to": null,
"attendee_name": null,
"gross_value": "23.00",
"tax_value": "0.00",
"tax_name": "VAT",
@@ -148,8 +216,24 @@ Endpoints
"number": "SAMPLECONF-00001",
"order": "ABC12",
"is_cancellation": false,
"invoice_from": "Big Events LLC\nDemo street 12\nDemo town",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT ID: EU123456789",
"invoice_from_name": "Big Events LLC",
"invoice_from": "Demo street 12",
"invoice_from_zipcode":"",
"invoice_from_city":"Demo town",
"invoice_from_country":"US",
"invoice_from_tax_id":"",
"invoice_from_vat_id":"",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
"invoice_to_company": "Sample company",
"invoice_to_name": "John Doe",
"invoice_to_street": "Test street 12",
"invoice_to_zipcode": "12345",
"invoice_to_city": "Testington",
"invoice_to_state": null,
"invoice_to_country": "TE",
"invoice_to_vat_id": "EU123456789",
"invoice_to_beneficiary": "",
"custom_field": null,
"date": "2017-12-01",
"refers": null,
"locale": "en",
@@ -162,6 +246,11 @@ Endpoints
{
"position": 1,
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"event_date_from": "2017-12-27T10:00:00Z",
"event_date_to": null,
"attendee_name": null,
"gross_value": "23.00",
"tax_value": "0.00",
"tax_name": "VAT",

View File

@@ -1,4 +1,4 @@
.. spelling:: fullname
.. spelling:: fullname checkin
.. _`rest-teams`:
@@ -32,6 +32,7 @@ can_view_orders boolean
can_change_orders boolean
can_view_vouchers boolean
can_change_vouchers boolean
can_checkin_orders boolean
===================================== ========================== =======================================================
Team member resource

View File

@@ -13,7 +13,10 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the waiting list entry
created datetime Creation date of the waiting list entry
name string Name of the user on the waiting list (or ``null``)
name_parts object of strings Decomposition of name of the user (or ``null``)
email string Email address of the user on the waiting list
phone string Phone number of the user on the waiting list (or ``null``)
voucher integer Internal ID of the voucher sent to this user. If
this field is set, the user has been sent a voucher
and is no longer waiting. If it is ``null``, the

View File

@@ -67,6 +67,10 @@ Then, create the local database::
A first user with username ``admin@localhost`` and password ``admin`` will be automatically
created.
You will also need to install a few JavaScript dependencies::
make npminstall
If you want to see pretix in a different language than English, you have to compile our language
files::

View File

@@ -59,6 +59,8 @@ Permissions separate into two areas:
* Can view vouchers This permission allows viewing the list of vouchers including the voucher codes themselves and
their redemption status.
* Can perform check-ins This permission allows using web-based features to perform ticket search and check-in.
* Can change vouchers This permission allows to create and modify vouchers in all their details. It only works
properly if the same users also have the "Can view vouchers" permission.

2
src/.gitignore vendored
View File

@@ -8,3 +8,5 @@ dist/
*.egg-info/
*.bak
pretix/static/jsi18n/
node_modules/

View File

@@ -26,3 +26,5 @@ recursive-include pretix/plugins/badges/templates *
recursive-include pretix/plugins/badges/static *
recursive-include pretix/plugins/returnurl/templates *
recursive-include pretix/plugins/returnurl/static *
recursive-include pretix/plugins/webcheckin/templates *
recursive-include pretix/plugins/webcheckin/static *

View File

@@ -6,13 +6,13 @@ localecompile:
./manage.py compilemessages
localegen:
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" $(LNGS)
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
staticfiles: jsi18n
./manage.py collectstatic --noinput
compress:
compress: npminstall
./manage.py compress
jsi18n: localecompile
@@ -23,3 +23,10 @@ test:
coverage:
coverage run -m py.test
npminstall:
# keep this in sync with setup.py!
mkdir -p pretix/static.dist/node_prefix/
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
npm install --prefix=pretix/static.dist/node_prefix

View File

@@ -1 +1 @@
__version__ = "3.17.0.dev0"
__version__ = "3.17.2"

View File

@@ -10,7 +10,7 @@ class FullAccessSecurityProfile:
class AllowListSecurityProfile:
allowlist = tuple()
allowlist = ()
def is_allowed(self, request):
key = (request.method, f"{request.resolver_match.namespace}:{request.resolver_match.url_name}")
@@ -95,6 +95,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:taxrule-list'),
('GET', 'api-v1:ticketlayout-list'),
('GET', 'api-v1:ticketlayoutitem-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:order-list'),
('POST', 'api-v1:order-list'),
('GET', 'api-v1:order-detail'),
@@ -112,6 +114,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
('GET', 'plugins:pretix_posbackend:poscashier-list'),
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'),

View File

@@ -46,8 +46,12 @@ class EventPermission(BasePermission):
else:
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if required_permission and required_permission not in request.eventpermset:
return False
if isinstance(required_permission, (list, tuple)):
if not any(p in request.eventpermset for p in required_permission):
return False
else:
if required_permission and required_permission not in request.eventpermset:
return False
elif 'organizer' in request.resolver_match.kwargs:
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request):
@@ -57,8 +61,12 @@ class EventPermission(BasePermission):
else:
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if required_permission and required_permission not in request.orgapermset:
return False
if isinstance(required_permission, (list, tuple)):
if not any(p in request.eventpermset for p in required_permission):
return False
else:
if required_permission and required_permission not in request.orgapermset:
return False
if isinstance(request.auth, OAuthAccessToken):
if not request.auth.allow_scopes(['write']) and request.method not in SAFE_METHODS:

View File

@@ -2,6 +2,7 @@ from django.utils.translation import gettext as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.models import CheckinList
@@ -20,6 +21,9 @@ class CheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True)
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')
if p[0] in self.fields:
@@ -50,4 +54,6 @@ class CheckinListSerializer(I18nAwareModelSerializer):
if channel not in get_all_sales_channels():
raise ValidationError(_('Unknown sales channel.'))
CheckinList.validate_rules(data.get('rules'))
return data

View File

@@ -309,7 +309,7 @@ class EventSerializer(I18nAwareModelSerializer):
# Item Meta properties
if item_meta_properties is not None:
current = [imp for imp in event.item_meta_properties.all()]
current = list(event.item_meta_properties.all())
for key, value in item_meta_properties.items():
prop = self.item_meta_props.get(key)
if prop in current:
@@ -614,6 +614,11 @@ class EventSettingsSerializer(SettingsSerializer):
'waiting_list_enabled',
'waiting_list_hours',
'waiting_list_auto',
'waiting_list_names_asked',
'waiting_list_names_required',
'waiting_list_phones_asked',
'waiting_list_phones_required',
'waiting_list_phones_explanation_text',
'max_items_per_order',
'reservation_time',
'contact_mail',
@@ -623,7 +628,9 @@ class EventSettingsSerializer(SettingsSerializer):
'redirect_to_checkout_directly',
'frontpage_subevent_ordering',
'event_list_type',
'event_list_available_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',

View File

@@ -18,18 +18,18 @@ class FormFieldWrapperField(serializers.Field):
simple_mappings = (
(forms.DateField, serializers.DateField, tuple()),
(forms.TimeField, serializers.TimeField, tuple()),
(forms.SplitDateTimeField, serializers.DateTimeField, tuple()),
(forms.DateTimeField, serializers.DateTimeField, tuple()),
(forms.DateField, serializers.DateField, ()),
(forms.TimeField, serializers.TimeField, ()),
(forms.SplitDateTimeField, serializers.DateTimeField, ()),
(forms.DateTimeField, serializers.DateTimeField, ()),
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
(forms.FloatField, serializers.FloatField, tuple()),
(forms.IntegerField, serializers.IntegerField, tuple()),
(forms.EmailField, serializers.EmailField, tuple()),
(forms.UUIDField, serializers.UUIDField, tuple()),
(forms.URLField, serializers.URLField, tuple()),
(forms.NullBooleanField, serializers.NullBooleanField, tuple()),
(forms.BooleanField, serializers.BooleanField, tuple()),
(forms.FloatField, serializers.FloatField, ()),
(forms.IntegerField, serializers.IntegerField, ()),
(forms.EmailField, serializers.EmailField, ()),
(forms.UUIDField, serializers.UUIDField, ()),
(forms.URLField, serializers.URLField, ()),
(forms.NullBooleanField, serializers.NullBooleanField, ()),
(forms.BooleanField, serializers.BooleanField, ()),
)

View File

@@ -13,7 +13,11 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer,
)
from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
@@ -45,6 +49,14 @@ class CompatibleCountryField(serializers.Field):
return instance.country_old
class CountryField(serializers.Field):
def to_internal_value(self, data):
return {self.field_name: Country(data)}
def to_representation(self, src):
return str(src) if src else None
class InvoiceAddressSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*')
name = serializers.CharField(required=False)
@@ -341,8 +353,9 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields.pop('pdf_data')
request = self.context.get('request')
if not request or not self.context['request'].query_params.get('pdf_data', 'false') == 'true' or 'can_view_orders' not in request.eventpermset:
self.fields.pop('pdf_data', None)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
@@ -476,6 +489,18 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
'order__status')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True)
if 'item' in self.context['request'].query_params.getlist('expand'):
self.fields['item'] = ItemSerializer(read_only=True)
if 'variation' in self.context['request'].query_params.getlist('expand'):
self.fields['variation'] = InlineItemVariationSerializer(read_only=True)
class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2
@@ -576,7 +601,7 @@ class OrderSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields['positions'].child.fields.pop('pdf_data')
self.fields['positions'].child.fields.pop('pdf_data', None)
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')
@@ -1322,17 +1347,24 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceLine
fields = ('position', 'description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
class InvoiceSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
refers = serializers.SlugRelatedField(slug_field='invoice_no', read_only=True)
refers = serializers.SlugRelatedField(slug_field='full_invoice_no', read_only=True)
lines = InlineInvoiceLineSerializer(many=True)
invoice_to_country = CountryField()
invoice_from_country = CountryField()
class Meta:
model = Invoice
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_to', 'date', 'refers', 'locale',
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
'custom_field', 'date', 'refers', 'locale',
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date',
'internal_reference')

View File

@@ -95,7 +95,7 @@ class TeamSerializer(serializers.ModelSerializer):
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers'
'can_change_vouchers', 'can_checkin_orders'
)
def validate(self, data):

View File

@@ -8,7 +8,7 @@ class WaitingListSerializer(I18nAwareModelSerializer):
class Meta:
model = WaitingListEntry
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent', 'priority')
fields = ('id', 'created', 'name', 'name_parts', 'email', 'phone', 'voucher', 'item', 'variation', 'locale', 'subevent', 'priority')
read_only_fields = ('id', 'created', 'voucher')
def validate(self, data):
@@ -32,4 +32,11 @@ class WaitingListSerializer(I18nAwareModelSerializer):
if availability[0] == 100:
raise ValidationError("This product is currently available.")
if data.get('name') and data.get('name_parts'):
raise ValidationError(
{'name': ['Do not specify name if you specified name_parts.']}
)
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = event.settings.name_scheme
return data

View File

@@ -25,13 +25,14 @@ from pretix.base.models import (
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
)
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, perform_checkin,
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
)
from pretix.helpers.database import FixedOrderBy
with scopes_disabled():
class CheckinListFilter(FilterSet):
subevent_match = django_filters.NumberFilter(method='subevent_match_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta:
model = CheckinList
@@ -42,19 +43,35 @@ with scopes_disabled():
Q(subevent_id=value) | Q(subevent_id__isnull=True)
)
def ends_after_qs(self, queryset, name, value):
expr = (
Q(subevent__isnull=True) |
Q(
Q(Q(subevent__date_to__isnull=True) & Q(subevent__date_from__gte=value))
| Q(Q(subevent__date_to__isnull=False) & Q(subevent__date_to__gte=value))
)
)
return queryset.filter(expr)
class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
queryset = CheckinList.objects.none()
filter_backends = (DjangoFilterBackend,)
filterset_class = CheckinListFilter
permission = 'can_view_orders'
permission = ('can_view_orders', 'can_checkin_orders',)
write_permission = 'can_change_event_settings'
def get_queryset(self):
qs = self.request.event.checkin_lists.prefetch_related(
'limit_products',
)
if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values'
)
return qs
def perform_create(self, serializer):
@@ -155,15 +172,37 @@ class CheckinListViewSet(viewsets.ModelViewSet):
with scopes_disabled():
class CheckinOrderPositionFilter(OrderPositionFilter):
check_rules = django_filters.rest_framework.BooleanFilter(method='check_rules_qs')
# check_rules is currently undocumented on purpose, let's get a feel for the performance impact first
def __init__(self, *args, **kwargs):
self.checkinlist = kwargs.pop('checkinlist')
super().__init__(*args, **kwargs)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value)
def check_rules_qs(self, queryset, name, value):
if not self.checkinlist.rules:
return queryset
return queryset.filter(SQLLogic(self.checkinlist).apply(self.checkinlist.rules))
class ExtendedBackend(DjangoFilterBackend):
def get_filterset_kwargs(self, request, queryset, view):
kwargs = super().get_filterset_kwargs(request, queryset, view)
# merge filterset kwargs provided by view class
if hasattr(view, 'get_filterset_kwargs'):
kwargs.update(view.get_filterset_kwargs())
return kwargs
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filter_backends = (ExtendedBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid')
ordering_fields = (
'order__code', 'order__datetime', 'positionid', 'attendee_name',
@@ -187,8 +226,13 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
}
filterset_class = CheckinOrderPositionFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
permission = ('can_view_orders', 'can_checkin_orders')
write_permission = ('can_change_orders', 'can_checkin_orders')
def get_filterset_kwargs(self):
return {
'checkinlist': self.checkinlist,
}
@cached_property
def checkinlist(self):
@@ -209,7 +253,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
order__event=self.request.event,
).annotate(
last_checked_in=Subquery(cqs)
)
).prefetch_related('order__event', 'order__event__organizer')
if self.checkinlist.subevent:
qs = qs.filter(
subevent=self.checkinlist.subevent
@@ -255,6 +299,22 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
if not self.checkinlist.all_products and not ignore_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values'
)
if 'item' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values', 'item__variations').select_related('item__tax_rule')
if 'variation' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related('variation')
if 'pk' not in self.request.resolver_match.kwargs and 'can_view_orders' not in self.request.eventpermset \
and len(self.request.query_params.get('search', '')) < 3:
qs = qs.none()
return qs
@action(detail=False, methods=['POST'], url_name='redeem', url_path='(?P<pk>.*)/redeem')

View File

@@ -53,8 +53,8 @@ class DeviceSerializer(serializers.ModelSerializer):
class InitializeView(APIView):
authentication_classes = tuple()
permission_classes = tuple()
authentication_classes = ()
permission_classes = ()
def post(self, request, format=None):
serializer = InitializationRequestSerializer(data=request.data)

View File

@@ -259,7 +259,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
qs = filter_qs_by_attr(qs, self.request)
return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings', 'meta_values'
)
def list(self, request, **kwargs):

View File

@@ -49,7 +49,9 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons', 'bundles').all()
return self.request.event.items.select_related('tax_rule').prefetch_related(
'variations', 'addons', 'bundles', 'meta_values'
).all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)

View File

@@ -257,7 +257,7 @@ def register_default_webhook_events(sender, **kwargs):
)
@app.task(base=TransactionAwareTask, acks_late=True)
@app.task(base=TransactionAwareTask, max_retries=9, default_retry_delay=900, acks_late=True)
def notify_webhooks(logentry_ids: list):
if not isinstance(logentry_ids, list):
logentry_ids = [logentry_ids]

View File

@@ -2,10 +2,12 @@ import inspect
import logging
from datetime import timedelta
from decimal import Decimal
from itertools import groupby
from smtplib import SMTPResponseException
from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend
from django.db.models import Count
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils.timezone import now
@@ -128,9 +130,21 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if order:
htmlctx['order'] = order
positions = list(order.positions.select_related(
'item', 'variation', 'subevent', 'addon_to'
).annotate(
has_addons=Count('addons')
))
htmlctx['cart'] = [(k, list(v)) for k, v in groupby(
positions, key=lambda op: (
op.item, op.variation, op.subevent, op.attendee_name,
(op.pk if op.addon_to_id else None), (op.pk if op.has_addons else None)
)
)]
if position:
htmlctx['position'] = position
htmlctx['ev'] = position.subevent or self.event
tpl = get_template(self.template_name)
body_html = inline_css(tpl.render(htmlctx))
@@ -237,6 +251,8 @@ def get_email_context(**kwargs):
from pretix.base.models import InvoiceAddress
event = kwargs['event']
if 'position' in kwargs:
kwargs.setdefault("position_or_address", kwargs['position'])
if 'order' in kwargs:
try:
kwargs['invoice_address'] = kwargs['order'].invoice_address

View File

@@ -13,12 +13,12 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _, pgettext
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
from ..services.export import ExportError
from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import BaseExporter, MultiSheetListExporter
from ..services.export import ExportError
from ..services.invoices import invoice_pdf_task
from ..signals import (
register_data_exporters, register_multievent_data_exporters,

View File

@@ -1,18 +1,22 @@
from collections import OrderedDict
from decimal import Decimal
import dateutil
import pytz
from django import forms
from django.db.models import (
CharField, Count, DateTimeField, IntegerField, Max, OuterRef, Subquery,
Sum,
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
Q, Subquery, Sum, When,
)
from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext as _, gettext_lazy, pgettext
from pretix.base.models import (
GiftCard, Invoice, InvoiceAddress, Order, OrderPosition, Question,
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
OrderPosition, Question,
)
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.services.quotas import QuotaAvailability
@@ -45,28 +49,61 @@ class OrderListExporter(MultiSheetListExporter):
@property
def additional_form_fields(self):
return OrderedDict(
[
('paid_only',
forms.BooleanField(
label=_('Only paid orders'),
initial=True,
required=False
)),
('include_payment_amounts',
forms.BooleanField(
label=_('Include payment amounts'),
initial=False,
required=False
)),
('group_multiple_choice',
forms.BooleanField(
label=_('Show multiple choice answers grouped in one column'),
initial=False,
required=False
)),
]
)
d = [
('paid_only',
forms.BooleanField(
label=_('Only paid orders'),
initial=True,
required=False
)),
('include_payment_amounts',
forms.BooleanField(
label=_('Include payment amounts'),
initial=False,
required=False
)),
('group_multiple_choice',
forms.BooleanField(
label=_('Show multiple choice answers grouped in one column'),
initial=False,
required=False
)),
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders created on or after this date.')
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders issued on or before this date.')
)),
('event_date_from',
forms.DateField(
label=_('Start event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
'Will also include other dates in case of mixed orders!')
)),
('event_date_to',
forms.DateField(
label=_('End event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
'Will also include other dates in case of mixed orders!')
)),
]
d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents:
del d['event_date_from']
del d['event_date_to']
return d
def _get_all_payment_methods(self, qs):
pps = dict(get_all_payment_providers())
@@ -104,6 +141,52 @@ class OrderListExporter(MultiSheetListExporter):
def event_object_cache(self):
return {e.pk: e for e in self.events}
def _date_filter(self, qs, form_data, rel):
annotations = {}
filters = {}
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
annotations['date'] = TruncDate(f'{rel}datetime')
filters['date__gte'] = date_value
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
annotations['date'] = TruncDate(f'{rel}datetime')
filters['date__lte'] = date_value
if form_data.get('event_date_from'):
date_value = form_data.get('event_date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
annotations['event_date_max'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_max__gte'] = date_value
if form_data.get('event_date_to'):
date_value = form_data.get('event_date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
annotations['event_date_min'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_min__lte'] = date_value
if filters:
return qs.annotate(**annotations).filter(**filters)
return qs
def iterate_orders(self, form_data: dict):
p_date = OrderPayment.objects.filter(
order=OuterRef('pk'),
@@ -140,6 +223,9 @@ class OrderListExporter(MultiSheetListExporter):
invoice_numbers=Subquery(i_numbers, output_field=CharField()),
pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address')
qs = self._date_filter(qs, form_data, rel='')
if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID)
tax_rates = self._get_all_tax_rates(qs)
@@ -305,6 +391,8 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = self._date_filter(qs, form_data, rel='order__')
headers = [
_('Event slug'),
_('Order code'),
@@ -403,6 +491,8 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = self._date_filter(qs, form_data, rel='order__')
has_subevents = self.events.filter(has_subevents=True).exists()
headers = [
@@ -691,13 +781,18 @@ class QuotaListExporter(ListExporter):
verbose_name = gettext_lazy('Quota availabilities')
def iterate_list(self, form_data):
has_subevents = self.event.has_subevents
headers = [
_('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'),
_('Current user\'s carts'), _('Waiting list'), _('Exited orders'), _('Current availability')
]
if has_subevents:
headers.append(pgettext('subevent', 'Date'))
headers.append(_('Start date'))
headers.append(_('End date'))
yield headers
quotas = list(self.event.quotas.all())
quotas = list(self.event.quotas.select_related('subevent'))
qa = QuotaAvailability(full_results=True)
qa.queue(*quotas)
qa.compute()
@@ -715,6 +810,18 @@ class QuotaListExporter(ListExporter):
qa.count_exited_orders[quota],
_('Infinite') if avail[1] is None else avail[1]
]
if has_subevents:
if quota.subevent:
row.append(quota.subevent.name)
row.append(quota.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
if quota.subevent.date_to:
row.append(quota.subevent.date_to.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
else:
row.append('')
else:
row.append('')
row.append('')
row.append('')
yield row
def get_filename(self):
@@ -765,6 +872,116 @@ class GiftcardRedemptionListExporter(ListExporter):
return '{}_giftcardredemptions'.format(self.event.slug)
def generate_GiftCardListExporter(organizer): # hackhack
class GiftcardListExporter(ListExporter):
identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards')
@property
def additional_form_fields(self):
return OrderedDict(
[
('date', forms.DateTimeField(
label=_('Show value at'),
initial=now(),
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
choices=(
('', _('All')),
('yes', _('Test mode')),
('no', _('Live')),
),
initial='no',
required=False
)),
('state', forms.ChoiceField(
label=_('Status'),
choices=(
('', _('All')),
('empty', _('Empty')),
('valid_value', _('Valid and with value')),
('expired_value', _('Expired and with value')),
('expired', _('Expired')),
),
initial='valid_value',
required=False
))
]
)
def iterate_list(self, form_data):
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
datetime__lte=form_data['date']
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = organizer.issued_gift_cards.filter(
issuance__lte=form_data['date']
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
)
if form_data.get('testmode') == 'yes':
qs = qs.filter(testmode=True)
elif form_data.get('testmode') == 'no':
qs = qs.filter(testmode=False)
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
elif form_data.get('state') == 'expired_value':
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date'])
headers = [
_('Gift card code'),
_('Test mode card'),
_('Creation date'),
_('Expiry date'),
_('Special terms and conditions'),
_('Currency'),
_('Current value'),
_('Created in order'),
_('Last invoice number of order'),
_('Last invoice date of order'),
]
yield headers
tz = get_current_timezone()
for obj in qs:
o = None
i = None
trans = list(obj.transactions.all())
if trans:
o = trans[0].order
if o:
invs = list(o.invoices.all())
if invs:
i = invs[-1]
row = [
obj.secret,
_('Yes') if obj.testmode else _('No'),
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
obj.conditions or '',
obj.currency,
obj.cached_value,
o.full_code if o else '',
i.number if i else '',
i.date.strftime('%Y-%m-%d') if i else '',
]
yield row
def get_filename(self):
return '{}_giftcards'.format(organizer.slug)
return GiftcardListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
def register_orderlist_exporter(sender, **kwargs):
return OrderListExporter
@@ -798,3 +1015,8 @@ def register_giftcardredemptionlist_exporter(sender, **kwargs):
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardredemptionlist")
def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
return GiftcardRedemptionListExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
return generate_GiftCardListExporter(sender)

View File

@@ -82,7 +82,9 @@ class WaitingListExporter(ListExporter):
headers = [
_('Date'),
_('Name'),
_('Email'),
_('Phone number'),
_('Product name'),
_('Variation'),
_('Event slug'),
@@ -117,7 +119,9 @@ class WaitingListExporter(ListExporter):
row = [
entry.created.astimezone(tz).strftime(datetime_format), # alternative: .isoformat(),
entry.name,
entry.email,
entry.phone,
str(entry.item) if entry.item else "",
str(entry.variation) if entry.variation else "",
entry.event.slug,

View File

@@ -100,7 +100,7 @@ class NamePartsWidget(forms.MultiWidget):
if not isinstance(value, list):
value = self.decompress(value)
output = []
final_attrs = self.build_attrs(attrs or dict())
final_attrs = self.build_attrs(attrs or {})
if 'required' in final_attrs:
del final_attrs['required']
id_ = final_attrs.get('id', None)
@@ -122,6 +122,8 @@ class NamePartsWidget(forms.MultiWidget):
these_attrs.pop('data-no-required-attr', None)
these_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
these_attrs['data-size'] = self.scheme['fields'][i][2]
if len(self.widgets) > 1:
these_attrs['aria-label'] = self.scheme['fields'][i][1]
else:
these_attrs = final_attrs
output.append(widget.render(name + '_%s' % i, widget_value, these_attrs, renderer=renderer))
@@ -220,7 +222,7 @@ class WrappedPhonePrefixSelect(Select):
country_name = locale.territories.get(country_code)
if country_name:
choices.append((prefix, "{} {}".format(country_name, prefix)))
super().__init__(choices=sorted(choices, key=lambda item: item[1]))
super().__init__(choices=sorted(choices, key=lambda item: item[1]), attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
def render(self, name, value, *args, **kwargs):
return super().render(name, value or self.initial, *args, **kwargs)
@@ -243,7 +245,10 @@ class WrappedPhonePrefixSelect(Select):
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def __init__(self, attrs=None, initial=None):
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput())
attrs = {
'aria-label': pgettext_lazy('phonenumber', 'Phone number (without international area code)')
}
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput(attrs=attrs))
super(PhoneNumberPrefixWidget, self).__init__(widgets, attrs)
def render(self, name, value, attrs=None, renderer=None):

View File

@@ -445,7 +445,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.custom_field:
story.append(Paragraph(
'{}: {}'.format(
bleach.clean(self.invoice.event.settings.invoice_address_custom_field, tags=[]).strip().replace('\n', '<br />\n'),
bleach.clean(str(self.invoice.event.settings.invoice_address_custom_field), tags=[]).strip().replace('\n', '<br />\n'),
bleach.clean(self.invoice.custom_field, tags=[]).strip().replace('\n', '<br />\n'),
),
self.stylesheet['Normal']
@@ -475,7 +475,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.introductory_text:
story.append(Paragraph(
bleach.clean(self.invoice.introductory_text, tags=[]).strip().replace('\n', '<br />\n'),
self.invoice.introductory_text,
self.stylesheet['Normal']
))
story.append(Spacer(1, 10 * mm))
@@ -578,13 +578,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.payment_provider_text:
story.append(Paragraph(
bleach.clean(self.invoice.payment_provider_text, tags=[]).strip().replace('\n', '<br />\n'),
self.invoice.payment_provider_text,
self.stylesheet['Normal']
))
if self.invoice.additional_text:
story.append(Paragraph(
bleach.clean(self.invoice.additional_text, tags=[]).strip().replace('\n', '<br />\n'),
self.invoice.additional_text,
self.stylesheet['Normal']
))
story.append(Spacer(1, 15 * mm))

View File

@@ -16,6 +16,8 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--tasks', action='store', type=str, help='Only execute the tasks with this name '
'(dotted path, comma separation)')
parser.add_argument('--exclude', action='store', type=str, help='Exclude the tasks with this name '
'(dotted path, comma separation)')
def handle(self, *args, **options):
verbosity = int(options['verbosity'])
@@ -28,6 +30,9 @@ class Command(BaseCommand):
if options.get('tasks'):
if name not in options.get('tasks').split(','):
continue
if options.get('exclude'):
if name in options.get('exclude').split(','):
continue
if verbosity > 1:
self.stdout.write(f'INFO Running {name}')

View File

@@ -0,0 +1,30 @@
# Generated by Django 3.0.10 on 2021-03-01 15:10
import jsonfallback.fields
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0176_auto_20210205_1512'),
]
operations = [
migrations.AddField(
model_name='waitinglistentry',
name='name_cached',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='waitinglistentry',
name='name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AddField(
model_name='waitinglistentry',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 3.0.12 on 2021-03-08 13:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0177_auto_20210301_1510'),
]
operations = [
migrations.AddField(
model_name='invoiceline',
name='attendee_name',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='invoiceline',
name='event_date_to',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='invoiceline',
name='item',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item'),
),
migrations.AddField(
model_name='invoiceline',
name='variation',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation'),
),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 3.0.10 on 2021-03-11 16:53
from django.db import migrations
def clean_duplicates(apps, schema_editor):
for i in range(100): # no infinite loops
# Double subquery to avoid MySQL error 1093
delete_options = """
DELETE
FROM pretixbase_questionanswer_options
WHERE questionanswer_id IN (
SELECT minid FROM (
SELECT MIN(qa.id) minid
FROM pretixbase_questionanswer qa
GROUP BY qa.cartposition_id, qa.orderposition_id, qa.question_id
HAVING COUNT(*) > 1
) AS tmptable
);
"""
delete_answers = """
DELETE
FROM pretixbase_questionanswer
WHERE pretixbase_questionanswer.id IN (
SELECT minid FROM (
SELECT MIN(qa.id) minid
FROM pretixbase_questionanswer qa
GROUP BY qa.cartposition_id, qa.orderposition_id, qa.question_id
HAVING COUNT(*) > 1
) AS tmptable
);
"""
with schema_editor.connection.cursor() as cursor:
cursor.execute(delete_options)
cursor.execute(delete_answers)
if cursor.rowcount == 0:
return
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0178_auto_20210308_1326'),
]
operations = [
migrations.RunPython(
clean_duplicates,
migrations.RunPython.noop,
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 3.0.12 on 2021-03-24 13:09
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0179_auto_20210311_1653'),
]
operations = [
migrations.AlterUniqueTogether(
name='questionanswer',
unique_together={('orderposition', 'question'), ('cartposition', 'question')},
),
migrations.RemoveField(
model_name='quota',
name='cached_availability_number',
),
migrations.RemoveField(
model_name='quota',
name='cached_availability_paid_orders',
),
migrations.RemoveField(
model_name='quota',
name='cached_availability_state',
),
migrations.RemoveField(
model_name='quota',
name='cached_availability_time',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.12 on 2021-03-29 08:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0180_auto_20210324_1309'),
]
operations = [
migrations.AddField(
model_name='team',
name='can_checkin_orders',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,4 +1,5 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
from django.utils.timezone import now
@@ -142,6 +143,54 @@ class CheckinList(LoggedModel):
def __str__(self):
return self.name
@classmethod
def validate_rules(cls, rules, seen_nonbool=False, depth=0):
# While we implement a full jsonlogic machine on Python-level, we also use the logic rules to generate
# SQL queries, which is not a full implementation of JSON logic right now, but makes some assumptions,
# e.g. it does not support something like (a AND b) == (c OR D)
# Every change to our supported JSON logic must be done
# * in pretix.base.services.checkin
# * in pretix.base.models.checkin
# * in checkinrules.js
# * in libpretixsync
top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
}
allowed_operators = top_level_operators | {
'buildTime', 'objectList', 'lookup', 'var',
}
allowed_vars = {
'product', 'variation', 'now', 'entries_number', 'entries_today', 'entries_days'
}
if not rules or not isinstance(rules, dict):
return
if len(rules) > 1:
raise ValidationError(f'Rules should not include dictionaries with more than one key, found: "{rules}".')
operator = list(rules.keys())[0]
if operator not in allowed_operators:
raise ValidationError(f'Logic operator "{operator}" is currently not allowed.')
if depth == 0 and operator not in top_level_operators:
raise ValidationError(f'Logic operator "{operator}" is currently not allowed on the first level.')
values = rules[operator]
if not isinstance(values, list) and not isinstance(values, tuple):
values = [values]
if operator == 'var':
if values[0] not in allowed_vars:
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
return
if operator in ('or', 'and') and seen_nonbool:
raise ValidationError(f'You cannot use OR/AND logic on a level below a comparison operator.')
for v in values:
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)
class Checkin(models.Model):
"""

View File

@@ -212,7 +212,8 @@ class EventMixin:
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
& Q(item__sales_channels__contains=channel)
& Q(item__hide_without_voucher=False) # TODO: does this make sense?
& Q(item__hide_without_voucher=False)
& Q(item__require_bundling=False)
& Q(quotas__pk=OuterRef('pk'))
).order_by().values_list('quotas__pk').annotate(
items=GroupConcat('pk', delimiter=',')
@@ -657,10 +658,6 @@ class Event(EventMixin, LoggedModel):
oldid = q.pk
q.pk = None
q.event = self
q.cached_availability_state = None
q.cached_availability_number = None
q.cached_availability_paid_orders = None
q.cached_availability_time = None
q.closed = False
q.save()
q.log_action('pretix.object.cloned')
@@ -697,9 +694,9 @@ class Event(EventMixin, LoggedModel):
for k, v in rules.items():
if k == 'lookup':
if v[0] == 'product':
v[1] = str(item_map.get(int(v[1]), 0).pk)
v[1] = str(item_map.get(int(v[1]), 0).pk) if int(v[1]) in item_map else "0"
elif v[0] == 'variation':
v[1] = str(variation_map.get(int(v[1]), 0).pk)
v[1] = str(variation_map.get(int(v[1]), 0).pk) if int(v[1]) in variation_map else "0"
else:
_walk_rules(v)
elif isinstance(rules, list):

View File

@@ -273,6 +273,14 @@ class InvoiceLine(models.Model):
:type subevent: SubEvent
:param event_date_from: Event date of the (sub)event at the time the invoice was created
:type event_date_from: datetime
:param event_date_to: Event end date of the (sub)event at the time the invoice was created
:type event_date_to: datetime
:param item: The item this line refers to
:type item: Item
:param variation: The variation this line refers to
:type variation: ItemVariation
:param attendee_name: The attendee name at the time the invoice was created
:type attendee_name: str
"""
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
position = models.PositiveIntegerField(default=0)
@@ -283,6 +291,10 @@ class InvoiceLine(models.Model):
tax_name = models.CharField(max_length=190)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
event_date_from = models.DateTimeField(null=True)
event_date_to = models.DateTimeField(null=True)
item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT)
variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT)
attendee_name = models.TextField(null=True, blank=True)
@property
def net_value(self):

View File

@@ -7,6 +7,7 @@ from typing import Tuple
import dateutil.parser
import pytz
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
@@ -17,6 +18,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country
from django_redis import get_redis_connection
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField
@@ -1086,17 +1088,23 @@ class Question(LoggedModel):
)
dependency_values = MultiStringField(default=[])
valid_number_min = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_number_max = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_date_min = models.DateField(null=True, blank=True,
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_date_max = models.DateField(null=True, blank=True,
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_datetime_min = models.DateTimeField(null=True, blank=True,
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_datetime_max = models.DateTimeField(null=True, blank=True,
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
objects = ScopedManager(organizer='event__organizer')
@@ -1374,10 +1382,6 @@ class Quota(LoggedModel):
blank=True,
verbose_name=_("Variations")
)
cached_availability_state = models.PositiveIntegerField(null=True, blank=True)
cached_availability_number = models.PositiveIntegerField(null=True, blank=True)
cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True)
cached_availability_time = models.DateTimeField(null=True, blank=True)
close_when_sold_out = models.BooleanField(
verbose_name=_('Close this quota permanently once it is sold out'),
@@ -1422,14 +1426,10 @@ class Quota(LoggedModel):
self.event.cache.clear()
def rebuild_cache(self, now_dt=None):
self.cached_availability_time = None
self.cached_availability_number = None
self.cached_availability_state = None
self.availability(now_dt=now_dt)
def cache_is_hot(self, now_dt=None):
now_dt = now_dt or now()
return self.cached_availability_time and (now_dt - self.cached_availability_time).total_seconds() < 120
if settings.HAS_REDIS:
rc = get_redis_connection("redis")
rc.hdel(f'quotas:{self.event_id}:availabilitycache', str(self.pk))
self.availability(now_dt=now_dt)
def availability(
self, now_dt: datetime=None, count_waitinglist=True, _cache=None, allow_cache=False
@@ -1452,9 +1452,6 @@ class Quota(LoggedModel):
"""
from ..services.quotas import QuotaAvailability
if allow_cache and self.cache_is_hot() and count_waitinglist:
return self.cached_availability_state, self.cached_availability_number
if _cache and count_waitinglist is not _cache.get('_count_waitinglist', True):
_cache.clear()
@@ -1462,7 +1459,7 @@ class Quota(LoggedModel):
return _cache[self.pk]
qa = QuotaAvailability(count_waitinglist=count_waitinglist, early_out=False)
qa.queue(self)
qa.compute(now_dt=now_dt)
qa.compute(now_dt=now_dt, allow_cache=allow_cache)
res = qa.results[self]
if _cache is not None:

View File

@@ -983,6 +983,9 @@ class QuestionAnswer(models.Model):
objects = ScopedManager(organizer='question__event__organizer')
class Meta:
unique_together = [['orderposition', 'question'], ['cartposition', 'question']]
@property
def backend_file_url(self):
if self.file:

View File

@@ -174,6 +174,8 @@ class Team(LoggedModel):
:type can_view_orders: bool
:param can_change_orders: If ``True``, the members can change details of orders of the associated events.
:type can_change_orders: bool
:param can_checkin_orders: If ``True``, the members can perform check-in related actions.
:type can_checkin_orders: bool
:param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events.
:type can_view_vouchers: bool
:param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events.
@@ -220,6 +222,12 @@ class Team(LoggedModel):
default=False,
verbose_name=_("Can change orders")
)
can_checkin_orders = models.BooleanField(
default=False,
verbose_name=_("Can perform check-ins"),
help_text=_('This includes searching for attendees, which can be used to obtain personal information about '
'attendees. Users with "can change orders" can also perform check-ins.')
)
can_view_vouchers = models.BooleanField(
default=False,
verbose_name=_("Can view vouchers")

View File

@@ -5,11 +5,14 @@ from django.db import models, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from jsonfallback.fields import FallbackJSONField
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import Voucher
from pretix.base.services.mail import mail
from pretix.base.settings import PERSON_NAME_SCHEMES
from .base import LoggedModel
from .event import Event, SubEvent
@@ -37,9 +40,21 @@ class WaitingListEntry(LoggedModel):
verbose_name=_("On waiting list since"),
auto_now_add=True
)
name_cached = models.CharField(
max_length=255,
verbose_name=_("Name"),
blank=True, null=True,
)
name_parts = FallbackJSONField(
blank=True, default=dict
)
email = models.EmailField(
verbose_name=_("E-mail address")
)
phone = PhoneNumberField(
null=True, blank=True,
verbose_name=_("Phone number")
)
voucher = models.ForeignKey(
'Voucher',
verbose_name=_("Assigned voucher"),
@@ -83,6 +98,27 @@ class WaitingListEntry(LoggedModel):
WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
WaitingListEntry.clean_subevent(self.event, self.subevent)
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields', [])
if 'name_parts' in update_fields:
update_fields.append('name_cached')
self.name_cached = self.name
if self.name_parts is None:
self.name_parts = {}
super().save(*args, **kwargs)
@property
def name(self):
if not self.name_parts:
return None
if '_legacy' in self.name_parts:
return self.name_parts['_legacy']
if '_scheme' in self.name_parts:
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
else:
scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
return scheme['concatenation'](self.name_parts).strip()
def send_voucher(self, quota_cache=None, user=None, auth=None):
availability = (
self.variation.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)

View File

@@ -1,5 +1,6 @@
import logging
from collections import OrderedDict, namedtuple
from itertools import groupby
from django.dispatch import receiver
from django.utils.formats import date_format
@@ -182,15 +183,33 @@ class ParametrizedOrderNotificationType(NotificationType):
n.add_attribute(pgettext_lazy('subevent', 'Dates'), '\n'.join(ses))
else:
n.add_attribute(_('Event date'), order.event.get_date_range_display())
positions = list(order.positions.select_related('item', 'variation', 'subevent'))
fees = list(order.fees.all())
n.add_attribute(_('Order code'), order.code)
n.add_attribute(_('Net total'), money_filter(sum([p.net_price for p in positions] + [f.net_value for f in fees]), logentry.event.currency))
n.add_attribute(_('Order total'), money_filter(order.total, logentry.event.currency))
n.add_attribute(_('Pending amount'), money_filter(order.pending_sum, logentry.event.currency))
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
n.add_attribute(_('Order date'), date_format(order.datetime.astimezone(logentry.event.timezone), 'SHORT_DATETIME_FORMAT'))
n.add_attribute(_('Order status'), order.get_status_display())
n.add_attribute(_('Order positions'), str(order.positions.count()))
def sortkey(op):
return op.item_id, op.variation_id, op.subevent_id
def groupkey(op):
return op.item, op.variation, op.subevent
cart = [(k, list(v)) for k, v in groupby(sorted(positions, key=sortkey), key=groupkey)]
items = []
for it in self.event.items.filter(id__in=order.positions.values_list('item', flat=True)):
items.append(str(it.name))
for (item, variation, subevent), pos in cart:
ele = [str(len(pos)) + 'x ' + str(item)]
if variation:
ele.append(str(variation.value))
if subevent:
ele.append(str(subevent))
items.append(' '.join(ele))
n.add_attribute(_('Purchased products'), '\n'.join(items))
n.add_action(_('View order details'), order_url)
return n

View File

@@ -970,17 +970,17 @@ class ManualPayment(BasePaymentProvider):
label=_('Payment process description in order confirmation emails'),
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
'mails. It should instruct the user on how to proceed with the payment. You can use '
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
)),
('pending_description', I18nFormField(
label=_('Payment process description for pending orders'),
help_text=_('This text will be shown on the order confirmation page for pending orders. '
'It should instruct the user on how to proceed with the payment. You can use '
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
)),
] + list(super().settings_form_fields.items())
)
@@ -1001,21 +1001,24 @@ class ManualPayment(BasePaymentProvider):
def checkout_confirm_render(self, request):
return self.payment_form_render(request)
def format_map(self, order):
def format_map(self, order, payment):
return {
'order': order.code,
'total': order.total,
'amount': payment.amount,
'currency': self.event.currency,
'total_with_currency': money_filter(order.total, self.event.currency)
'amount_with_currency': money_filter(payment.amount, self.event.currency),
# {total} and {total_with_currency} are deprecated
'total': order.total,
'total_with_currency': money_filter(order.total, self.event.currency),
}
def order_pending_mail_render(self, order) -> str:
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
def order_pending_mail_render(self, order, payment) -> str:
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order, payment))
return msg
def payment_pending_render(self, request, payment) -> str:
return rich_text(
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order))
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order, payment))
)

View File

@@ -298,6 +298,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Event organizer info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
}),
("event_info_text", {
"label": _("Event info text"),
"editor_sample": _("Event info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.event_info_text)
}),
("now_date", {
"label": _("Printing date"),
"editor_sample": _("2017-05-31"),

View File

@@ -207,7 +207,7 @@ class CartManager:
def _update_subevents_cache(self, se_ids: List[int]):
self._subevents_cache.update({
i.pk: i
for i in self.event.subevents.filter(id__in=[i for i in se_ids if i and i not in self._items_cache])
for i in self.event.subevents.filter(id__in=[i for i in se_ids if i and i not in self._subevents_cache])
})
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
@@ -241,7 +241,7 @@ class CartManager:
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
def _check_item_constraints(self, op, current_ops=[]):
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
if not (
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
@@ -863,7 +863,7 @@ class CartManager:
op.position.addons.all().delete()
op.position.delete()
elif isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
# Create a CartPosition for as much items as we can
requested_count = quota_available_count = voucher_available_count = op.count

View File

@@ -1,9 +1,15 @@
from datetime import timedelta
from functools import partial, reduce
import dateutil
import dateutil.parser
from django.core.files import File
from django.db import transaction
from django.db.models.functions import TruncDate
from django.db.models import (
BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q,
Subquery, Value,
)
from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import now, override
@@ -15,9 +21,18 @@ from pretix.base.models import (
)
from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_query import (
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
tolerance,
)
def get_logic_environment(ev):
# Every change to our supported JSON logic must be done
# * in pretix.base.services.checkin
# * in pretix.base.models.checkin
# * in checkinrules.js
# * in libpretixsync
def build_time(t=None, value=None):
if t == "custom":
return dateutil.parser.parse(value)
@@ -82,10 +97,181 @@ class LazyRuleVars:
tz = self._clist.event.timezone
with override(tz):
return self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).annotate(
day=TruncDate('datetime')
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
class SQLLogic:
"""
This is a simplified implementation of JSON logic that creates a Q-object to be used in a QuerySet.
It does not implement all operations supported by JSON logic and makes a few simplifying assumptions,
but all that can be created through our graphical editor. There's also CheckinList.validate_rules()
which tries to validate the same preconditions for rules set throught he API (probably not perfect).
Assumptions:
* Only a limited set of operators is used
* The top level operator is always a boolean operation (and, or) or a comparison operation (==, !=, …)
* Expression operators (var, lookup, buildTime) do not require further recursion
* Comparison operators (==, !=, …) never contain boolean operators (and, or) further down in the stack
"""
def __init__(self, list):
self.list = list
self.bool_ops = {
"and": lambda *args: reduce(lambda total, arg: total & arg, args),
"or": lambda *args: reduce(lambda total, arg: total | arg, args),
}
self.comparison_ops = {
"==": partial(self.comparison_to_q, operator=Equal),
"!=": partial(self.comparison_to_q, operator=Equal, negate=True),
">": partial(self.comparison_to_q, operator=GreaterThan),
">=": partial(self.comparison_to_q, operator=GreaterEqualThan),
"<": partial(self.comparison_to_q, operator=LowerThan),
"<=": partial(self.comparison_to_q, operator=LowerEqualThan),
"inList": partial(self.comparison_to_q, operator=InList),
"isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)),
"isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)),
}
self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var'}
def operation_to_expression(self, rule):
if not isinstance(rule, dict):
return rule
operator = list(rule.keys())[0]
values = rule[operator]
if not isinstance(values, list) and not isinstance(values, tuple):
values = [values]
if operator == 'buildTime':
if values[0] == "custom":
return Value(dateutil.parser.parse(values[1]))
elif values[0] == 'date_from':
return Coalesce(
F(f'subevent__date_from'),
F(f'order__event__date_from'),
)
elif values[0] == 'date_to':
return Coalesce(
F(f'subevent__date_to'),
F(f'subevent__date_from'),
F(f'order__event__date_to'),
F(f'order__event__date_from'),
)
elif values[0] == 'date_admission':
return Coalesce(
F(f'subevent__date_admission'),
F(f'subevent__date_from'),
F(f'order__event__date_admission'),
F(f'order__event__date_from'),
)
else:
raise ValueError(f'Unknown time type {values[0]}')
elif operator == 'objectList':
return [self.operation_to_expression(v) for v in values]
elif operator == 'lookup':
return int(values[1])
elif operator == 'var':
if values[0] == 'now':
return Value(now())
elif values[0] == 'product':
return F('item_id')
elif values[0] == 'variation':
return F('variation_id')
elif values[0] == 'entries_number':
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk
).values('position_id').order_by().annotate(
c=Count('*')
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif values[0] == 'entries_today':
midnight = now().astimezone(self.list.event.timezone).replace(hour=0, minute=0, second=0, microsecond=0)
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__gte=midnight,
).values('position_id').order_by().annotate(
c=Count('*')
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif values[0] == 'entries_days':
tz = self.list.event.timezone
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('position_id').order_by().annotate(
c=Count('day', distinct=True)
).values('c')
),
Value(0),
output_field=IntegerField()
)
else:
raise ValueError(f'Unknown operator {operator}')
def comparison_to_q(self, a, b, *args, operator, negate=False, modifier=None):
a = self.operation_to_expression(a)
b = self.operation_to_expression(b)
if modifier:
b = modifier(b, *args)
q = Q(
ExpressionWrapper(
operator(
a,
b,
),
output_field=BooleanField()
)
)
return ~q if negate else q
def apply(self, tests):
"""
Convert JSON logic to queryset info, returns an Q object and fills self.annotations
"""
if not tests:
return Q()
if isinstance(tests, bool):
# not really a legal configuration but used in the test suite
return Value(tests, output_field=BooleanField())
operator = list(tests.keys())[0]
values = tests[operator]
# Easy syntax for unary operators, like {"var": "x"} instead of strict
# {"var": ["x"]}
if not isinstance(values, list) and not isinstance(values, tuple):
values = [values]
if operator in self.bool_ops:
return self.bool_ops[operator](*[self.apply(v) for v in values])
elif operator in self.comparison_ops:
return self.comparison_ops[operator](*values)
else:
raise ValueError(f'Invalid operator {operator} on first level')
class CheckInError(Exception):
def __init__(self, msg, code):
self.msg = msg
@@ -207,7 +393,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'product'
)
elif op.order.status != Order.STATUS_PAID and not force and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
):
raise CheckInError(
_('This order is not marked as paid.'),

View File

@@ -171,9 +171,17 @@ def build_invoice(invoice: Invoice) -> Invoice:
if invoice.event.has_subevents:
desc += "<br />" + pgettext("subevent", "Date: {}").format(p.subevent)
InvoiceLine.objects.create(
position=i, invoice=invoice, description=desc,
gross_value=p.price, tax_value=p.tax_value,
subevent=p.subevent, event_date_from=(p.subevent.date_from if p.subevent else invoice.event.date_from),
position=i,
invoice=invoice,
description=desc,
gross_value=p.price,
tax_value=p.tax_value,
subevent=p.subevent,
item=p.item,
variation=p.variation,
attendee_name=p.attendee_name if invoice.event.settings.invoice_attendee_name else None,
event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to,
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
)
@@ -198,6 +206,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice=invoice,
description=fee_title,
gross_value=fee.value,
event_date_from=None if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=None if invoice.event.has_subevents else invoice.event.date_to,
tax_value=fee.tax_value,
tax_rate=fee.tax_rate,
tax_name=fee.tax_rule.name if fee.tax_rule else ''

View File

@@ -11,15 +11,19 @@ from typing import Any, Dict, List, Sequence, Union
from urllib.parse import urljoin, urlparse
import cssutils
import pytz
import requests
from bs4 import BeautifulSoup
from celery import chain
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.mail import (
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
)
from django.core.mail.message import SafeMIMEText
from django.db import transaction
from django.template.loader import get_template
from django.utils.timezone import override
from django.utils.translation import gettext as _, pgettext
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
@@ -144,6 +148,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
bcc = []
if event:
timezone = event.timezone
renderer = event.get_html_mail_renderer()
if event.settings.mail_bcc:
for bcc_mail in event.settings.mail_bcc.split(','):
@@ -202,19 +207,24 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
)
)
body_plain += "\r\n"
elif user:
timezone = pytz.timezone(user.timezone)
else:
timezone = pytz.timezone(settings.TIME_ZONE)
try:
if 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else:
# Backwards compatibility
warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order)
except:
logger.exception('Could not render HTML body')
body_html = None
with override(timezone):
try:
if 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else:
# Backwards compatibility
warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order)
except:
logger.exception('Could not render HTML body')
body_html = None
send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email),
@@ -240,7 +250,15 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
task_chain = []
task_chain.append(send_task)
chain(*task_chain).apply_async()
if 'locmem' in settings.EMAIL_BACKEND:
# This clause is triggered during unit tests, because transaction.on_commit never fires due to the nature
# Django's unit tests work
chain(*task_chain).apply_async()
else:
transaction.on_commit(
lambda: chain(*task_chain).apply_async()
)
class CustomEmail(EmailMultiAlternatives):
@@ -372,11 +390,25 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
try:
backend.send_messages([email])
except smtplib.SMTPResponseException as e:
except (smtplib.SMTPResponseException, smtplib.SMTPSenderRefused) 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 * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
logger.exception('Error sending email')
# Most likely temporary, retry again (but pretty soon)
try:
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
except MaxRetriesExceededError:
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'SMTP code {}, max retries exceeded'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
'recipient': '',
'invoices': [],
}
)
raise e
logger.exception('Error sending email')
if order:
order.log_action(
'pretix.event.order.email.error',
@@ -388,10 +420,51 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
}
)
raise SendMailException('Failed to send an email to {}.'.format(to))
except smtplib.SMTPRecipientsRefused as e:
smtp_codes = [a[0] for a in e.recipients.values()]
if not any(c >= 500 for c in smtp_codes):
# Not a permanent failure (mailbox full, service unavailable), retry later, but with large intervals
try:
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3) * 4) # max is 2 ** (4*3) * 4 = 16384 seconds = approx 4.5 hours
except MaxRetriesExceededError:
# ignore and go on with logging the error
pass
logger.exception('Error sending email')
if order:
message = []
for e, val in e.recipients.items():
message.append(f'{e}: {val[0]} {val[1].decode()}')
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'SMTP error',
'message': '\n'.join(message),
'recipient': '',
'invoices': [],
}
)
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
if isinstance(e, (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, ssl.SSLError, OSError)):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
try:
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
except MaxRetriesExceededError:
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'Internal error',
'message': 'Max retries exceeded',
'recipient': '',
'invoices': [],
}
)
raise e
if order:
order.log_action(
'pretix.event.order.email.error',

View File

@@ -13,7 +13,7 @@ from pretix.celery_app import app
from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask, acks_late=True)
@app.task(base=TransactionAwareTask, acks_late=True, max_retries=9, default_retry_delay=900)
@scopes_disabled()
def notify(logentry_ids: list):
if not isinstance(logentry_ids, list):
@@ -70,7 +70,7 @@ def notify(logentry_ids: list):
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
@app.task(base=ProfiledTask, acks_late=True)
@app.task(base=ProfiledTask, acks_late=True, max_retries=9, default_retry_delay=900)
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id)
if logentry.event:

View File

@@ -327,7 +327,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
cancellation_fee=None, keep_fees=None):
cancellation_fee=None, keep_fees=None, cancel_invoice=True):
"""
Mark this order as canceled
:param order: The order to change
@@ -351,9 +351,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
invoices = []
i = order.invoices.filter(is_cancellation=False).last()
if i and not i.refered.exists():
invoices.append(generate_cancellation(i))
if cancel_invoice:
i = order.invoices.filter(is_cancellation=False).last()
if i and not i.refered.exists():
invoices.append(generate_cancellation(i))
for position in order.positions.all():
for gc in position.issued_gift_cards.all():
@@ -403,7 +404,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
order.cancellation_date = now()
order.save(update_fields=['status', 'cancellation_date', 'total'])
if i:
if cancel_invoice and i:
invoices.append(generate_invoice(order))
else:
with order.event.lock():
@@ -2152,11 +2153,12 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
@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, refund_as_giftcard=False, comment=None):
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None,
cancel_invoice=True):
try:
try:
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
cancellation_fee)
cancellation_fee, cancel_invoice=cancel_invoice)
if try_auto_refund:
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
comment=comment)

View File

@@ -1,25 +1,22 @@
import sys
import time
from collections import Counter, defaultdict
from datetime import timedelta
from itertools import zip_longest
from django.conf import settings
from django.db import OperationalError, models
from django.db import models
from django.db.models import (
Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When,
)
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from django_redis import get_redis_connection
from pretix.base.models import (
CartPosition, Checkin, Event, LogEntry, Order, OrderPosition, Quota,
Voucher, WaitingListEntry,
CartPosition, Checkin, Order, OrderPosition, Quota, Voucher,
WaitingListEntry,
)
from pretix.celery_app import app
from ...helpers.periodic import minimum_interval
from ..signals import periodic_task, quota_availability
from ..signals import quota_availability
class QuotaAvailability:
@@ -89,7 +86,11 @@ class QuotaAvailability:
def queue(self, *quota):
self._queue += quota
def compute(self, now_dt=None):
def compute(self, now_dt=None, allow_cache=False, allow_cache_stale=False):
"""
Compute the queued quotas. If ``allow_cache`` is set, results may also be taken from a cache that might
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
"""
now_dt = now_dt or now()
quotas = list(set(self._queue))
quotas_original = list(self._queue)
@@ -97,6 +98,36 @@ class QuotaAvailability:
if not quotas:
return
if allow_cache:
if self._full_results:
raise ValueError("You cannot combine full_results and allow_cache.")
elif not self._count_waitinglist:
raise ValueError("If you set allow_cache, you need to set count_waitinglist.")
elif settings.HAS_REDIS:
rc = get_redis_connection("redis")
quotas_by_event = defaultdict(list)
for q in quotas_original:
quotas_by_event[q.event_id].append(q)
for eventid, evquotas in quotas_by_event.items():
d = rc.hmget(f'quotas:{eventid}:availabilitycache', [str(q.pk) for q in evquotas])
for redisval, q in zip(d, evquotas):
if redisval is not None:
data = [rv for rv in redisval.decode().split(',')]
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
if time.time() - int(data[2]) < 120 or allow_cache_stale:
quotas_original.remove(q)
quotas.remove(q)
if data[1] == "None":
self.results[q] = int(data[0]), None
else:
self.results[q] = int(data[0]), int(data[1])
if not quotas:
return
self._compute(quotas, now_dt)
for q in quotas_original:
@@ -105,36 +136,51 @@ class QuotaAvailability:
self.results[q] = resp
self._close(quotas)
try:
self._write_cache(quotas, now_dt)
except OperationalError as e:
# Ignore deadlocks when multiple threads try to write to the cache
if 'deadlock' not in str(e).lower():
raise e
self._write_cache(quotas, now_dt)
def _write_cache(self, quotas, now_dt):
if not settings.HAS_REDIS or not quotas:
return
rc = get_redis_connection("redis")
# We write the computed availability to redis in a per-event hash as
#
# quota_id -> (availability_state, availability_number, timestamp).
#
# We store this in a hash instead of inidividual values to avoid making two many redis requests
# which would introduce latency.
# The individual entries in the hash are "valid" for 120 seconds. This means in a typical peak scenario with
# high load *to a specific calendar or event*, lots of parallel web requests will receive an "expired" result
# around the same time, recompute quotas and write back to the cache. To avoid overloading redis with lots of
# simultaneous write queries for the same page, we place a very naive and simple "lock" on the write process for
# these quotas. We choose 10 seconds since that should be well above the duration of a write.
lock_name = '_'.join([str(p) for p in sorted([q.pk for q in quotas])])
if rc.exists(f'quotas:availabilitycachewrite:{lock_name}'):
return
rc.setex(f'quotas:availabilitycachewrite:{lock_name}', '1', 10)
update = defaultdict(list)
for q in quotas:
update[q.event_id].append(q)
for eventid, quotas in update.items():
rc.hmset(f'quotas:{eventid}:availabilitycache', {
str(q.id): ",".join(
[str(i) for i in self.results[q]] +
[str(int(time.time()))]
) for q in quotas
})
# To make sure old events do not fill up our redis instance, we set an expiry on the cache. However, we set it
# on 7 days even though we mostly ignore values older than 2 monites. The reasoning is that we have some places
# where we set allow_cache_stale and use the old entries anyways to save on performance.
rc.expire(f'quotas:{eventid}:availabilitycache', 3600 * 24 * 7)
# We used to also delete item_quota_cache:* from the event cache here, but as the cache
# gets more complex, this does not seem worth it. The cache is only present for up to
# 5 seconds to prevent high peaks, and a 5-second delay in availability is usually
# tolerable
update = []
for q in quotas:
rewrite_cache = self._count_waitinglist and (
not q.cache_is_hot(now_dt) or self.results[q][0] > q.cached_availability_state
or q.cached_availability_paid_orders is None
)
if rewrite_cache:
q.cached_availability_state = self.results[q][0]
q.cached_availability_number = self.results[q][1]
q.cached_availability_time = now_dt
if q in self.count_paid_orders:
q.cached_availability_paid_orders = self.count_paid_orders[q]
update.append(q)
if update:
Quota.objects.using('default').bulk_update(update, [
'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
'cached_availability_paid_orders'
], batch_size=50)
def _close(self, quotas):
for q in quotas:
@@ -404,44 +450,8 @@ class QuotaAvailability:
self.results[q] = Quota.AVAILABILITY_GONE, 0
@receiver(signal=periodic_task)
@minimum_interval(minutes_after_success=60)
def build_all_quota_caches(sender, **kwargs):
refresh_quota_caches.apply()
def grouper(iterable, n, fillvalue=None):
"""Collect data into fixed-length chunks or blocks"""
# grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
args = [iter(iterable)] * n
return zip_longest(fillvalue=fillvalue, *args)
@app.task
@scopes_disabled()
def refresh_quota_caches():
# Active events
active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter(
datetime__gt=now() - timedelta(days=7)
).order_by().values('event').annotate(
last_activity=Max('datetime')
)
for a in active:
try:
e = Event.objects.using(settings.DATABASE_REPLICA).get(pk=a['event'])
except Event.DoesNotExist:
continue
quotas = e.quotas.filter(
Q(cached_availability_time__isnull=True) |
Q(cached_availability_time__lt=a['last_activity']) |
Q(cached_availability_time__lt=now() - timedelta(hours=2))
).filter(
Q(subevent__isnull=True) |
Q(subevent__date_to__isnull=False, subevent__date_to__gte=now() - timedelta(days=14)) |
Q(subevent__date_from__gte=now() - timedelta(days=14))
)
for qs in grouper(quotas, 100, None):
qa = QuotaAvailability(early_out=False)
qa.queue(*[q for q in qs if q is not None])
qa.compute()

View File

@@ -3,22 +3,21 @@ from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import Event, User, Voucher
from pretix.base.models import Event, LogEntry, User, Voucher
from pretix.base.services.mail import mail
from pretix.base.services.tasks import TransactionAwareProfiledEventTask
from pretix.celery_app import app
@app.task(base=TransactionAwareProfiledEventTask, acks_late=True)
def vouchers_send(event: Event, vouchers: list, subject: str, message: str, recipients: list, user: int) -> None:
def vouchers_send(event: Event, vouchers: list, subject: str, message: str, recipients: list, user: int,
progress=None) -> None:
vouchers = list(Voucher.objects.filter(id__in=vouchers).order_by('id'))
user = User.objects.get(pk=user)
for r in recipients:
for ir, r in enumerate(recipients):
voucher_list = []
for i in range(r['number']):
voucher_list.append(vouchers.pop())
with language(event.settings.locale):
email_context = get_email_context(event=event, name=r.get('name') or '', voucher_list=[v.code for v in voucher_list])
email_context = get_email_context(event=event, name=r.get('name') or '',
voucher_list=[v.code for v in voucher_list])
mail(
r['email'],
subject,
@@ -27,14 +26,14 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
event,
locale=event.settings.locale,
)
logs = []
for v in voucher_list:
if r.get('tag') and r.get('tag') != v.tag:
v.tag = r.get('tag')
if v.comment:
v.comment += '\n\n'
v.comment = gettext('The voucher has been sent to {recipient}.').format(recipient=r['email'])
v.save(update_fields=['tag', 'comment'])
v.log_action(
logs.append(v.log_action(
'pretix.voucher.sent',
user=user,
data={
@@ -42,5 +41,11 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
'name': r.get('name'),
'subject': subject,
'message': message,
}
)
},
save=False
))
Voucher.objects.bulk_update(voucher_list, fields=['comment', 'tag'], batch_size=500)
LogEntry.objects.bulk_create(logs, batch_size=500)
if progress and ir % 50 == 0:
progress(ir / len(recipients))

View File

@@ -975,6 +975,61 @@ DEFAULTS = {
widget=forms.NumberInput(),
)
},
'waiting_list_names_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for a name"),
help_text=_("Ask for a name when signing up to the waiting list."),
)
},
'waiting_list_names_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require name"),
help_text=_("Require a name when signing up to the waiting list.."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-waiting_list_names_asked'}),
)
},
'waiting_list_phones_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for a phone number"),
help_text=_("Ask for a phone number when signing up to the waiting list."),
)
},
'waiting_list_phones_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require phone number"),
help_text=_("Require a phone number when signing up to the waiting list.."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-waiting_list_phones_asked'}),
)
},
'waiting_list_phones_explanation_text': {
'default': '',
'type': LazyI18nString,
'form_class': I18nFormField,
'serializer_class': I18nField,
'form_kwargs': dict(
label=_("Phone number explanation"),
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
)
},
'ticket_download': {
'default': 'False',
'type': bool,
@@ -1083,6 +1138,15 @@ DEFAULTS = {
help_text=_('If your event series has more than 50 dates in the future, only the month or week calendar can be used.')
),
},
'event_list_available_only': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Hide all unavailable dates from calendar or list views"),
)
},
'allow_modifications_after_checkin': {
'default': 'False',
'type': bool,
@@ -1744,7 +1808,7 @@ Your {event} team"""))
),
},
'theme_color_danger': {
'default': '#D36060',
'default': '#C44F4F',
'type': str,
'form_class': forms.CharField,
'serializer_class': serializers.CharField,
@@ -1945,6 +2009,18 @@ Your {event} team"""))
widget=I18nTextarea
)
},
'event_info_text': {
'default': '',
'type': LazyI18nString,
'serializer_class': I18nField,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_('Info text'),
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_('Not displayed anywhere by default, but if you want to, you can use this e.g. in ticket templates.')
)
},
'banner_text': {
'default': '',
'type': LazyI18nString,

View File

@@ -203,7 +203,7 @@ class EmailAddressShredder(BaseDataShredder):
class WaitingListShredder(BaseDataShredder):
verbose_name = _('Waiting list')
identifier = 'waiting_list'
description = _('This will remove all email addresses from the waiting list.')
description = _('This will remove all names, email addresses, and phone numbers from the waiting list.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'waiting-list.json', 'application/json', json.dumps([
@@ -213,7 +213,7 @@ class WaitingListShredder(BaseDataShredder):
@transaction.atomic
def shred_data(self):
self.event.waitinglistentries.update(email='')
self.event.waitinglistentries.update(name_cached=None, name_parts={'_shredded': True}, email='', phone='')
for wle in self.event.waitinglistentries.select_related('voucher').filter(voucher__isnull=False):
if '@' in wle.voucher.comment:
@@ -222,7 +222,14 @@ class WaitingListShredder(BaseDataShredder):
for le in self.event.logentry_set.filter(action_type="pretix.voucher.added.waitinglist").exclude(data=""):
d = le.parsed_data
if 'name' in d:
d['name'] = ''
if 'name_parts' in d:
d['name_parts'] = {
'_legacy': ''
}
d['email'] = ''
d['phone'] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])

View File

@@ -136,6 +136,31 @@
text-decoration: none;
color: {{ color }};
}
.order-button {
padding-top: 5px
}
.order-button a.button {
font-size: 12px;
}
.order-info {
padding-bottom: 5px
}
.order {
font-size: 12px;
}
.cart-table > tr > td:first-child {
width: 40px;
}
.order-details > tr > td:first-child {
width: 20%;
}
.order-details td {
font-size: 12px;
}
{% if rtl %}
body {
direction: rtl;

View File

@@ -0,0 +1,142 @@
{% load eventurl %}
{% load i18n %}
{% if position %}
<div class="order-info">
{% trans "You are receiving this email because someone signed you up for the following event:" %}
</div>
<table class="order-details">
<tr>
<td>
<strong>{% trans "Event:" %}</strong>
</td>
<td>
{{ event.name }}
<br>
{% if event.has_subevents and ev.name|upper != event.name|upper %}{{ ev.name }}<br>{% endif %}
{{ ev.get_date_range_display }}
{% if event.settings.show_times %}
{{ ev.date_from|date:"TIME_FORMAT" }}
{% endif %}
</td>
</tr>
<tr>
<td>
<strong>{% trans "Order code:" %}</strong>
</td>
<td>
{{ order.code }} ({{ order.datetime|date:"SHORT_DATE_FORMAT" }})<br>
{% if order.email %}
{% trans "created by" %} {{ order.email }}
{% endif %}
</td>
</tr>
<tr>
<td>
<strong>{% trans "Organizer:" %}</strong>
</td>
<td>
{{ event.organizer }}
{% if event.settings.contact_mail %}
<br>
<a href="mailto:{{ event.settings.contact_mail }}">
{{ event.settings.contact_mail }}
</a>
{% endif %}
</td>
</tr>
</table>
<div class="order-button">
<a href="{% abseventurl event "presale:event.order.position" order=order.code secret=position.web_secret position=position.positionid %}" class="button">
{% trans "View registration details" %}
</a>
</div>
{% else %}
<div class="order-info">
{% trans "You are receiving this email because you placed an order for the following event:" %}
</div>
<table class="order-details">
<tr>
<td>
<strong>{% trans "Event:" %}</strong>
</td>
<td>
{{ event.name }}
{% if not event.has_subevents and event.settings.show_dates_on_frontpage %}
<br>
{{ event.get_date_range_display }}
{% if event.settings.show_times %}
{{ event.date_from|date:"TIME_FORMAT" }}
{% endif %}
{% endif %}
</td>
</tr>
<tr>
<td>
<strong>{% trans "Order code:" %}</strong>
</td>
<td>
{{ order.code }} ({{ order.datetime|date:"SHORT_DATE_FORMAT" }})
</td>
</tr>
{% if cart %}
<tr>
<td>
<strong>{% trans "Details:" %}</strong>
</td>
<td>
<table class="cart-table">
{% for groupkey, positions in cart %}
<tr>
<td>
{% if not groupkey.4 %} {# is addon #}
{{ positions|length }}x
{% endif %}
</td>
<td>
{% if groupkey.4 %} {# is addon #}
+
{% endif %}
{{ groupkey.0.name }}{% if groupkey.1 %} {{ groupkey.1.value }}{% endif %}
{% if groupkey.2 %} {# subevent #}
<br>
{% if groupkey.2.name|upper != event.name|upper %}
{{ groupkey.2.name }} &middot;
{% endif %}
{{ groupkey.2.get_date_range_display }}
{% if event.settings.show_times %}
{{ groupkey.2.date_from|date:"TIME_FORMAT" }}
{% endif %}
{% endif %}
{% if groupkey.3 %} {# attendee name #}
<br>
{{ groupkey.3.name }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
{% endif %}
<tr>
<td>
<strong>{% trans "Organizer:" %}</strong>
</td>
<td>
{{ event.organizer }}
{% if event.settings.contact_mail %}
<br>
<a href="mailto:{{ event.settings.contact_mail }}">
{{ event.settings.contact_mail }}
</a>
{% endif %}
</td>
</tr>
</table>
<div class="order-button">
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_hash order=order.code secret=order.secret %}" class="button">
{% trans "View order details" %}
</a>
</div>
{% endif %}

View File

@@ -23,23 +23,7 @@
<table cellpadding="20"><tr><td>
<![endif]-->
<div class="content">
{% if position %}
{% trans "You are receiving this email because someone signed you up for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order.position" order=order.code secret=position.web_secret position=position.positionid %}">
{% trans "View registration details" %}
</a>
{% else %}
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_hash order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
{% endif %}
{% include "pretixbase/email/order_details.html" %}
</div>
<!--[if gte mso 9]>
</td></tr></table>

View File

@@ -147,6 +147,31 @@
text-decoration: none;
color: {{ color }};
}
.order-button {
padding-top: 5px
}
.order-button a.button {
font-size: 12px;
}
.order-info {
padding-bottom: 5px
}
.order {
font-size: 12px;
}
.cart-table > tr > td:first-child {
width: 40px;
}
.order-details > tr > td:first-child {
width: 20%;
}
.order-details td {
font-size: 12px;
}
{% if rtl %}
body {
direction: rtl;
@@ -226,23 +251,7 @@
<table cellpadding="20"><tr><td>
<![endif]-->
<div class="content">
{% if position %}
{% trans "You are receiving this email because someone signed you up for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order.position" order=order.code secret=position.web_secret position=position.positionid %}">
{% trans "View registration details" %}
</a>
{% else %}
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_hash order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
{% endif %}
{% include "pretixbase/email/order_details.html" %}
</div>
<!--[if gte mso 9]>
</td></tr></table>

View File

@@ -1,7 +1,7 @@
{% load thumb %}
{% if widget.is_initial %}{{ widget.initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.value.name }}"><img src="{{ widget.value|thumb:"200x100" }}" /></a>{% endif %}<br>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.name }}"><img src="{{ widget.value|thumb:"200x100" }}" /></a>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% if widget.cachedfile %}<input type="hidden" name="{{ widget.hidden_name }}" value="{{ widget.cachedfile.id }}">{% endif %}

View File

@@ -11,7 +11,7 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, float) or isinstance(value, int):
if isinstance(value, (float, int)):
value = Decimal(value)
if not isinstance(value, Decimal):
if value == '':
@@ -47,7 +47,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
@register.filter("money_numberfield")
def money_numberfield_filter(value: Decimal, arg=''):
if isinstance(value, float) or isinstance(value, int):
if isinstance(value, (float, int)):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))

View File

@@ -48,7 +48,7 @@ def page_not_found(request, exception):
except (AttributeError, IndexError):
pass
else:
if isinstance(message, str) or isinstance(message, Promise):
if isinstance(message, (str, Promise)):
exception_repr = str(message)
context = {
'request_path': request.path,

View File

@@ -4,6 +4,7 @@ from decimal import Decimal
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db import IntegrityError
from django.db.models import Prefetch, QuerySet
from django.utils.functional import cached_property
@@ -148,8 +149,24 @@ class BaseQuestionsViewMixin:
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question,
)
self._save_to_answer(field, answer, v)
answer.save()
try:
self._save_to_answer(field, answer, v)
answer.save()
except IntegrityError:
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
# here if the users submits their save request a second time while the first one is still running,
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
# again. However, both of these approaches have a significant performance overhead for *all* requests,
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
answer = QuestionAnswer.objects.get(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question,
)
self._save_to_answer(field, answer, v)
answer.save()
else:
meta_info.setdefault('question_form_data', {})
if v is None:

View File

@@ -4,43 +4,25 @@ import celery.exceptions
from celery.result import AsyncResult
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.test import RequestFactory
from django.utils.translation import gettext as _
from django.views.generic import FormView
from pretix.base.models import User
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
logger = logging.getLogger('pretix.base.tasks')
class AsyncAction:
task = None
class AsyncMixin:
success_url = None
error_url = None
known_errortypes = []
def do(self, *args, **kwargs):
if not isinstance(self.task, app.Task):
raise TypeError('Method has no task attached')
try:
res = self.task.apply_async(args=args, kwargs=kwargs)
except ConnectionError:
# Task very likely not yet sent, due to redis restarting etc. Let's try once agan
res = self.task.apply_async(args=args, kwargs=kwargs)
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
data = self._return_ajax_result(res)
data['check_url'] = self.get_check_url(res.id, True)
return JsonResponse(data)
else:
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self.success(res.info)
else:
return self.error(res.info)
return redirect(self.get_check_url(res.id, False))
def get_success_url(self, value):
return self.success_url
@@ -50,11 +32,6 @@ class AsyncAction:
def get_check_url(self, task_id, ajax):
return self.request.path + '?async_id=%s' % task_id + ('&ajax=1' if ajax else '')
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return self.http_method_not_allowed(request)
def _ajax_response_data(self):
return {}
@@ -86,7 +63,7 @@ class AsyncAction:
if smes:
messages.success(self.request, smes)
# TODO: Do not store message if the ajax client states that it will not redirect
# but handle the mssage itself
# but handle the message itself
data.update({
'redirect': self.get_success_url(res.info),
'success': True,
@@ -95,7 +72,7 @@ class AsyncAction:
else:
messages.error(self.request, self.get_error_message(res.info))
# TODO: Do not store message if the ajax client states that it will not redirect
# but handle the mssage itself
# but handle the message itself
data.update({
'redirect': self.get_error_url(),
'success': False,
@@ -159,3 +136,124 @@ class AsyncAction:
def get_success_message(self, value):
return _('The task has been completed.')
class AsyncAction(AsyncMixin):
task = None
def do(self, *args, **kwargs):
if not isinstance(self.task, app.Task):
raise TypeError('Method has no task attached')
try:
res = self.task.apply_async(args=args, kwargs=kwargs)
except ConnectionError:
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
res = self.task.apply_async(args=args, kwargs=kwargs)
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
data = self._return_ajax_result(res)
data['check_url'] = self.get_check_url(res.id, True)
return JsonResponse(data)
else:
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self.success(res.info)
else:
return self.error(res.info)
return redirect(self.get_check_url(res.id, False))
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return self.http_method_not_allowed(request)
class AsyncFormView(AsyncMixin, FormView):
"""
FormView variant in which instead of ``form_valid``, an ``async_form_valid``
is executed in a celery task. Note that this places some severe limitations
on the form and the view, e.g. neither ``get_form*`` nor the form itself
may depend on the request object unless specifically supported by this class.
Also, all form keyword arguments except ``instance`` need to be serializable.
"""
known_errortypes = ['ValidationError']
def __init_subclass__(cls):
def async_execute(self, request_path, form_kwargs, organizer=None, event=None, user=None):
view_instance = cls()
view_instance.request = RequestFactory().post(request_path)
if organizer:
view_instance.request.event = event
if organizer:
view_instance.request.organizer = organizer
if user:
view_instance.request.user = User.objects.get(pk=user)
form_class = view_instance.get_form_class()
if form_kwargs.get('instance'):
cls.model.objects.get(pk=form_kwargs['instance'])
form_kwargs = view_instance.get_async_form_kwargs(form_kwargs, organizer, event)
form = form_class(**form_kwargs)
return view_instance.async_form_valid(self, form)
cls.async_execute = app.task(
base=ProfiledEventTask,
bind=True,
name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
throws=(ValidationError,)
)(async_execute)
def async_form_valid(self, task, form):
pass
def get_async_form_kwargs(self, form_kwargs, organizer=None, event=None):
return form_kwargs
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return super().get(request, *args, **kwargs)
def form_valid(self, form):
if form.files:
raise TypeError('File upload currently not supported in AsyncFormView')
form_kwargs = {
k: v for k, v in self.get_form_kwargs().items()
}
if form_kwargs.get('instance'):
if form_kwargs['instance'].pk:
form_kwargs['instance'] = form_kwargs['instance'].pk
else:
form_kwargs['instance'] = None
form_kwargs.setdefault('data', {})
kwargs = {
'request_path': self.request.path,
'form_kwargs': form_kwargs,
}
if hasattr(self.request, 'organizer'):
kwargs['organizer'] = self.request.organizer.pk
if self.request.user.is_authenticated:
kwargs['user'] = self.request.user.pk
if hasattr(self.request, 'event'):
kwargs['event'] = self.request.event.pk
try:
res = type(self).async_execute.apply_async(kwargs=kwargs)
except ConnectionError:
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
res = type(self).async_execute.apply_async(kwargs=kwargs)
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
data = self._return_ajax_result(res)
data['check_url'] = self.get_check_url(res.id, True)
return JsonResponse(data)
else:
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self.success(res.info)
else:
return self.error(res.info)
return redirect(self.get_check_url(res.id, False))

View File

@@ -66,7 +66,9 @@ def _default_context(request):
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
ctx['complain_testmode_orders'] = complain_testmode_orders and request.user.has_event_permission(
request.organizer, request.event, 'can_view_orders', request=request
)
else:
ctx['complain_testmode_orders'] = False

View File

@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
from django.core.files import File
from django.core.files.uploadedfile import UploadedFile
from django.forms.utils import from_current_timezone
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -122,7 +123,7 @@ class CachedFileInput(forms.ClearableFileInput):
@property
def url(self):
return self.file.file.url
return reverse('cachedfile.download', kwargs={'id': self.file.id})
def value_from_datadict(self, data, files, name):
from ...base.models import CachedFile
@@ -200,6 +201,8 @@ class CachedFileField(ExtFileField):
from ...base.models import CachedFile
if isinstance(data, File):
if hasattr(data, '_uploaded_to'):
return data._uploaded_to
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
@@ -209,6 +212,7 @@ class CachedFileField(ExtFileField):
)
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
return cf
return super().bound_data(data, initial)
@@ -217,6 +221,8 @@ class CachedFileField(ExtFileField):
data = super().clean(*args, **kwargs)
if isinstance(data, File):
if hasattr(data, '_uploaded_to'):
return data._uploaded_to
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
web_download=True,
@@ -226,6 +232,7 @@ class CachedFileField(ExtFileField):
)
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
return cf
return data

View File

@@ -105,6 +105,11 @@ class CheckinListForm(forms.ModelForm):
'exit_all_at': NextTimeField,
}
def clean(self):
d = super().clean()
CheckinList.validate_rules(d.get('rules'))
return d
class SimpleCheckinListForm(forms.ModelForm):
def __init__(self, **kwargs):

View File

@@ -449,6 +449,11 @@ class EventSettingsForm(SettingsForm):
'waiting_list_enabled',
'waiting_list_hours',
'waiting_list_auto',
'waiting_list_names_asked',
'waiting_list_names_required',
'waiting_list_phones_asked',
'waiting_list_phones_required',
'waiting_list_phones_explanation_text',
'max_items_per_order',
'reservation_time',
'contact_mail',
@@ -458,7 +463,9 @@ class EventSettingsForm(SettingsForm):
'redirect_to_checkout_directly',
'frontpage_subevent_ordering',
'event_list_type',
'event_list_available_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
@@ -541,6 +548,7 @@ class EventSettingsForm(SettingsForm):
if not self.event.has_subevents:
del self.fields['frontpage_subevent_ordering']
del self.fields['event_list_type']
del self.fields['event_list_available_only']
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
self.virtual_keys = []
@@ -1126,6 +1134,13 @@ class TicketSettingsForm(SettingsForm):
class CommentForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.readonly = kwargs.pop('readonly', None)
super().__init__(*args, **kwargs)
if self.readonly:
self.fields['comment'].widget.attrs['readonly'] = 'readonly'
class Meta:
model = Event
fields = ['comment']

View File

@@ -5,7 +5,9 @@ from urllib.parse import urlencode
from django import forms
from django.apps import apps
from django.conf import settings
from django.db.models import Exists, F, Max, Model, OuterRef, Q, QuerySet
from django.db.models import (
Count, Exists, F, Max, Model, OuterRef, Q, QuerySet,
)
from django.db.models.functions import Coalesce, ExtractWeekDay
from django.urls import reverse, reverse_lazy
from django.utils.formats import date_format, localize
@@ -153,6 +155,7 @@ class OrderFilterForm(FilterForm):
(Order.STATUS_CANCELED, _('Canceled (fully)')),
('cp', _('Canceled (fully or with paid fee)')),
('rc', _('Cancellation requested')),
('cni', _('Fully canceled but invoice not canceled')),
)),
(_('Payment process'), (
(Order.STATUS_EXPIRED, _('Expired')),
@@ -264,6 +267,18 @@ class OrderFilterForm(FilterForm):
status=Order.STATUS_PAID,
pending_sum_t__gt=0
)
elif s == 'cni':
i = Invoice.objects.filter(
order=OuterRef('pk'),
is_cancellation=False,
refered__isnull=True,
).order_by().values('order').annotate(k=Count('id')).values('k')
qs = qs.annotate(
icnt=i
).filter(
icnt__gt=0,
status=Order.STATUS_CANCELED,
)
elif s == 'pa':
qs = qs.filter(
status=Order.STATUS_PENDING,
@@ -980,7 +995,6 @@ class EventFilterForm(FilterForm):
'date_from': 'order_from',
'date_to': 'order_to',
'live': 'live',
'sum_tickets_paid': 'sum_tickets_paid'
}
status = forms.ChoiceField(
label=_('Status'),
@@ -1424,6 +1438,8 @@ class VoucherFilterForm(FilterForm):
s = fdata.get('tag').strip()
if s == '<>':
qs = qs.filter(Q(tag__isnull=True) | Q(tag=''))
elif s[0] == '"' and s[-1] == '"':
qs = qs.filter(tag__iexact=s[1:-1])
else:
qs = qs.filter(tag__icontains=s)

View File

@@ -337,7 +337,7 @@ class ItemCreateForm(I18nModelForm):
setattr(self.instance, f, getattr(self.cleaned_data['copy_from'], f))
else:
# Add to all sales channels by default
self.instance.sales_channels = [k for k in get_all_sales_channels().keys()]
self.instance.sales_channels = list(get_all_sales_channels().keys())
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
instance = super().save(*args, **kwargs)

View File

@@ -75,7 +75,7 @@ class ExtendForm(I18nModelForm):
return super().save(commit)
class ConfirmPaymentForm(forms.Form):
class ForceQuotaConfirmationForm(forms.Form):
force = forms.BooleanField(
label=_('Overbook quota and ignore late payment'),
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
@@ -101,7 +101,15 @@ class ConfirmPaymentForm(forms.Form):
del self.fields['force']
class CancelForm(ConfirmPaymentForm):
class ConfirmPaymentForm(ForceQuotaConfirmationForm):
pass
class ReactivateOrderForm(ForceQuotaConfirmationForm):
pass
class CancelForm(ForceQuotaConfirmationForm):
send_email = forms.BooleanField(
required=False,
label=_('Notify customer by email'),
@@ -117,6 +125,11 @@ class CancelForm(ConfirmPaymentForm):
'in your cancellation fee if you want to keep them. Please always enter a gross value, '
'tax will be calculated automatically.'),
)
cancel_invoice = forms.BooleanField(
label=_('Generate cancellation for invoice'),
initial=True,
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -130,6 +143,8 @@ class CancelForm(ConfirmPaymentForm):
self.fields['cancellation_fee'].max_value = prs
else:
del self.fields['cancellation_fee']
if not self.instance.invoices.exists():
del self.fields['cancel_invoice']
def clean_cancellation_fee(self):
val = self.cleaned_data['cancellation_fee'] or Decimal('0.00')

View File

@@ -71,7 +71,7 @@ class OrganizerUpdateForm(OrganizerForm):
kwargs.setdefault('initial', {})
self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.first()
initial_domain = self.instance.domains.filter(event__isnull=True).first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
@@ -149,7 +149,7 @@ class TeamForm(forms.ModelForm):
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders',
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
'can_view_vouchers', 'can_change_vouchers']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={

View File

@@ -5,7 +5,7 @@ from io import StringIO
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator
from django.db.models.functions import Lower
from django.db.models.functions import Upper
from django.urls import reverse
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
@@ -346,8 +346,8 @@ class VoucherBulkForm(VoucherForm):
data = super().clean()
vouchers = self.instance.event.vouchers.annotate(
code_lower=Lower('code')
).filter(code_lower__in=[c.lower() for c in data['codes']])
code_upper=Upper('code')
).filter(code_upper__in=[c.upper() for c in data['codes']])
if vouchers.exists():
raise ValidationError(_('A voucher with one of these codes already exists.'))
@@ -377,26 +377,5 @@ class VoucherBulkForm(VoucherForm):
return data
def save(self, event, *args, **kwargs):
objs = []
for code in self.cleaned_data['codes']:
obj = modelcopy(self.instance)
obj.event = event
obj.code = code
try:
obj.seat = self.cleaned_data['seats'].pop()
obj.item = obj.seat.product
except IndexError:
pass
data = dict(self.cleaned_data)
data['code'] = code
data['bulk'] = True
del data['codes']
objs.append(obj)
Voucher.objects.bulk_create(objs, batch_size=200)
objs = []
for v in event.vouchers.filter(code__in=self.cleaned_data['codes']):
# We need to query them again as bulk_create does not fill in .pk values on databases
# other than PostgreSQL
objs.append(v)
return objs
def post_bulk_save(self, objs):
pass

View File

@@ -514,5 +514,5 @@ def merge_in(nav, newnav):
if 'children' not in parents[0]:
parents[0]['children'] = []
parents[0]['children'].append(item)
else:
nav.append(item)
continue
nav.append(item)

View File

@@ -7,7 +7,7 @@ from django.utils.translation import gettext as _
def current_url(request):
if len(request.GET):
if request.GET:
return request.path + '?' + request.GET.urlencode()
else:
return request.path

View File

@@ -154,6 +154,11 @@ This signal allows you to replace the form class that is used for modifying vouc
You will receive the default form class (or the class set by a previous plugin) in the
``cls`` argument so that you can inherit from it.
Note that this is also called for the voucher bulk creation form, which is executed in
an asynchronous context. For the bulk creation form, ``save()`` is not called. Instead,
you can implement ``post_bulk_save(saved_vouchers)`` which may be called multiple times
for every batch persisted to the database.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""

View File

@@ -283,6 +283,7 @@
{% for nav in nav_items %}
<li>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.external %}target="_blank"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon %}
{% if "<svg" in nav.icon %}
@@ -301,6 +302,7 @@
{% for item in nav.children %}
<li>
<a href="{{ item.url }}"
{% if item.external %}target="_blank"{% endif %}
{% if item.active %}class="active"{% endif %}>
{{ item.label }}
</a>

View File

@@ -150,12 +150,14 @@
<div class="row">
{% bootstrap_field comment_form.comment layout="horizontal" show_help=True show_label=False horizontal_field_class="col-md-12" %}
</div>
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% if not comment_form.readonly %}
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% endif %}
</form>
</div>
</div>

View File

@@ -30,6 +30,7 @@
{% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
{% if meta_forms %}
<div class="form-group metadata-group">
@@ -193,6 +194,7 @@
{% bootstrap_field sform.checkout_phone_helptext layout="control" %}
{% bootstrap_field sform.banner_text layout="control" %}
{% bootstrap_field sform.banner_text_bottom layout="control" %}
{% bootstrap_field sform.event_info_text layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Shop design" %}</legend>
@@ -235,7 +237,9 @@
{% if sform.event_list_type %}
{% bootstrap_field sform.event_list_type layout="control" %}
{% endif %}
{% bootstrap_field form.sales_channels layout="control" %}
{% if sform.event_list_available_only %}
{% bootstrap_field sform.event_list_available_only layout="control" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Cart" %}</legend>
@@ -248,6 +252,9 @@
{% bootstrap_field sform.waiting_list_enabled layout="control" %}
{% bootstrap_field sform.waiting_list_auto layout="control" %}
{% bootstrap_field sform.waiting_list_hours layout="control" %}
{% bootstrap_field sform.waiting_list_names_asked_required layout="control" %}
{% bootstrap_field sform.waiting_list_phones_asked_required layout="control" %}
{% bootstrap_field sform.waiting_list_phones_explanation_text layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Item metadata" %}</legend>

View File

@@ -81,8 +81,6 @@
</th>
<th>
{% trans "Paid tickets per quota" %}
<a href="?{% url_replace request 'ordering' '-sum_tickets_paid' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'sum_tickets_paid' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Status" %}

View File

@@ -1,6 +1,7 @@
{% load i18n %}
<div class="quotabox" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}">
<a class="quotabox" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
</div>
@@ -13,4 +14,4 @@
<div class="numbers">
{{ q.cached_availability_paid_orders|default_if_none:"?" }} / {{ q.size|default_if_none:"∞" }}
</div>
</div>
</a>

View File

@@ -23,13 +23,16 @@
<input type="hidden" name="status" value="c"/>
{% bootstrap_form_errors form %}
{% bootstrap_field form.send_email layout='' %}
{% if form.cancel_invoice %}
{% bootstrap_field form.cancel_invoice layout='' %}
{% endif %}
{% if form.cancellation_fee %}
{% bootstrap_field form.cancellation_fee layout='' %}
{% endif %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "No, take me back" %}
</a>
</div>

View File

@@ -24,6 +24,7 @@
<form method="post" class="form-horizontal" href="">
{% csrf_token %}
{% bootstrap_form form layout='horizontal' horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
<div class="form-group submit-group">
<a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">

View File

@@ -170,6 +170,10 @@
</span>
{% endif %}
{{ o.total|money:request.event.currency }}
{% if o.status == "c" and o.icnt %}
<br>
<span class="label label-warning">{% trans "INVOICE NOT CANCELED" %}</span>
{% endif %}
</td>
<td class="text-right flip">{{ o.pcnt|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>

View File

@@ -35,6 +35,7 @@
{% bootstrap_field form.can_change_items layout="control" %}
{% bootstrap_field form.can_view_orders layout="control" %}
{% bootstrap_field form.can_change_orders layout="control" %}
{% bootstrap_field form.can_checkin_orders layout="control" %}
{% bootstrap_field form.can_view_vouchers layout="control" %}
{% bootstrap_field form.can_change_vouchers layout="control" %}
</fieldset>

View File

@@ -80,8 +80,6 @@
</th>
<th>
{% trans "Paid tickets per quota" %}
<a href="?{% url_replace request 'filter-ordering' '-sum_tickets_paid' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'sum_tickets_paid' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Status" %}

View File

@@ -5,7 +5,7 @@
{% block title %}{% trans "Voucher" %}{% endblock %}
{% block inside %}
<h1>{% trans "Create multiple vouchers" %}</h1>
<form action="" method="post" class="form-horizontal">
<form action="" method="post" class="form-horizontal" data-asynctask>
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>

View File

@@ -32,7 +32,7 @@
<label class="col-md-3 control-label" for="id_url">{% trans "Voucher link" %}</label>
<div class="col-md-9">
<input type="text" name="url"
value="{% abseventurl request.event "presale:event.redeem" %}?voucher={{ voucher.code }}{% if voucher.subevent_id %}&subevent={{ voucher.subevent_id }}{% endif %}"
value="{% abseventurl request.event "presale:event.redeem" %}?voucher={{ voucher.code|urlencode }}{% if voucher.subevent_id %}&subevent={{ voucher.subevent_id }}{% endif %}"
class="form-control"
id="id_url" readonly>
</div>

View File

@@ -49,7 +49,7 @@
<td>
<strong>
{% if t.tag %}
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ t.tag|urlencode }}">
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ '"'|add:t.tag|add:'"'|urlencode }}">
{{ t.tag }}
</a>
{% else %}

View File

@@ -132,7 +132,13 @@
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "User" %}</th>
{% if request.event.settings.waiting_list_names_asked %}
<th>{% trans "Name" %}</th>
{% endif %}
<th>{% trans "Email" %}</th>
{% if request.event.settings.waiting_list_phones_asked %}
<th>{% trans "Phone number" %}</th>
{% endif %}
<th>{% trans "Product" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}</th>
@@ -146,7 +152,13 @@
<tbody>
{% for e in entries %}
<tr>
{% if request.event.settings.waiting_list_names_asked %}
<td>{{ e.name|default:"" }}</td>
{% endif %}
<td>{{ e.email }}</td>
{% if request.event.settings.waiting_list_phones_asked %}
<td>{{ e.phone|default:"" }}</td>
{% endif %}
<td>
{{ e.item }}
{% if e.variation %}

View File

@@ -22,8 +22,8 @@ from django.utils.translation import gettext_lazy as _, pgettext, ungettext
from pretix.base.decimal import round_decimal
from pretix.base.models import (
Item, ItemVariation, Order, OrderPosition, OrderRefund, RequiredAction,
SubEvent, Voucher, WaitingListEntry,
Item, ItemCategory, ItemVariation, Order, OrderPosition, OrderRefund,
Question, Quota, RequiredAction, SubEvent, Voucher, WaitingListEntry,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
@@ -202,14 +202,10 @@ def quota_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets = []
quotas = sender.quotas.filter(subevent=subevent)
quotas_to_compute = [
q for q in quotas
if not q.cache_is_hot(now() + timedelta(seconds=5))
]
qa = QuotaAvailability()
if quotas_to_compute:
qa.queue(*quotas_to_compute)
qa.compute()
if quotas:
qa.queue(*quotas)
qa.compute(allow_cache=True)
for q in quotas:
if not lazy:
@@ -317,19 +313,40 @@ def event_index(request, organizer, event):
except SubEvent.DoesNotExist:
pass
widgets = []
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'can_view_orders',
request=request)
can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders',
request=request)
can_change_event_settings = request.user.has_event_permission(request.organizer, request.event,
'can_change_event_settings', request=request)
can_view_vouchers = request.user.has_event_permission(request.organizer, request.event, 'can_view_vouchers',
request=request)
widgets = []
if can_view_orders:
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application',
'device').order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request):
if not can_view_orders:
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order))
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_vouchers', request=request):
if not can_view_vouchers:
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
if not can_change_event_settings:
allowed_types = [
ContentType.objects.get_for_model(Voucher),
ContentType.objects.get_for_model(Order)
]
if request.user.has_event_permission(request.organizer, request.event, 'can_change_items', request=request):
allowed_types += [
ContentType.objects.get_for_model(Item),
ContentType.objects.get_for_model(ItemCategory),
ContentType.objects.get_for_model(Quota),
ContentType.objects.get_for_model(Question),
]
qs = qs.filter(content_type__in=allowed_types)
a_qs = request.event.requiredaction_set.filter(done=False)
@@ -338,25 +355,25 @@ def event_index(request, organizer, event):
'logs': qs[:5],
'subevent': subevent,
'actions': a_qs[:5] if can_change_orders else [],
'comment_form': CommentForm(initial={'comment': request.event.comment})
'comment_form': CommentForm(initial={'comment': request.event.comment}, readonly=not can_change_event_settings),
}
ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter(
ctx['has_overpaid_orders'] = can_view_orders and Order.annotate_overpayments(request.event.orders).filter(
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
).exists()
ctx['has_pending_orders_with_full_payment'] = Order.annotate_overpayments(request.event.orders).filter(
ctx['has_pending_orders_with_full_payment'] = can_view_orders and Order.annotate_overpayments(request.event.orders).filter(
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) & Q(require_approval=False)
).exists()
ctx['has_pending_refunds'] = OrderRefund.objects.filter(
ctx['has_pending_refunds'] = can_view_orders and OrderRefund.objects.filter(
order__event=request.event,
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_EXTERNAL)
).exists()
ctx['has_pending_approvals'] = request.event.orders.filter(
ctx['has_pending_approvals'] = can_view_orders and request.event.orders.filter(
status=Order.STATUS_PENDING,
require_approval=True
).exists()
ctx['has_cancellation_requests'] = CancellationRequest.objects.filter(
ctx['has_cancellation_requests'] = can_view_orders and CancellationRequest.objects.filter(
order__event=request.event
).exists()

View File

@@ -55,7 +55,9 @@ from pretix.plugins.stripe.payment import StripeSettingsHolder
from pretix.presale.style import regenerate_css
from ...base.i18n import language
from ...base.models.items import ItemMetaProperty
from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota,
)
from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList
from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView
@@ -691,14 +693,14 @@ class MailSettingsRendererPreview(MailSettingsPreview):
expires=now(), code="PREVIEW", total=119)
item = request.event.items.create(name=gettext("Sample product"), default_price=42.23,
description=gettext("Sample product description"))
p = order.positions.create(item=item, attendee_name_parts={'_legacy': gettext("John Doe")},
price=item.default_price)
order.positions.create(item=item, attendee_name_parts={'_legacy': gettext("John Doe")},
price=item.default_price, subevent=request.event.subevents.last())
v = renderers[request.GET.get('renderer')].render(
v,
str(request.event.settings.mail_text_signature),
gettext('Your order: %(code)s') % {'code': order.code},
order,
position=p
position=None
)
r = HttpResponse(v, content_type='text/html')
r._csp_ignore = True
@@ -955,11 +957,10 @@ class EventDelete(RecentAuthenticationRequiredMixin, EventPermissionRequiredMixi
return reverse('control:index')
class EventLog(EventPermissionRequiredMixin, ListView):
class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixcontrol/event/logs.html'
model = LogEntry
context_object_name = 'logs'
paginate_by = 20
def get_queryset(self):
qs = self.request.event.logentry_set.all().select_related(
@@ -972,6 +973,21 @@ class EventLog(EventPermissionRequiredMixin, ListView):
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_vouchers',
request=self.request):
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
if not self.request.user.has_event_permission(self.request.organizer, self.request.event,
'can_change_event_settings', request=self.request):
allowed_types = [
ContentType.objects.get_for_model(Voucher),
ContentType.objects.get_for_model(Order)
]
if self.request.user.has_event_permission(self.request.organizer, self.request.event,
'can_change_items', request=self.request):
allowed_types += [
ContentType.objects.get_for_model(Item),
ContentType.objects.get_for_model(ItemCategory),
ContentType.objects.get_for_model(Quota),
ContentType.objects.get_for_model(Question),
]
qs = qs.filter(content_type__in=allowed_types)
if self.request.GET.get('user') == 'yes':
qs = qs.filter(user__isnull=False)
@@ -1088,6 +1104,7 @@ class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView
}
def post(self, request, *args, **kwargs):
self.object = None
form = self.get_form()
if form.is_valid() and self.formset.is_valid():
return self.form_valid(form)
@@ -1363,7 +1380,7 @@ class QuickSetupView(FormView):
tax_rule=tax_rule,
admission=True,
position=i,
sales_channels=[k for k in get_all_sales_channels().keys()]
sales_channels=list(get_all_sales_channels().keys())
)
item.log_action('pretix.event.item.added', user=self.request.user, data=dict(f.cleaned_data))
if f.cleaned_data['quota'] or not form.cleaned_data['total_quota']:

View File

@@ -1,9 +1,7 @@
from django.conf import settings
from django.contrib import messages
from django.db import transaction
from django.db.models import (
F, IntegerField, Max, Min, OuterRef, Prefetch, Subquery, Sum,
)
from django.db.models import F, Max, Min, Prefetch
from django.db.models.functions import Coalesce, Greatest
from django.http import JsonResponse
from django.shortcuts import redirect
@@ -52,17 +50,7 @@ class EventList(PaginationMixin, ListView):
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to', 'date_from'),
)
sum_tickets_paid = Quota.objects.filter(
event=OuterRef('pk'), subevent__isnull=True
).order_by().values('event').annotate(
s=Sum('cached_availability_paid_orders')
).values(
's'
)
qs = qs.annotate(
sum_tickets_paid=Subquery(sum_tickets_paid, output_field=IntegerField())
).prefetch_related(
qs = qs.prefetch_related(
Prefetch('quotas',
queryset=Quota.objects.filter(subevent__isnull=True).annotate(s=Coalesce(F('size'), 0)).order_by('-s'),
to_attr='first_quotas')
@@ -90,15 +78,12 @@ class EventList(PaginationMixin, ListView):
qa = QuotaAvailability(early_out=False)
for q in quotas:
if q.cached_availability_time is None or q.cached_availability_paid_orders is None:
qa.queue(q)
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = (
qa.results[q] if q in qa.results
else (q.cached_availability_state, q.cached_availability_number)
)
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,

View File

@@ -83,7 +83,7 @@ from pretix.control.forms.orders import (
ExtendForm, MarkPaidForm, OrderContactForm, OrderFeeChangeForm,
OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
OrderPositionAddFormset, OrderPositionChangeForm, OrderPositionMailForm,
OrderRefundForm, OtherOperationsForm,
OrderRefundForm, OtherOperationsForm, ReactivateOrderForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import order_search_forms
@@ -151,6 +151,11 @@ class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin,
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
i = Invoice.objects.filter(
order=OuterRef('pk'),
is_cancellation=False,
refered__isnull=True,
).order_by().values('order').annotate(k=Count('id')).values('k')
annotated = {
o['pk']: o
for o in
@@ -158,10 +163,11 @@ class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin,
pk__in=[o.pk for o in ctx['orders']]
).annotate(
pcnt=Subquery(s, output_field=IntegerField()),
icnt=Subquery(i, output_field=IntegerField()),
has_cancellation_request=Exists(CancellationRequest.objects.filter(order=OuterRef('pk')))
).values(
'pk', 'pcnt', 'is_overpaid', 'is_underpaid', 'is_pending_with_full_payment', 'has_external_refund',
'has_pending_refund', 'has_cancellation_request', 'computed_payment_refund_sum'
'has_pending_refund', 'has_cancellation_request', 'computed_payment_refund_sum', 'icnt'
)
}
@@ -177,6 +183,7 @@ class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin,
o.has_pending_refund = annotated.get(o.pk)['has_pending_refund']
o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request']
o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum']
o.icnt = annotated.get(o.pk)['icnt']
o.sales_channel_obj = scs[o.sales_channel]
if ctx['page_obj'].paginator.count < 1000:
@@ -1134,6 +1141,7 @@ class OrderTransition(OrderView):
try:
cancel_order(self.order.pk, user=self.request.user,
send_mail=self.mark_canceled_form.cleaned_data['send_email'],
cancel_invoice=self.mark_canceled_form.cleaned_data.get('cancel_invoice', True),
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee'))
except OrderError as e:
messages.error(self.request, str(e))
@@ -1416,11 +1424,24 @@ class OrderExtend(OrderView):
class OrderReactivate(OrderView):
permission = 'can_change_orders'
@cached_property
def reactivate_form(self):
return ReactivateOrderForm(
instance=self.order,
data=self.request.POST if self.request.method == "POST" else None,
)
def post(self, *args, **kwargs):
if not self.reactivate_form.is_valid():
return render(self.request, 'pretixcontrol/order/reactivate.html', {
'form': self.reactivate_form,
'order': self.order,
})
try:
reactivate_order(
self.order,
user=self.request.user
user=self.request.user,
force=self.reactivate_form.cleaned_data.get('force', False)
)
messages.success(self.request, _('The order has been reactivated.'))
except OrderError as e:
@@ -1445,6 +1466,7 @@ class OrderReactivate(OrderView):
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/reactivate.html', {
'form': self.reactivate_form,
'order': self.order,
})

View File

@@ -938,6 +938,7 @@ class GiftCardListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
template_name = 'pretixcontrol/organizers/giftcards.html'
permission = 'can_manage_gift_cards'
context_object_name = 'giftcards'
paginate_by = 50
def get_queryset(self):
s = GiftCardTransaction.objects.filter(
@@ -1437,12 +1438,11 @@ class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionR
return redirect(success_url)
class LogView(OrganizerPermissionRequiredMixin, ListView):
class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixcontrol/organizers/logs.html'
permission = 'can_change_organizer_settings'
model = LogEntry
context_object_name = 'logs'
paginate_by = 20
def get_queryset(self):
qs = self.request.organizer.all_logentries().select_related(

View File

@@ -6,9 +6,7 @@ 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 connections, transaction
from django.db.models import (
Count, F, IntegerField, OuterRef, Prefetch, Subquery, Sum,
)
from django.db.models import Count, F, Prefetch
from django.db.models.functions import Coalesce, TruncDate, TruncTime
from django.forms import inlineformset_factory
from django.http import Http404, HttpResponse, HttpResponseRedirect
@@ -57,20 +55,11 @@ class SubEventQueryMixin:
return self.request.GET
def get_queryset(self, list=False):
sum_tickets_paid = Quota.objects.filter(
subevent=OuterRef('pk')
).order_by().values('subevent').annotate(
s=Sum('cached_availability_paid_orders')
).values(
's'
)
qs = self.request.event.subevents
if list:
qs = qs.annotate(
sum_tickets_paid=Subquery(sum_tickets_paid, output_field=IntegerField())
).prefetch_related(
qs = qs.prefetch_related(
Prefetch('quotas',
queryset=Quota.objects.annotate(s=Coalesce(F('size'), 0)).order_by('-s'),
queryset=self.request.event.quotas.annotate(s=Coalesce(F('size'), 0)).order_by('-s'),
to_attr='first_quotas')
)
if self.filter_form.is_valid():
@@ -108,15 +97,12 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
qa = QuotaAvailability(early_out=False)
for q in quotas:
if q.cached_availability_time is None or q.cached_availability_paid_orders is None:
qa.queue(q)
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = (
qa.results[q] if q in qa.results
else (q.cached_availability_state, q.cached_availability_number)
)
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,
@@ -1184,7 +1170,7 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie
for fname in ('size', 'name', 'release_after_exit'):
setattr(q, fname, f.cleaned_data.get(fname))
q.save(clear_cache=False)
if 'itemvar' in f.changed_data:
if 'itemvars' in f.changed_data:
q.items.set(selected_items)
q.variations.set(selected_variations)
log_entries.append(
@@ -1220,8 +1206,7 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie
).values(
'item_list', 'var_list',
*(f.name for f in Quota._meta.fields if f.name not in (
'id', 'event', 'items', 'variations', 'cached_availability_state', 'cached_availability_number',
'cached_availability_paid_orders', 'cached_availability_time', 'closed',
'id', 'event', 'items', 'variations', 'closed',
))
).order_by('subevent_id')

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