Compare commits

...

110 Commits

Author SHA1 Message Date
Richard Schreiber
6d8d06e78f fix isort 2024-04-15 16:26:11 +02:00
Richard Schreiber
3bde9d8b0e Fix: show country name in countrycodes question in profile description 2024-04-15 16:26:11 +02:00
Richard Schreiber
11956a8f4d fix isort 2024-04-15 16:25:25 +02:00
Raphael Michel
d0c58713c4 Badge/ticket export: Allow to sort by company 2024-04-15 10:55:40 +02:00
Raphael Michel
46da0bda61 Placeholders: Use dynamic sample for name_for_salutation 2024-04-15 10:55:40 +02:00
Raphael Michel
009e3a6d36 Respect Order.valid_if_pending for mail_attach_ical_paid_only (#4065) 2024-04-12 15:00:00 +02:00
Martin Gross
c01855270b Approval Process: Attach ical to approval-messages if properly configured (#4064) 2024-04-12 11:18:21 +02:00
dependabot[bot]
2df1585b71 Update sentry-sdk requirement from ==1.44.* to ==1.45.* (#4058)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/1.44.0...1.45.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-11 13:33:01 +02:00
Raphael Michel
bf48ae567f NREI export: Fix parsing of stripe data 2024-04-11 09:27:55 +02:00
dependabot[bot]
5a72c72d18 Update django-otp requirement from ==1.3.* to ==1.4.* (#4057)
Updates the requirements on [django-otp](https://github.com/django-otp/django-otp) to permit the latest version.
- [Changelog](https://github.com/django-otp/django-otp/blob/master/CHANGES.rst)
- [Commits](https://github.com/django-otp/django-otp/compare/v1.3.0...v1.4.0)

---
updated-dependencies:
- dependency-name: django-otp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-10 09:56:40 +02:00
Raphael Michel
ac02f3b417 API: Fix crash in order creation (PRETIXEU-9XS) 2024-04-10 09:40:35 +02:00
Martin Gross
58add74b3a Stripe: Add PayPal (#4049) (Z#23123667) 2024-04-09 10:06:58 +02:00
Raphael Michel
0067c3537d Fix invalid orders being created in a complex situation (#4054)
This was a bug that took days to find. The story goes like this: A cart
is created with four positions that each include four bundled positions.
A discount is applied, changing the price of *one* of the four top-level
positions to a reduced value. The list of position IDs gets passed to
`perform_order()`, which later passes it on to `transform_cart_positions()`.
`transform_cart_positions()`, however, receives the positions in an order
that has the first-level product *after* the bundled products that
belong to it. Therefore, it can't properly assign the parent-child
relationship between the positions.

The main reason is that cart positions are processed in "database order"
in a number of places, i.e. we make `SELECT` queries without an explicit
`ORDER BY` statement, leading the database to respond in unspecified
order. This is the case for `get_cart()` and hence for `CartMixin.positions`,
and hence for the list of position IDs that is passed to `perform_order()`
and hence for the order in which discounts are processed.

Therefore, if this "databse order" of the cart positions changes, the
discount compuation in `_check_positions()` might make a different choice
of *which* cart position should receive the discount than the CartManager
originally did. That's not nice, but most customers would not even
notice that a different one of their four (otherwise identical) tickets
is now discounted than the cart originally showed.

This leads to `_check_positions()` changing the price on two of the
cart positions. However, it only changes the price on the copy of
the CartPosition object that is directly part of the positions array,
while the `addon_to` attribute of its bundled positions contain a
*different* representation of the same cart position, that is not
refreshed to have the updated price now in the database.

This causes the `CartPosition.sort_key` of the bundled products to be
significantly different from the one of their parent products, which can
cause `transform_cart_positions()` to try to insert them before their
respective parent product, which is how the bug leads to the nasty end
result.

Now, I'm still not sure why this has happened *now* for the first time,
but I suspect it *might* even have something to do with our operations
team tuning our autovacuum parameters on our production installation,
which might make it *more likely* that newly created cart positions are
arbitrarily  stored on PostgreSQL disk pages in a different order than
they were inserted than before.

This commit now fixes the bug now in two ways, each of which would be
sufficient to fix it for now, but together they make it hopefully more
stable in the future:

- `perform_order` no longer respects the order of the position IDs it
  gets passed in, but instead uses the order last displayed in the cart.
  Additionally, both `CartManager` and `_check_positions()` now sort
  positions by their `pk` value before applying discounts to ensure
  consistent choice of which position is discounted (using  `sort_key`
  here does not make much sense since it includes sorting by price,
  which is about to change).

- `_check_positions()` makes sure that after its completion, only one
  copy of the same `CartPosition` is in use that has the current price.

Additionally, this commit makes sure `sort_key` cache is cleared after
e.g. a price change.

It was hard to write a regression test, since "database order" is, by
definition, unreliable, but I tried my best.
2024-04-08 16:55:54 +02:00
Raphael Michel
64ae1d08a6 Docs: Fix incorrect API response 2024-04-05 17:28:44 +02:00
Raphael Michel
ca25c3c81e Add logging for special bug case (Z#23149646) 2024-04-04 18:13:54 +02:00
Raphael Michel
abbe9ec897 Order creation: Fail loudly on invalid addon-to relationship 2024-04-03 17:21:47 +02:00
Raphael Michel
a7735d5d9e API: Allow request_valid_from in the past (#4048) 2024-04-03 17:21:25 +02:00
Raphael Michel
174c81a22b Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5598 of 5598 strings)

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

powered by weblate
2024-04-03 13:15:57 +02:00
Raphael Michel
38c6294ede Translations: Update German
Currently translated at 100.0% (5598 of 5598 strings)

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

powered by weblate
2024-04-03 13:15:57 +02:00
Raphael Michel
217ae90642 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-03 12:43:52 +02:00
Raphael Michel
0c998ca884 Remove special handling of translations in pretix/helpers 2024-04-03 12:43:20 +02:00
Raphael Michel
b1691f867d Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5594 of 5594 strings)

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

powered by weblate
2024-04-03 11:43:39 +02:00
Raphael Michel
72e451b27b Translations: Update German
Currently translated at 100.0% (5594 of 5594 strings)

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

powered by weblate
2024-04-03 11:43:39 +02:00
Raphael Michel
8124ced6c1 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-03 11:22:25 +02:00
Raphael Michel
a3139944f6 Send notifications about login with new client or country (#4032)
* Send notifications about login with new client or country

* Rebase migration

* Remove immediately

* Fix isort

* Text update
2024-04-03 11:19:20 +02:00
Felix Schäfer
48493c517b Add database.disable_server_side_cursors option (#4016) 2024-04-03 10:16:48 +02:00
dependabot[bot]
535a29bf4b Update css-inline requirement from ==0.13.* to ==0.14.* (#4043)
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/c-v0.13.0...c-v0.14.0)

---
updated-dependencies:
- dependency-name: css-inline
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-03 10:16:13 +02:00
dependabot[bot]
9d415f5179 Update pillow requirement from ==10.2.* to ==10.3.* (#4044)
Updates the requirements on [pillow](https://github.com/python-pillow/Pillow) to permit the latest version.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/10.2.0...10.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-03 10:16:03 +02:00
Raphael Michel
990e9da21d Generalize import process from orders to more models (#4002)
* Generalize import process from orders to more models

* Add voucher import

* Model import: Guess assignments of based on column headers

* Fix lock_seats being pointless

* Update docs

* Update doc/development/api/import.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update src/pretix/base/modelimport_vouchers.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-04-03 10:15:30 +02:00
Raphael Michel
4afb7a4976 Allow admins to generate emergency 2FA tokens (#4035)
* Allow admins to generate emergency 2FA tokens

* Update src/pretix/control/views/users.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-04-03 10:15:17 +02:00
Raphael Michel
22e5579ed1 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael Michel
79cd84e243 Translations: Update German
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael Michel
fad4b8846c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael Michel
0f4790afd8 Translations: Update German
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael Michel
2068a5ac29 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-02 17:53:26 +02:00
Raphael Michel
440c97061c Fix duplicate key on SQLite (#4038)
* Fix duplicate key on SQLite

* Update migration
2024-04-02 17:37:37 +02:00
Raphael Michel
3b6d0c4341 Translations: Add Slovak 2024-04-02 17:28:11 +02:00
Raphael Michel
06ac4b0250 Translations: Add Slovak 2024-04-02 17:28:11 +02:00
Raphael Michel
a233b92f6f Add disable date of waiting list to event timeline (#4036) 2024-04-02 17:15:41 +02:00
Raphael Michel
4ea4189e6d Allow team admins to require two-factor authentication (#4034)
* Allow team admins to require two-factor authentication

* Add API tests

* Improve logic

* ADd button tooltip
2024-04-02 17:15:16 +02:00
Mira
50838b9cea Update github action versions (#4033) 2024-04-02 13:53:09 +02:00
Raphael Michel
c68ee56d51 Log discarding a valid session for suspicious reasons (#4025) 2024-04-02 13:52:30 +02:00
Thatthep
5c0587c30e Translations: Update Thai
Currently translated at 0.6% (36 of 5574 strings)

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

powered by weblate
2024-04-02 11:45:11 +02:00
Raphael Michel
f3f42a8a42 Login: Add logging for incorrect JS hostnames 2024-04-02 11:34:43 +02:00
dependabot[bot]
20d0a9a0ed Update django-countries requirement from ==7.5.* to ==7.6.* (#4031)
Updates the requirements on [django-countries](https://github.com/SmileyChris/django-countries) to permit the latest version.
- [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.rst)
- [Commits](https://github.com/SmileyChris/django-countries/compare/v7.5...v7.6.1)

---
updated-dependencies:
- dependency-name: django-countries
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 11:07:51 +02:00
Raphael Michel
cda8144ff0 Enforce uniqueness of order codes and ticket secrets (#3988)
* Enforce uniqueness of order codes and ticket secrets

* Fix test cases which created orders with identical codes

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-04-02 11:07:40 +02:00
dependabot[bot]
43e8875c1e Update sentry-sdk requirement from ==1.42.* to ==1.44.* (#4021)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/1.42.0...1.44.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:26:37 +02:00
dependabot[bot]
28c142b2ed Update pytest-mock requirement from ==3.12.* to ==3.14.* (#4009)
Updates the requirements on [pytest-mock](https://github.com/pytest-dev/pytest-mock) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-mock/releases)
- [Changelog](https://github.com/pytest-dev/pytest-mock/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.12.0...v3.14.0)

---
updated-dependencies:
- dependency-name: pytest-mock
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:14:24 +02:00
dependabot[bot]
46203fd8ba Bump @babel/core from 7.24.0 to 7.24.3 in /src/pretix/static/npm_dir (#4027)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.24.0 to 7.24.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.24.3/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:13:39 +02:00
dependabot[bot]
52e45c37df Update webauthn requirement from ==2.0.* to ==2.1.* (#4022)
Updates the requirements on [webauthn](https://github.com/duo-labs/py_webauthn) to permit the latest version.
- [Release notes](https://github.com/duo-labs/py_webauthn/releases)
- [Changelog](https://github.com/duo-labs/py_webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/duo-labs/py_webauthn/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: webauthn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:13:17 +02:00
dependabot[bot]
d1580dca2c Bump django-filter from 24.1 to 24.2 (#4017)
Bumps [django-filter](https://github.com/carltongibson/django-filter) from 24.1 to 24.2.
- [Release notes](https://github.com/carltongibson/django-filter/releases)
- [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst)
- [Commits](https://github.com/carltongibson/django-filter/compare/24.1...24.2)

---
updated-dependencies:
- dependency-name: django-filter
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:13:00 +02:00
dependabot[bot]
cd9e672871 Bump pycparser from 2.21 to 2.22 (#4029)
Bumps [pycparser](https://github.com/eliben/pycparser) from 2.21 to 2.22.
- [Release notes](https://github.com/eliben/pycparser/releases)
- [Changelog](https://github.com/eliben/pycparser/blob/main/CHANGES)
- [Commits](https://github.com/eliben/pycparser/compare/release_v2.21...release_v2.22)

---
updated-dependencies:
- dependency-name: pycparser
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:10:42 +02:00
dependabot[bot]
427f508627 Bump @babel/preset-env from 7.24.0 to 7.24.3 in /src/pretix/static/npm_dir (#4028)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.24.0 to 7.24.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.24.3/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:10:01 +02:00
Raphael Michel
887d06a485 Add .watchmanconfig 2024-03-31 23:14:57 +02:00
Raphael Michel
fb49046ac1 Log and count user logins (#4020)
* Log and count user logins

* Allow metrics without label

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-03-28 17:18:51 +01:00
Martin Gross
ce826e50f7 PDF: Check for allowed font name before adding modifiers to the name 2024-03-27 12:54:35 +01:00
Raphael Michel
d866c6954d Bump to 2024.4.0.dev0 2024-03-27 12:15:31 +01:00
Raphael Michel
40c76dda74 Bump version to 2024.3.0 2024-03-27 12:14:07 +01:00
Raphael Michel
f532853021 Memberships: Prefer valid_from over event date for .is_valid() (#4003)
* Memberships: Prefer valid_from over event date for .is_valid()

* Fix tests

* Add parameter description

* Use reasonable default for requested_valid_from if membership starts in the future

* Set datetimepicker viewDate to closest allowed date

* Keep current value on going back to QuestionsStep

* Fix min_date/max_date in SplitDateTimePickerWidget

* Remove unused import

* Update src/pretix/base/models/memberships.py

Co-authored-by: Mira <weller@rami.io>

* Respect variations

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-03-27 12:11:20 +01:00
Martin Gross
8cb187502d Event Fonts: Only run register_event_fonts with actual, still existing events (FIXES PRETIXEU-9WE) 2024-03-26 17:13:13 +01:00
Raphael Michel
156037f2cd Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5574 of 5574 strings)

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

powered by weblate
2024-03-26 16:53:43 +01:00
Raphael Michel
134d63fb3f Translations: Update German
Currently translated at 100.0% (5574 of 5574 strings)

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

powered by weblate
2024-03-26 16:53:43 +01:00
Raphael Michel
816002fda0 Translations: Update German (informal) (de_Informal)
Currently translated at 99.9% (5572 of 5574 strings)

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

powered by weblate
2024-03-26 16:53:43 +01:00
Raphael Michel
3939bbc11c Translations: Update German
Currently translated at 100.0% (5574 of 5574 strings)

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

powered by weblate
2024-03-26 16:53:43 +01:00
Raphael Michel
95d1603cc7 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-03-26 14:37:18 +01:00
Raphael Michel
ada3ada699 Fix #3974 -- Webhooks: Add type of checkin (#3994) 2024-03-26 14:21:57 +01:00
Richard Schreiber
97eaeac4f2 Fix: improve order-change headline on customer’s order page (Z#23148804) (#4012) 2024-03-26 11:19:17 +01:00
Martin Gross
d67f5c650c Event-specific fonts and Web-Embedded Fonts (Z#23130701) (#3893) 2024-03-26 09:55:08 +01:00
Raphael Michel
273c1ae0a6 Waiting list: Allow to set auto-disable date (Z#23141338) (#4004)
* Waiting list: Allow to set auto-disable date (Z#23141338)

* ADd warning on non-esries events
2024-03-22 11:17:02 +01:00
Mira
a946c10ab4 Build jsi18n for all supported languages (#4007) 2024-03-22 10:19:27 +01:00
Raphael Michel
2d8fba7d7c Treat partially paid expired orders as overpaid orders (Z#23147757) (#3990)
* Treat partially paid expired orders as overpaid orders (Z#23147757)

* Use is_overpaid from annotate_overpayments in OrderFilterForm

* Revert change to pending sum

* Show warning on order page

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-03-22 10:17:51 +01:00
Mira
e4e0bd7ca0 Fix regression in thumb filter (#4006) 2024-03-21 14:32:01 +01:00
Richard Schreiber
3651c88289 Widget: pass utm-params from embedding page to presale 2024-03-20 09:19:02 +01:00
Richard Schreiber
c92ca40026 Fix code style/flake8 issue 2024-03-19 13:56:43 +01:00
Mira
4d00efb549 Fix generating thumbs for favicon.ico 2024-03-19 13:54:02 +01:00
Raphael Michel
7e60d13910 Fix #3984 -- API: Add phone to customer resource (#3992)
* Fix #3984 -- API: Add phone to customer resource

* add "phone": None to test

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-03-19 10:17:44 +01:00
Martin Weinelt
35800e21c7 Allow customization of cache and log directory (#3997)
On systems that follow the FHS it may be desirable to separate logs and
cache files into dedicated base directories (e.g. /var/log/pretix or
/var/cache/pretix).
2024-03-19 10:17:36 +01:00
Raphael Michel
99b4c5bd36 Fix cart grouping function to include membership and validity (#3991)
* Fix cart grouping function to include membership and validity

* Update src/pretix/presale/views/__init__.py

Co-authored-by: Mira <weller@rami.io>

---------

Co-authored-by: Mira <weller@rami.io>
2024-03-19 10:17:26 +01:00
Raphael Michel
f121205dd1 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (225 of 225 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/de_Informal/

powered by weblate
2024-03-19 10:16:58 +01:00
Raphael Michel
1ac54cd209 Translations: Update German
Currently translated at 100.0% (225 of 225 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/de/

powered by weblate
2024-03-19 10:16:58 +01:00
Raphael Michel
4694719a53 API: Fix creating free orders requiring approval 2024-03-19 10:10:57 +01:00
Raphael Michel
9513b6e8d7 Gift card payment: Fix public_name fallback 2024-03-15 17:09:21 +01:00
dependabot[bot]
4fd7d406a0 Update sentry-sdk requirement from ==1.41.* to ==1.42.* (#3983)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/1.41.0...1.42.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-15 16:49:52 +01:00
dependabot[bot]
47cb5b207a Update pytest-rerunfailures requirement from ==13.* to ==14.* (#3982)
Updates the requirements on [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) to permit the latest version.
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/13.0...14.0)

---
updated-dependencies:
- dependency-name: pytest-rerunfailures
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-15 16:42:03 +01:00
dependabot[bot]
7d2cf68727 Bump markdown from 3.5.2 to 3.6 (#3985)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.5.2 to 3.6.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.5.2...3.6)

---
updated-dependencies:
- dependency-name: markdown
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-15 16:41:49 +01:00
dependabot[bot]
459cb47ca8 Update protobuf requirement from ==4.25.* to ==5.26.* (#3986)
Updates the requirements on [protobuf](https://github.com/protocolbuffers/protobuf) to permit the latest version.
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/protobuf_release.bzl)
- [Commits](https://github.com/protocolbuffers/protobuf/compare/v4.25.0-rc1...v5.26.0)

---
updated-dependencies:
- dependency-name: protobuf
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-15 16:41:31 +01:00
Mira
39705556cd Fix tests that failed between 0:00 and 4:00 / 4:30 AM (#3987) 2024-03-15 16:40:56 +01:00
Raphael Michel
9f794290dc Memberships: Check valid_from/valid_until for parallel usage (#3975) 2024-03-15 16:40:41 +01:00
Raphael Michel
b6221ab6d9 Improve error messages for test mode checks for memberships 2024-03-15 15:57:11 +01:00
Richard Schreiber
483518bce9 Fix: left align first column header on invoices without tax 2024-03-15 13:34:19 +01:00
Raphael Michel
d9019ae735 Fix splitting free orders that require approval 2024-03-15 11:37:27 +01:00
Raphael Michel
721fd3b998 Remove incorrectly named, duplicate test file 2024-03-15 10:46:47 +01:00
Raphael Michel
ad0d3f5469 PDF editor: Minor UX adjustments 2024-03-14 12:34:21 +01:00
Raphael Michel
40b44f9272 Stripe: Do not open 3rd-party payment pages in iframe (#3981)
* Stripe: Do not open 3rd-party payment pages in iframe

* Update src/pretix/plugins/stripe/payment.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-03-14 11:49:41 +01:00
Mira
304d290f22 Presale: improve clientside handling of max-count for add-on products
* Fix typo in error message

* Use exclusive checkboxes for addon items with max_count == 1 and !multi_allowed

* combine exclusive items + variations

* move exclusive to containing fieldset

* fix add-on-exclusive

* add max_count check

* fix plus/minus-stepper buttons bubbling

* Update src/pretix/static/pretixpresale/js/ui/main.js

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-03-14 09:17:42 +01:00
Richard Schreiber
7592a8a575 Presale: add data-attribute price to each item’s article-element
* Presale: add data-attribute price to article

* fix price for items (defaul_price)

Co-authored-by: Mira <weller@rami.io>

* add data-price to voucher and addons, unlocalize price

* use universal display_price for items and variation, respect net or gross

---------

Co-authored-by: Mira <weller@rami.io>
2024-03-14 09:15:44 +01:00
dependabot[bot]
4f33159f93 Update sentry-sdk requirement from ==1.40.* to ==1.41.* (#3963)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/1.40.0...1.41.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-12 13:05:09 +01:00
dependabot[bot]
819ce6abf7 Bump django-filter from 23.5 to 24.1 (#3969)
Bumps [django-filter](https://github.com/carltongibson/django-filter) from 23.5 to 24.1.
- [Release notes](https://github.com/carltongibson/django-filter/releases)
- [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst)
- [Commits](https://github.com/carltongibson/django-filter/compare/23.5...24.1)

---
updated-dependencies:
- dependency-name: django-filter
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-12 13:04:54 +01:00
dependabot[bot]
760dfd22b8 Bump @babel/core from 7.23.9 to 7.24.0 in /src/pretix/static/npm_dir (#3951)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.23.9 to 7.24.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.24.0/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-12 13:04:31 +01:00
Mira
f9eaa193c9 Add migration to force slash after hostname in returnurl prefixes (#3948)
* Add migration to force slash after hostname in returnurl prefixes

* isort

* add dependency to pretixbase
2024-03-12 12:41:48 +01:00
dependabot[bot]
c7720a2553 Update pytest requirement from ==8.0.* to ==8.1.* (#3976)
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.0rc1...8.1.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-12 12:41:33 +01:00
fyksen
7754f5420c Translations: Update Norwegian Bokmål
Currently translated at 98.1% (5462 of 5565 strings)

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

powered by weblate
2024-03-12 12:41:21 +01:00
Raphael Michel
2c7ada6e86 OIDC: Do not expect uid to be string 2024-03-12 10:52:20 +01:00
dependabot[bot]
3f31843fd1 Update dnspython requirement from ==2.5.* to ==2.6.* (#3927)
Updates the requirements on [dnspython](https://github.com/rthalley/dnspython) to permit the latest version.
- [Release notes](https://github.com/rthalley/dnspython/releases)
- [Changelog](https://github.com/rthalley/dnspython/blob/main/doc/whatsnew.rst)
- [Commits](https://github.com/rthalley/dnspython/compare/v2.5.0rc1...v2.6.1)

---
updated-dependencies:
- dependency-name: dnspython
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 13:19:11 +01:00
fyksen
952b9bd9b9 Translations: Update Norwegian Bokmål
Currently translated at 95.5% (215 of 225 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nb_NO/

powered by weblate
2024-03-11 13:18:45 +01:00
fyksen
3619a6bcd0 Translations: Update Norwegian Bokmål
Currently translated at 98.1% (5462 of 5565 strings)

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

powered by weblate
2024-03-11 13:18:45 +01:00
Richard Schreiber
3e2c12cdb0 Presale: fix auto-unchecked button-checkboxes (#3968) 2024-03-08 19:33:06 +01:00
Raphael Michel
a3ce3b9af3 Select2: Fix multi-select styling for events 2024-03-08 10:09:11 +01:00
Raphael Michel
b6461e9303 Select2: Set closeOnSelect for event selection 2024-03-08 10:08:44 +01:00
Raphael Michel
f7dfd51c2c Open invoices on new page 2024-03-07 10:57:41 +01:00
238 changed files with 138668 additions and 92946 deletions

View File

@@ -26,12 +26,12 @@ jobs:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}

View File

@@ -25,12 +25,12 @@ jobs:
name: Spellcheck
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}

View File

@@ -23,12 +23,12 @@ jobs:
runs-on: ubuntu-22.04
name: Check gettext syntax
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -48,12 +48,12 @@ jobs:
runs-on: ubuntu-22.04
name: Spellcheck
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}

View File

@@ -23,12 +23,12 @@ jobs:
name: isort
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -43,12 +43,12 @@ jobs:
name: flake8
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -63,9 +63,9 @@ jobs:
name: licenseheaders
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install Dependencies

View File

@@ -32,7 +32,7 @@ jobs:
- database: sqlite
python-version: "3.10"
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- uses: harmon758/postgresql-action@v1
with:
postgresql version: '15'
@@ -41,10 +41,10 @@ jobs:
postgresql password: 'postgres'
if: matrix.database == 'postgres'
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}

View File

@@ -52,10 +52,18 @@ Example::
``currency``
The default currency as a three-letter code. Defaults to ``EUR``.
``cachedir``
The local path to a directory where temporary files will be stored.
Defaults to the ``cache`` directory below the ``datadir``.
``datadir``
The local path to a data directory that will be used for storing user uploads and similar
data. Defaults to the value of the environment variable ``DATA_DIR`` or ``data``.
``logdir``
The local path to a directory where log files will be stored.
Defaults to the ``logs`` directory below the ``datadir``.
``plugins_default``
A comma-separated list of plugins that are enabled by default for all new events.
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
@@ -89,8 +97,9 @@ Example::
Defaults to ``off``.
``obligatory_2fa``
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
Defaults to ``False``
Enables or disables obligatory usage of two-factor authentication for users of the pretix backend.
Can be ``True`` to make two-factor authentication obligatory for all users or ``staff`` to make it only
obligatory to users with admin permissions. Defaults to ``False``.
``trust_x_forwarded_for``
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
@@ -149,6 +158,7 @@ Example::
host=localhost
port=3306
advisory_lock_index=1
disable_server_side_cursors=0
sslmode=require
sslrootcert=/etc/pretix/postgresql-ca.crt
sslcert=/etc/pretix/postgresql-client-crt.crt
@@ -169,6 +179,11 @@ Example::
and are not scoped to a specific database. If you run multiple pretix applications with the same PostgreSQL server,
you should set separate values for this setting (integers up to 256).
``disable_server_side_cursors``
On PostgreSQL pretix might use server side cursors for certain operations. This is generally fine but will break in
specific circumstances, for example when connecting to PostgreSQL through a PGBouncer configured with a transaction
pool mode. Off by default (i.e. by default server side cursors will be used).
``sslmode``, ``sslrootcert``
Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default.

View File

@@ -249,7 +249,10 @@ You can get three response codes:
Content-Type: application/json
{
"event": "democon",
"event": {
"name": "Demo Conference",
"slug": "democon"
},
"subevent": 23,
"checkinlist": 5
}

View File

@@ -19,6 +19,7 @@ external_identifier string External ID of
the API, but is read-only for customers created through a
SSO integration.
email string Customer email address
phone string Customer phone number
name string Name of this customer (or ``null``)
name_parts object of strings Decomposition of name (i.e. given name, family name)
is_active boolean Whether this account is active
@@ -39,6 +40,10 @@ password string Can only be set
Passwords can now be set through the API during customer creation.
.. versionchanged:: 2024.3
The attribute ``phone`` has been added.
Endpoints
---------
@@ -71,6 +76,7 @@ Endpoints
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "customer@example.org",
"phone": "+493012345678",
"name": "John Doe",
"name_parts": {
"_scheme": "full",
@@ -118,6 +124,7 @@ Endpoints
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "customer@example.org",
"phone": "+493012345678",
"name": "John Doe",
"name_parts": {
"_scheme": "full",
@@ -155,6 +162,7 @@ Endpoints
{
"email": "test@example.org",
"phone": "+493012345678",
"password": "verysecret",
"send_email": true
}
@@ -171,6 +179,7 @@ Endpoints
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "test@example.org",
"phone": "+493012345678",
...
}
@@ -215,6 +224,7 @@ Endpoints
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "test@example.org",
"phone": "+493012345678",
}
@@ -249,6 +259,7 @@ Endpoints
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": null,
"phone": null,
}

View File

@@ -22,6 +22,8 @@ id integer Internal ID of
name string Team name
all_events boolean Whether this team has access to all events
limit_events list List of event slugs this team has access to
require_2fa boolean Whether members of this team are required to use
two-factor authentication
can_create_events boolean
can_change_teams boolean
can_change_organizer_settings boolean
@@ -122,6 +124,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -159,6 +162,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -186,6 +190,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -203,6 +208,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -246,6 +252,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}

View File

@@ -3,11 +3,12 @@
.. _`importcol`:
Extending the order import process
==================================
Extending the import process
============================
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
It's possible through the backend to import objects into pretix, for example orders from a legacy ticketing system. If
your plugin defines additional data structures around those objects, it might be useful to make it possible to import
them as well.
Import process
--------------
@@ -40,7 +41,7 @@ Column registration
The import API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available import columns. Your plugin
should listen for this signal and return the subclass of ``pretix.base.orderimport.ImportColumn``
should listen for this signal and return the subclass of ``pretix.base.modelimport.ImportColumn``
that we'll provide in this plugin:
.. sourcecode:: python
@@ -56,10 +57,16 @@ that we'll provide in this plugin:
EmailColumn(sender),
]
Similar signals exist for other objects:
.. automodule:: pretix.base.signals
:members: voucher_import_columns
The column class API
--------------------
.. class:: pretix.base.orderimport.ImportColumn
.. class:: pretix.base.modelimport.ImportColumn
The central object of each import extension is the subclass of ``ImportColumn``.

View File

@@ -84,6 +84,8 @@ convenient to you:
.. automethod:: _register_fonts
.. automethod:: _register_event_fonts
.. automethod:: _on_first_page
.. automethod:: _on_other_page

View File

@@ -33,14 +33,14 @@ dependencies = [
"celery==5.3.*",
"chardet==5.2.*",
"cryptography>=3.4.2",
"css-inline==0.13.*",
"css-inline==0.14.*",
"defusedcsv>=1.1.0",
"dj-static",
"Django==4.2.*",
"django-bootstrap3==23.6.*",
"django-compressor==4.4",
"django-countries==7.5.*",
"django-filter==23.5",
"django-countries==7.6.*",
"django-filter==24.2",
"django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1",
"django-hierarkey==1.1.*",
@@ -50,13 +50,13 @@ dependencies = [
"django-localflavor==4.0",
"django-markup",
"django-oauth-toolkit==2.3.*",
"django-otp==1.3.*",
"django-otp==1.4.*",
"django-phonenumber-field==7.3.*",
"django-redis==5.4.*",
"django-scopes==2.0.*",
"django-statici18n==2.4.*",
"djangorestframework==3.14.*",
"dnspython==2.5.*",
"dnspython==2.6.*",
"drf_ujson2==1.7.*",
"geoip2==4.*",
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
@@ -65,7 +65,7 @@ dependencies = [
"kombu==5.3.*",
"libsass==0.23.*",
"lxml",
"markdown==3.5.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.6", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.2.*",
@@ -75,12 +75,12 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.8.*",
"phonenumberslite==8.13.*",
"Pillow==10.2.*",
"Pillow==10.3.*",
"pretix-plugin-build",
"protobuf==4.25.*",
"protobuf==5.26.*",
"psycopg2-binary",
"pycountry",
"pycparser==2.21",
"pycparser==2.22",
"pycryptodome==3.20.*",
"pypdf==3.9.*",
"python-bidi==0.4.*", # Support for Arabic in reportlab
@@ -92,7 +92,7 @@ dependencies = [
"redis==5.0.*",
"reportlab==4.1.*",
"requests==2.31.*",
"sentry-sdk==1.40.*",
"sentry-sdk==1.45.*",
"sepaxml==2.6.*",
"slimit",
"static3==0.7.*",
@@ -100,9 +100,10 @@ dependencies = [
"text-unidecode==1.*",
"tlds>=2020041600",
"tqdm==4.*",
"ua-parser==0.18.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*",
"webauthn==2.0.*",
"webauthn==2.1.*",
"zeep==4.2.*"
]
@@ -122,11 +123,11 @@ dev = [
"pytest-cache",
"pytest-cov",
"pytest-django==4.*",
"pytest-mock==3.12.*",
"pytest-rerunfailures==13.*",
"pytest-mock==3.14.*",
"pytest-rerunfailures==14.*",
"pytest-sugar",
"pytest-xdist==3.5.*",
"pytest==8.0.*",
"pytest==8.1.*",
"responses",
]

4
src/.watchmanconfig Normal file
View File

@@ -0,0 +1,4 @@
{
"ignore_dirs": ["node_modules", "data", "pretix/static", "pretix/locale", "pretix/static.dist"]
}

View File

@@ -6,7 +6,7 @@ localecompile:
./manage.py compilemessages
localegen:
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot --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

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2024.3.0.dev0"
__version__ = "2024.4.0.dev0"

View File

@@ -111,6 +111,7 @@ LANGUAGES_RTL = {
LANGUAGES_INCUBATING = {
'fi', 'pt-br', 'gl',
}
LANGUAGES = ALL_LANGUAGES
LOCALE_PATHS = [
os.path.join(os.path.dirname(__file__), 'locale'),
]

View File

@@ -39,7 +39,8 @@ from pretix.base.models import Device, Event, User
from pretix.base.models.auth import SuperuserPermissionSet
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
SessionReauthRequired, assert_session_valid,
)
@@ -66,6 +67,10 @@ class EventPermission(BasePermission):
return False
except SessionReauthRequired:
return False
except Session2FASetupRequired:
return False
except SessionPasswordChangeRequired:
return False
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
else request.user)
@@ -144,6 +149,10 @@ class ProfilePermission(BasePermission):
return False
except SessionReauthRequired:
return False
except Session2FASetupRequired:
return False
except SessionPasswordChangeRequired:
return False
if isinstance(request.auth, OAuthAccessToken):
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
@@ -166,5 +175,9 @@ class AnyAuthenticatedClientPermission(BasePermission):
return False
except SessionReauthRequired:
return False
except Session2FASetupRequired:
return False
except SessionPasswordChangeRequired:
return False
return True

View File

@@ -687,6 +687,7 @@ class EventSettingsSerializer(SettingsSerializer):
'allow_modifications_after_checkin',
'show_quota_left',
'waiting_list_enabled',
'waiting_list_auto_disable',
'waiting_list_hours',
'waiting_list_auto',
'waiting_list_names_asked',

View File

@@ -1315,7 +1315,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
valid_from, valid_until = pos_data['item'].compute_validity(
requested_start=(
max(requested_valid_from, now())
requested_valid_from
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
else now()
),
@@ -1439,6 +1439,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
else:
pos._calculate_tax(invoice_address=ia)
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = listed_price
@@ -1466,7 +1467,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
pos = pos_data['__instance']
pos._calculate_tax()
pos._calculate_tax(invoice_address=ia)
if simulate:
pos = WrappedModel(pos)
@@ -1585,7 +1586,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
payment_provider = 'free'
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
order.status = Order.STATUS_PAID
order.save()
order.payments.create(
@@ -1597,6 +1598,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
elif validated_data.get('status') == Order.STATUS_PAID:
if not payment_provider:
raise ValidationError('You cannot create a paid order without a payment provider.')
if validated_data.get('require_approval'):
raise ValidationError('You cannot create a paid order that requires approval.')
order.payments.create(
amount=order.total,
provider=payment_provider,

View File

@@ -79,8 +79,8 @@ class CustomerSerializer(I18nAwareModelSerializer):
class Meta:
model = Customer
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified', 'notes')
fields = ('identifier', 'external_identifier', 'email', 'phone', 'name', 'name_parts', 'is_active',
'is_verified', 'last_login', 'date_joined', 'locale', 'last_modified', 'notes')
def update(self, instance, validated_data):
if instance and instance.provider_id:
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = (
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'id', 'name', 'require_2fa', '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_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'

View File

@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import datetime
import logging
import mimetypes
import os
from decimal import Decimal
@@ -27,7 +28,7 @@ from zoneinfo import ZoneInfo
import django_filters
from django.conf import settings
from django.db import transaction
from django.db import IntegrityError, transaction
from django.db.models import (
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
)
@@ -97,6 +98,8 @@ from pretix.base.signals import (
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import order_search_filter_q
logger = logging.getLogger(__name__)
with scopes_disabled():
class OrderFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
@@ -900,7 +903,11 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
def perform_create(self, serializer):
serializer.save()
try:
serializer.save()
except IntegrityError:
logger.exception("Integrity error while saving order")
raise ValidationError("Integrity error, possibly duplicate submission of same order.")
def perform_destroy(self, instance):
if not instance.testmode:

View File

@@ -176,7 +176,7 @@ class ParametrizedItemWebhookEvent(ParametrizedWebhookEvent):
}
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
class ParametrizedOrderPositionCheckinWebhookEvent(ParametrizedOrderWebhookEvent):
def build_payload(self, logentry: LogEntry):
d = super().build_payload(logentry)
@@ -185,6 +185,7 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
d['orderposition_id'] = logentry.parsed_data.get('position')
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
d['checkin_list'] = logentry.parsed_data.get('list')
d['type'] = logentry.parsed_data.get('type')
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
return d
@@ -296,11 +297,11 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.denied',
_('Order denied'),
),
ParametrizedOrderPositionWebhookEvent(
ParametrizedOrderPositionCheckinWebhookEvent(
'pretix.event.checkin',
_('Ticket checked in'),
),
ParametrizedOrderPositionWebhookEvent(
ParametrizedOrderPositionCheckinWebhookEvent(
'pretix.event.checkin.reverted',
_('Ticket check-in reverted'),
),

View File

@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
from . import invoice # NOQA
from . import notifications # NOQA
from . import email # NOQA
from .services import auth, checkin, currencies, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .models import _transactions # NOQA
from django.conf import settings

View File

@@ -116,15 +116,29 @@ class DekodiNREIExporter(BaseExporter):
'PTNo15': p.full_id or '',
})
elif p.provider and p.provider.startswith('stripe'):
src = p.info_data.get("source", p.info_data)
pi = p.info_data or {}
try:
if "latest_charge" in pi and isinstance(pi.get("latest_charge"), dict):
details = pi["latest_charge"]["payment_method_details"]
card = details.get("card", {})
elif pi.get("charges") and pi["charges"]["data"]:
details = pi["charges"]["data"][0].get("payment_method_details", {})
card = details.get("card", {})
else:
details = pi["source"]
card = pi["source"]["card"]
except:
details = {}
card = {}
payments.append({
'PTID': '81',
'PTN': 'Stripe',
'PTNo1': p.info_data.get("id") or '',
'PTNo5': src.get("card", {}).get("last4") or '',
'PTNo1': pi.get("id") or '',
'PTNo5': card.get("last4", ""),
'PTNo7': round(float(p.amount), 2) or '',
'PTNo8': str(self.event.currency) or '',
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
'PTNo10': details.get('owner', {}).get('verified_name') or details.get('owner', {}).get('name') or '',
'PTNo15': p.full_id or '',
})
else:

View File

@@ -35,6 +35,7 @@
import hashlib
import ipaddress
import logging
from django import forms
from django.conf import settings
@@ -44,10 +45,13 @@ from django.contrib.auth.password_validation import (
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix.base.metrics import pretix_failed_logins
from pretix.base.models import User
from pretix.helpers.dicts import move_to_end
from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__)
class LoginForm(forms.Form):
"""
@@ -55,6 +59,7 @@ class LoginForm(forms.Form):
username/password logins.
"""
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
origin = forms.CharField(widget=forms.HiddenInput, required=False)
error_messages = {
'invalid_login': _("This combination of credentials is not known to our system."),
@@ -104,12 +109,16 @@ class LoginForm(forms.Form):
rc = get_redis_connection("redis")
cnt = rc.get(self.ratelimit_key)
if cnt and int(cnt) > 10:
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None:
if self.ratelimit_key:
rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 300)
logger.info("Backend login invalid.")
pretix_failed_logins.inc(1, reason="invalid")
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'
@@ -131,6 +140,8 @@ class LoginForm(forms.Form):
If the given user may log in, this method should return None.
"""
if not user.is_active:
logger.info("Backend login rejected due to user inactive.")
pretix_failed_logins.inc(1, reason="inactive")
raise forms.ValidationError(
self.error_messages['inactive'],
code='inactive',

View File

@@ -609,27 +609,38 @@ class BaseQuestionsForm(forms.Form):
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
else:
max_date = None
min_date = now()
initial = None
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
if pos.used_membership.date_start >= now():
initial = min_date = pos.used_membership.date_start
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
attrs = {}
if max_date:
attrs['data-max'] = max_date.date().isoformat()
if min_date:
attrs['data-min'] = min_date.date().isoformat()
self.fields['requested_valid_from'] = forms.DateField(
label=_('Start date'),
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
required=False,
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
required=bool(initial),
initial=pos.requested_valid_from or initial,
widget=DatePickerWidget(attrs),
validators=[MaxDateValidator(max_date.date())] if max_date else []
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
)
else:
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
label=_('Start date'),
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
required=False,
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
required=bool(initial),
initial=pos.requested_valid_from or initial,
widget=SplitDateTimePickerWidget(
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
min_date=min_date,
max_date=max_date
),
validators=[MaxDateTimeValidator(max_date)] if max_date else []
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
)
add_fields = {}

View File

@@ -33,7 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import os
from datetime import date
from datetime import datetime
from django import forms
from django.utils.formats import get_format
@@ -188,11 +188,11 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
time_attrs['autocomplete'] = 'off'
if min_date:
date_attrs['data-min'] = (
min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date()
min_date if not isinstance(min_date, datetime) else min_date.astimezone(get_current_timezone()).date()
).isoformat()
if max_date:
date_attrs['data-max'] = (
max_date if isinstance(max_date, date) else max_date.astimezone(get_current_timezone()).date()
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
).isoformat()
def date_placeholder():

View File

@@ -182,7 +182,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
italic='OpenSansIt', boldItalic='OpenSansBI')
for family, styles in get_fonts().items():
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
if family == self.event.settings.invoice_renderer_font:
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
self.font_regular = family
@@ -625,7 +625,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)]
else:
tdata = [(
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['BoldRight']),
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
)]

View File

@@ -268,7 +268,10 @@ def metric_values():
dkey = key.decode("utf-8")
splitted = dkey.split("{", 2)
value = float(value.decode("utf-8"))
metrics[splitted[0]]["{" + splitted[1]] = value
if len(splitted) == 1:
metrics[splitted[0]][""] = value
else:
metrics[splitted[0]]["{" + splitted[1]] = value
# Aliases
aliases = {
@@ -314,3 +317,5 @@ pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a cel
["task_name", "status"])
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
["task_name"])
pretix_successful_logins = Counter("pretix_logins_successful", "Successful logins", [])
pretix_failed_logins = Counter("pretix_logins_failed", "Failed logins", ["reason"])

View File

@@ -20,7 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from collections import OrderedDict
from urllib.parse import urlsplit
from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
@@ -40,6 +40,7 @@ from pretix.base.settings import global_settings_object
from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain,
)
from pretix.presale.style import get_fonts
_supported = None
@@ -240,6 +241,14 @@ class SecurityMiddleware(MiddlewareMixin):
)
def process_response(self, request, resp):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
url = resolve(request.path_info)
if settings.DEBUG and resp.status_code >= 400:
@@ -259,6 +268,14 @@ class SecurityMiddleware(MiddlewareMixin):
if gs.settings.leaflet_tiles:
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
font_src = set()
if hasattr(request, 'event'):
for font in get_fonts(request.event, pdf_support_required=False).values():
for path in list(nested_dict_values(font)):
font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
h = {
'default-src': ["{static}"],
'script-src': ['{static}'],
@@ -267,7 +284,7 @@ class SecurityMiddleware(MiddlewareMixin):
'style-src': ["{static}", "{media}"],
'connect-src': ["{dynamic}", "{media}"],
'img-src': ["{static}", "{media}", "data:"] + img_src,
'font-src': ["{static}"],
'font-src': ["{static}"] + list(font_src),
'media-src': ["{static}", "data:"],
# form-action is not only used to match on form actions, but also on URLs
# form-actions redirect to. In the context of e.g. payment providers or

View File

@@ -0,0 +1,48 @@
# Generated by Django 4.2.10 on 2024-03-15 09:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0257_item_default_price_not_null"),
]
operations = [
migrations.AddField(
model_name="order",
name="organizer",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AddField(
model_name="orderposition",
name="organizer",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="order_positions",
to="pretixbase.organizer",
),
),
migrations.AddConstraint(
model_name="order",
constraint=models.UniqueConstraint(
fields=("organizer", "code"), name="order_organizer_code_uniq"
),
),
migrations.AddConstraint(
model_name="orderposition",
constraint=models.UniqueConstraint(
models.F("organizer"),
models.F("secret"),
name="orderposition_organizer_secret_uniq",
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-04-02 11:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0258_uniq_indx"),
]
operations = [
migrations.AddField(
model_name="team",
name="require_2fa",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2024-04-02 15:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0259_team_require_2fa"),
]
operations = [
migrations.AlterIndexTogether(
name="reusablemedium",
index_together=set(),
),
]

View File

@@ -0,0 +1,48 @@
# Generated by Django 4.2.10 on 2024-04-02 15:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.helpers.countries
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0260_alter_reusablemedium_index_together"),
]
operations = [
migrations.CreateModel(
name="UserKnownLoginSource",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("agent_type", models.CharField(max_length=255, null=True)),
("device_type", models.CharField(max_length=255, null=True)),
("os_type", models.CharField(max_length=255, null=True)),
(
"country",
pretix.helpers.countries.FastCountryField(
countries=pretix.helpers.countries.CachedCountries,
max_length=2,
null=True,
),
),
("last_seen", models.DateTimeField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="known_login_sources",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -0,0 +1,287 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import csv
import datetime
import io
import re
from decimal import Decimal, DecimalException
from django.core.exceptions import ValidationError
from django.core.validators import validate_integer
from django.utils import formats
from django.utils.functional import cached_property
from django.utils.translation import gettext as _, gettext_lazy, pgettext
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import SubEvent
class DataImportError(LazyLocaleException):
def __init__(self, *args):
msg = args[0]
msgargs = args[1] if len(args) > 1 else None
self.args = args
if msgargs:
msg = _(msg) % msgargs
else:
msg = _(msg)
super().__init__(msg)
def parse_csv(file, length=None, mode="strict", charset=None):
file.seek(0)
data = file.read(length)
if not charset:
try:
import chardet
charset = chardet.detect(data)['encoding']
except ImportError:
charset = file.charset
data = data.decode(charset or "utf-8", mode)
# If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n')
try:
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
except csv.Error:
return None
if dialect is None:
return None
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
return reader
class ImportColumn:
@property
def identifier(self):
"""
Unique, internal name of the column.
"""
raise NotImplementedError
@property
def verbose_name(self):
"""
Human-readable description of the column
"""
raise NotImplementedError
@property
def initial(self):
"""
Initial value for the form component
"""
return None
@property
def default_value(self):
"""
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
option.
"""
return 'empty'
@property
def default_label(self):
"""
Human-readable description of the default assignment of this column, defaults to "Keep empty".
"""
return gettext_lazy('Keep empty')
def __init__(self, event):
self.event = event
def static_choices(self):
"""
This will be called when rendering the form component and allows you to return a list of values that can be
selected by the user statically during import.
:return: list of 2-tuples of strings
"""
return []
def resolve(self, settings, record):
"""
This method will be called to get the raw value for this field, usually by either using a static value or
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
the default should be fine.
"""
k = settings.get(self.identifier, self.default_value)
if k == self.default_value:
return None
elif k.startswith('csv:'):
return record.get(k[4:], None) or None
elif k.startswith('static:'):
return k[7:]
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
def clean(self, value, previous_values):
"""
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
You do not need to include the column or row name or value in the error message as it will automatically be
included.
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
e.g. if the column is empty or does not exist in this row.
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
"""
return value
def assign(self, value, obj, **kwargs):
"""
This will be called to perform the actual import. You are supposed to set attributes on the ``obj`` or other
related objects that get passed in based on the input ``value``. This is called *before* the actual database
transaction, so the input objects do not yet have a primary key. If you want to create related objects, you
need to place them into some sort of internal queue and persist them when ``save`` is called.
"""
pass
def save(self, obj):
"""
This will be called to perform the actual import. This is called inside the actual database transaction and the
input object ``obj`` has already been saved to the database.
"""
pass
@property
def timezone(self):
return self.event.timezone
def i18n_flat(l):
if isinstance(l.data, dict):
return l.data.values()
return [l.data]
class BooleanColumnMixin:
default_value = None
initial = "static:false"
def static_choices(self):
return (
("false", _("No")),
("true", _("Yes")),
)
def clean(self, value, previous_values):
if not value:
return False
if value.lower() in ("true", "1", "yes", _("Yes").lower()):
return True
elif value.lower() in ("false", "0", "no", _("No").lower()):
return False
else:
raise ValidationError(_("Could not parse {value} as a yes/no value.").format(value=value))
class DatetimeColumnMixin:
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.timezone)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
class DecimalColumnMixin:
def clean(self, value, previous_values):
if value not in (None, ''):
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
try:
value = Decimal(value)
except (DecimalException, TypeError):
raise ValidationError(_('You entered an invalid number.'))
return value
class IntegerColumnMixin:
def clean(self, value, previous_values):
if value is not None:
validate_integer(value)
return int(value)
class SubeventColumnMixin:
def __init__(self, *args, **kwargs):
self._subevent_cache = {}
super().__init__(*args, **kwargs)
@cached_property
def subevents(self):
return list(self.event.subevents.filter(active=True).order_by('date_from'))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.subevents
]
def clean(self, value, previous_values):
if value in self._subevent_cache:
return self._subevent_cache[value]
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
try:
se = self.event.subevents.get(
active=True,
date_from__gt=d - datetime.timedelta(seconds=1),
date_from__lt=d + datetime.timedelta(seconds=1),
)
self._subevent_cache[value] = se
return se
except SubEvent.DoesNotExist:
raise ValidationError(pgettext("subevent", "No matching date was found."))
except SubEvent.MultipleObjectsReturned:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
except (ValueError, TypeError):
continue
matches = [
p for p in self.subevents
if str(p.pk) == value or any(
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
]
if len(matches) == 0:
raise ValidationError(pgettext("subevent", "No matching date was found."))
if len(matches) > 1:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
self._subevent_cache[value] = matches[0]
return matches[0]

View File

@@ -19,17 +19,13 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import datetime
import re
from collections import defaultdict
from decimal import Decimal, DecimalException
import pycountry
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.db.models import Q
from django.utils import formats
from django.utils.functional import cached_property
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
@@ -42,9 +38,13 @@ from phonenumbers import SUPPORTED_REGIONS
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.questions import guess_country
from pretix.base.modelimport import (
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
i18n_flat,
)
from pretix.base.models import (
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
QuestionOption, Seat, SubEvent,
QuestionOption, Seat,
)
from pretix.base.services.pricing import get_price
from pretix.base.settings import (
@@ -53,99 +53,6 @@ from pretix.base.settings import (
from pretix.base.signals import order_import_columns
class ImportColumn:
@property
def identifier(self):
"""
Unique, internal name of the column.
"""
raise NotImplementedError
@property
def verbose_name(self):
"""
Human-readable description of the column
"""
raise NotImplementedError
@property
def initial(self):
"""
Initial value for the form component
"""
return None
@property
def default_value(self):
"""
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
option.
"""
return 'empty'
@property
def default_label(self):
"""
Human-readable description of the default assignment of this column, defaults to "Keep empty".
"""
return gettext_lazy('Keep empty')
def __init__(self, event):
self.event = event
def static_choices(self):
"""
This will be called when rendering the form component and allows you to return a list of values that can be
selected by the user statically during import.
:return: list of 2-tuples of strings
"""
return []
def resolve(self, settings, record):
"""
This method will be called to get the raw value for this field, usually by either using a static value or
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
the default should be fine.
"""
k = settings.get(self.identifier, self.default_value)
if k == self.default_value:
return None
elif k.startswith('csv:'):
return record.get(k[4:], None) or None
elif k.startswith('static:'):
return k[7:]
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
def clean(self, value, previous_values):
"""
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
You do not need to include the column or row name or value in the error message as it will automatically be
included.
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
e.g. if the column is empty or does not exist in this row.
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
"""
return value
def assign(self, value, order, position, invoice_address, **kwargs):
"""
This will be called to perform the actual import. You are supposed to set attributes on the ``order``, ``position``,
or ``invoice_address`` objects based on the input ``value``. This is called *before* the actual database
transaction, so these three objects do not yet have a primary key. If you want to create related objects, you
need to place them into some sort of internal queue and persist them when ``save`` is called.
"""
pass
def save(self, order):
"""
This will be called to perform the actual import. This is called inside the actual database transaction and the
input object ``order`` has already been saved to the database.
"""
pass
class EmailColumn(ImportColumn):
identifier = 'email'
verbose_name = gettext_lazy('E-mail address')
@@ -182,74 +89,20 @@ class PhoneColumn(ImportColumn):
order.phone = value
class SubeventColumn(ImportColumn):
class SubeventColumn(SubeventColumnMixin, ImportColumn):
identifier = 'subevent'
verbose_name = pgettext_lazy('subevents', 'Date')
default_value = None
def __init__(self, *args, **kwargs):
self._subevent_cache = {}
super().__init__(*args, **kwargs)
@cached_property
def subevents(self):
return list(self.event.subevents.filter(active=True).order_by('date_from'))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.subevents
]
def clean(self, value, previous_values):
if not value:
raise ValidationError(pgettext("subevent", "You need to select a date."))
if value in self._subevent_cache:
return self._subevent_cache[value]
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
try:
se = self.event.subevents.get(
active=True,
date_from__gt=d - datetime.timedelta(seconds=1),
date_from__lt=d + datetime.timedelta(seconds=1),
)
self._subevent_cache[value] = se
return se
except SubEvent.DoesNotExist:
raise ValidationError(pgettext("subevent", "No matching date was found."))
except SubEvent.MultipleObjectsReturned:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
except (ValueError, TypeError):
continue
matches = [
p for p in self.subevents
if str(p.pk) == value or any(
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
]
if len(matches) == 0:
raise ValidationError(pgettext("subevent", "No matching date was found."))
if len(matches) > 1:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
self._subevent_cache[value] = matches[0]
return matches[0]
return super().clean(value, previous_values)
def assign(self, value, order, position, invoice_address, **kwargs):
position.subevent = value
def i18n_flat(l):
if isinstance(l.data, dict):
return l.data.values()
return [l.data]
class ItemColumn(ImportColumn):
identifier = 'item'
verbose_name = gettext_lazy('Product')
@@ -572,20 +425,11 @@ class AttendeeState(ImportColumn):
position.state = value or ''
class Price(ImportColumn):
class Price(DecimalColumnMixin, ImportColumn):
identifier = 'price'
verbose_name = gettext_lazy('Price')
default_label = gettext_lazy('Calculate from product')
def clean(self, value, previous_values):
if value not in (None, ''):
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
try:
value = Decimal(value)
except (DecimalException, TypeError):
raise ValidationError(_('You entered an invalid number.'))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
if value is None:
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
@@ -649,48 +493,18 @@ class Locale(ImportColumn):
order.locale = value
class ValidFrom(ImportColumn):
class ValidFrom(DatetimeColumnMixin, ImportColumn):
identifier = 'valid_from'
verbose_name = gettext_lazy('Valid from')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_from = value
class ValidUntil(ImportColumn):
class ValidUntil(DatetimeColumnMixin, ImportColumn):
identifier = 'valid_until'
verbose_name = gettext_lazy('Valid until')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_until = value
@@ -849,7 +663,7 @@ class CustomerColumn(ImportColumn):
order.customer = value
def get_all_columns(event):
def get_order_import_columns(event):
default = []
if event.has_subevents:
default.append(SubeventColumn(event))

View File

@@ -0,0 +1,378 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.utils.functional import cached_property
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pretix.base.modelimport import (
BooleanColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn,
IntegerColumnMixin, i18n_flat,
)
from pretix.base.models import ItemVariation, Quota, Seat, Voucher
from pretix.base.signals import voucher_import_columns
class CodeColumn(ImportColumn):
identifier = 'code'
verbose_name = gettext_lazy('Voucher code')
default_value = None
def __init__(self, *args):
self._cached = set()
super().__init__(*args)
def clean(self, value, previous_values):
if value:
MinLengthValidator(5)(value)
if value and (value in self._cached or Voucher.objects.filter(event=self.event, code=value).exists()):
raise ValidationError(_('A voucher with this code already exists.'))
self._cached.add(value)
return value
def assign(self, value, obj: Voucher, **kwargs):
obj.code = value
class SubeventColumn(ImportColumn):
identifier = 'subevent'
verbose_name = pgettext_lazy('subevents', 'Date')
def assign(self, value, obj: Voucher, **kwargs):
obj.subevent = value
class MaxUsagesColumn(IntegerColumnMixin, ImportColumn):
identifier = 'max_usages'
verbose_name = gettext_lazy('Maximum usages')
initial = "static:1"
def static_choices(self):
return [
("1", "1")
]
def assign(self, value, obj: Voucher, **kwargs):
obj.max_usages = value if value is not None else 1
class MinUsagesColumn(IntegerColumnMixin, ImportColumn):
identifier = 'min_usages'
verbose_name = gettext_lazy('Minimum usages')
initial = "static:1"
def static_choices(self):
return [
("1", "1")
]
def assign(self, value, obj: Voucher, **kwargs):
obj.min_usages = value if value is not None else 1
class BudgetColumn(DecimalColumnMixin, ImportColumn):
identifier = 'budget'
verbose_name = gettext_lazy('Maximum discount budget')
def assign(self, value, obj: Voucher, **kwargs):
obj.budget = value
class ValidUntilColumn(DatetimeColumnMixin, ImportColumn):
identifier = 'valid_until'
verbose_name = gettext_lazy('Valid until')
def assign(self, value, obj: Voucher, **kwargs):
obj.valid_until = value
class BlockQuotaColumn(BooleanColumnMixin, ImportColumn):
identifier = 'block_quota'
verbose_name = gettext_lazy('Reserve ticket from quota')
def assign(self, value, obj: Voucher, **kwargs):
obj.block_quota = value
class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
identifier = 'allow_ignore_quota'
verbose_name = gettext_lazy('Allow to bypass quota')
def assign(self, value, obj: Voucher, **kwargs):
obj.allow_ignore_quota = value
class PriceModeColumn(ImportColumn):
identifier = 'price_mode'
verbose_name = gettext_lazy('Price mode')
default_value = None
initial = 'static:none'
def static_choices(self):
return Voucher.PRICE_MODES
def clean(self, value, previous_values):
d = dict(Voucher.PRICE_MODES)
reverse = {v: k for k, v in Voucher.PRICE_MODES}
if value in d:
return value
elif value in reverse:
return reverse[value]
else:
raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
value=value, options=', '.join(d.keys())
))
def assign(self, value, voucher: Voucher, **kwargs):
voucher.price_mode = value
class ValueColumn(DecimalColumnMixin, ImportColumn):
identifier = 'value'
verbose_name = gettext_lazy('Voucher value')
def clean(self, value, previous_values):
value = super().clean(value, previous_values)
if value and previous_values.get("price_mode") == "none":
raise ValidationError(_("It is pointless to set a value without a price mode."))
return value
def assign(self, value, obj: Voucher, **kwargs):
obj.value = value or Decimal("0.00")
class ItemColumn(ImportColumn):
identifier = 'item'
verbose_name = gettext_lazy('Product')
@cached_property
def items(self):
return list(self.event.items.filter(active=True))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.items
]
def clean(self, value, previous_values):
if not value:
return
matches = [
p for p in self.items
if str(p.pk) == value or (p.internal_name and p.internal_name == value) or any(
(v and v == value) for v in i18n_flat(p.name))
]
if len(matches) == 0:
raise ValidationError(_("No matching product was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching products were found."))
return matches[0]
def assign(self, value, voucher, **kwargs):
voucher.item = value
class VariationColumn(ImportColumn):
identifier = 'variation'
verbose_name = gettext_lazy('Product variation')
@cached_property
def items(self):
return list(ItemVariation.objects.filter(
active=True, item__active=True, item__event=self.event
).select_related('item'))
def static_choices(self):
return [
(str(p.pk), '{} {}'.format(p.item, p.value)) for p in self.items
]
def clean(self, value, previous_values):
if value:
matches = [
p for p in self.items
if (str(p.pk) == value or any((v and v == value) for v in i18n_flat(p.value))) and p.item_id == previous_values['item'].pk
]
if len(matches) == 0:
raise ValidationError(_("No matching variation was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching variations were found."))
return matches[0]
return value
def assign(self, value, voucher: Voucher, **kwargs):
voucher.variation = value
class QuotaColumn(ImportColumn):
identifier = 'quota'
verbose_name = gettext_lazy('Quota')
@cached_property
def quotas(self):
return list(Quota.objects.filter(
event=self.event
))
def static_choices(self):
return [
(str(q.pk), q.name) for q in self.quotas
]
def clean(self, value, previous_values):
if value:
if previous_values.get('item'):
raise ValidationError(_("You cannot specify a quota if you specified a product."))
matches = [
q for q in self.quotas
if str(q.pk) == value or any((v and v == value) for v in i18n_flat(q.name))
]
if len(matches) == 0:
raise ValidationError(_("No matching variation was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching variations were found."))
return matches[0]
return value
def assign(self, value, voucher: Voucher, **kwargs):
voucher.quota = value
class SeatColumn(ImportColumn):
identifier = 'seat'
verbose_name = gettext_lazy('Seat ID')
def __init__(self, *args):
self._cached = set()
super().__init__(*args)
def clean(self, value, previous_values):
if value:
if self.event.has_subevents:
if not previous_values.get('subevent'):
raise ValidationError(_('You need to choose a date if you select a seat.'))
try:
value = Seat.objects.get(
event=self.event,
seat_guid=value,
subevent=previous_values.get('subevent')
)
except Seat.MultipleObjectsReturned:
raise ValidationError(_('Multiple matching seats were found.'))
except Seat.DoesNotExist:
raise ValidationError(_('No matching seat was found.'))
if not value.is_available() or value in self._cached:
raise ValidationError(
_('The seat you selected has already been taken. Please select a different seat.'))
if previous_values.get("quota"):
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
if previous_values.get('max_usages', 1) > 1 or previous_values.get('min_usages', 1) > 1:
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
if previous_values.get("item") and value.product != previous_values.get("item"):
raise ValidationError(
_('You need to choose the product "{prod}" for this seat.').format(prod=value.product)
)
self._cached.add(value)
return value
def assign(self, value, voucher: Voucher, **kwargs):
voucher.seat = value
class TagColumn(ImportColumn):
identifier = 'tag'
verbose_name = gettext_lazy('Tag')
def assign(self, value, voucher: Voucher, **kwargs):
voucher.tag = value or ''
class CommentColumn(ImportColumn):
identifier = 'comment'
verbose_name = gettext_lazy('Comment')
def assign(self, value, voucher: Voucher, **kwargs):
voucher.comment = value or ''
class ShowHiddenItemsColumn(BooleanColumnMixin, ImportColumn):
identifier = 'show_hidden_items'
verbose_name = gettext_lazy('Shows hidden products that match this voucher')
initial = "static:true"
def assign(self, value, obj: Voucher, **kwargs):
obj.show_hidden_items = value
class AllAddonsIncludedColumn(BooleanColumnMixin, ImportColumn):
identifier = 'all_addons_included'
verbose_name = gettext_lazy('Offer all add-on products for free when redeeming this voucher')
def assign(self, value, obj: Voucher, **kwargs):
obj.all_addons_included = value
class AllBundlesIncludedColumn(BooleanColumnMixin, ImportColumn):
identifier = 'all_bundles_included'
verbose_name = gettext_lazy('Include all bundled products without a designated price when redeeming this voucher')
def assign(self, value, obj: Voucher, **kwargs):
obj.all_bundles_included = value
def get_voucher_import_columns(event):
default = []
if event.has_subevents:
default.append(SubeventColumn(event))
default += [
CodeColumn(event),
MaxUsagesColumn(event),
MinUsagesColumn(event),
BudgetColumn(event),
ValidUntilColumn(event),
BlockQuotaColumn(event),
AllowIgnoreQuotaColumn(event),
PriceModeColumn(event),
ValueColumn(event),
ItemColumn(event),
VariationColumn(event),
QuotaColumn(event),
SeatColumn(event),
TagColumn(event),
CommentColumn(event),
ShowHiddenItemsColumn(event),
AllAddonsIncludedColumn(event),
AllBundlesIncludedColumn(event),
]
for recv, resp in voucher_import_columns.send(sender=event):
default += resp
return default

View File

@@ -56,6 +56,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor
from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri
from ...helpers.countries import FastCountryField
from ...helpers.u2f import pub_key_from_der, websafe_decode
from .base import LoggingMixin
@@ -579,6 +580,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
self.save(update_fields=['session_token'])
class UserKnownLoginSource(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
agent_type = models.CharField(max_length=255, null=True, blank=True)
device_type = models.CharField(max_length=255, null=True, blank=True)
os_type = models.CharField(max_length=255, null=True, blank=True)
country = FastCountryField(null=True, blank=True)
last_seen = models.DateTimeField()
class StaffSession(models.Model):
user = models.ForeignKey('User', on_delete=models.PROTECT)
date_start = models.DateTimeField(auto_now_add=True)

View File

@@ -229,6 +229,14 @@ class EventMixin:
else:
return self.presale_end
@property
def waiting_list_active(self):
if not self.settings.waiting_list_enabled:
return False
if self.settings.waiting_list_auto_disable:
return self.settings.waiting_list_auto_disable.datetime(self) > now()
return True
@property
def presale_has_ended(self):
"""

View File

@@ -122,7 +122,6 @@ class ReusableMedium(LoggedModel):
class Meta:
unique_together = (("identifier", "type", "organizer"),)
indexes = [
models.Index(fields=("identifier", "type", "organizer")),
models.Index(fields=("updated", "id")),
]
ordering = "identifier", "type", "organizer"

View File

@@ -49,7 +49,8 @@ class MembershipType(LoggedModel):
allow_parallel_usage = models.BooleanField(
verbose_name=_('Parallel usage is allowed'),
help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note '
'that this will only check for an identical start time of the events, not for any overlap between events.'),
'that this will only check for an identical start time of the events, not for any overlap between events. An overlap '
'check will be performed if there is a product-level validity of the ticket.'),
default=False
)
max_usages = models.PositiveIntegerField(
@@ -162,8 +163,12 @@ class Membership(models.Model):
def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
def is_valid(self, ev=None):
if ev:
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
if valid_from_not_chosen:
return not self.canceled and self.date_end >= now()
elif ticket_valid_from:
dt = ticket_valid_from
elif ev:
dt = ev.date_from
else:
dt = now()

View File

@@ -188,6 +188,14 @@ class Order(LockModel, LoggedModel):
default=False,
)
testmode = models.BooleanField(default=False)
organizer = models.ForeignKey(
# Redundant foreign key, but is required for a uniqueness constraint
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
@@ -286,6 +294,9 @@ class Order(LockModel, LoggedModel):
models.Index(fields=["datetime", "id"]),
models.Index(fields=["last_modified", "id"]),
]
constraints = [
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
]
def __str__(self):
return self.full_code
@@ -451,9 +462,9 @@ class Order(LockModel, LoggedModel):
if results:
qs = qs.annotate(
is_overpaid=Case(
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
When(~Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_t__lt=-1e-8),
then=Value(1)),
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__lt=-1e-8),
then=Value(1)),
default=Value(0),
output_field=models.IntegerField()
@@ -468,7 +479,7 @@ class Order(LockModel, LoggedModel):
is_underpaid=Case(
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
then=Value(1)),
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__gt=1e-8),
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__gt=1e-8),
then=Value(1)),
default=Value(0),
output_field=models.IntegerField()
@@ -499,6 +510,10 @@ class Order(LockModel, LoggedModel):
self.set_expires()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'expires'}.union(kwargs['update_fields'])
if not self.organizer_id:
self.organizer_id = self.event.organizer_id
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
is_new = not self.pk
update_fields = kwargs.get('update_fields', [])
@@ -2356,6 +2371,14 @@ class OrderPosition(AbstractPosition):
"""
positionid = models.PositiveIntegerField(default=1)
organizer = models.ForeignKey(
# Redundant foreign key, but is required for a uniqueness constraint
"Organizer",
related_name="order_positions",
on_delete=models.CASCADE,
null=True,
blank=True,
)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
@@ -2429,6 +2452,9 @@ class OrderPosition(AbstractPosition):
verbose_name = _("Order position")
verbose_name_plural = _("Order positions")
ordering = ("positionid", "id")
constraints = [
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
]
@cached_property
def sort_key(self):
@@ -2498,7 +2524,8 @@ class OrderPosition(AbstractPosition):
op = OrderPosition(order=order)
for f in AbstractPosition._meta.fields:
if f.name == 'addon_to':
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
if cartpos.addon_to_id:
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
@@ -2518,6 +2545,9 @@ class OrderPosition(AbstractPosition):
op.valid_from = valid_from
op.valid_until = valid_until
if op.is_bundled and not op.addon_to_id:
raise ValueError("Bundled cart position without parent does not make sense.")
op.positionid = i + 1
op.save()
ops.append(op)
@@ -2552,10 +2582,10 @@ class OrderPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.order_id
)
def _calculate_tax(self, tax_rule=None):
def _calculate_tax(self, tax_rule=None, invoice_address=None):
self.tax_rule = tax_rule or self.item.tax_rule
try:
ia = self.order.invoice_address
ia = invoice_address or self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if self.tax_rule:
@@ -2585,6 +2615,10 @@ class OrderPosition(AbstractPosition):
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'secret'}.union(kwargs['update_fields'])
if not self.organizer_id:
self.organizer_id = self.order.event.organizer_id
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
if not self.blocked and self.blocked is not None:
self.blocked = None
if 'update_fields' in kwargs:
@@ -2932,6 +2966,14 @@ class CartPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.cart_id
)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# invalidate cached values of cached properties that likely have changed
try:
del self.sort_key
except AttributeError:
pass
@property
def tax_value(self):
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),

View File

@@ -263,6 +263,12 @@ class Team(LoggedModel):
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
require_2fa = models.BooleanField(
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
"authentication or leave the team. The setting may take a few minutes to become effective for "
"all users.")
)
can_create_events = models.BooleanField(
default=False,

View File

@@ -517,9 +517,6 @@ class Voucher(LoggedModel):
if item and seat.product != item:
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
if not seat.is_available(ignore_voucher_id=pk):
raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid))
return seat
def save(self, *args, **kwargs):

View File

@@ -1311,9 +1311,7 @@ class GiftCardPayment(BasePaymentProvider):
@property
def public_name(self) -> str:
return str(self.settings.get("public_name", as_type=LazyI18nString)) or _(
"Gift card"
)
return str(self.settings.get("public_name", as_type=LazyI18nString) or _("Gift card"))
@property
def settings_form_fields(self):

View File

@@ -78,7 +78,7 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.i18n import language
from pretix.base.models import Order, OrderPosition, Question
from pretix.base.models import Event, Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
@@ -738,9 +738,10 @@ class Renderer:
else:
self.bg_bytes = None
self.bg_pdf = None
self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans']
@classmethod
def _register_fonts(cls):
def _register_fonts(cls, event: Event = None):
if hasattr(cls, '_fonts_registered'):
return
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
@@ -748,7 +749,7 @@ class Renderer:
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
for family, styles in get_fonts().items():
for family, styles in get_fonts(event, pdf_support_required=True).items():
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
@@ -934,6 +935,13 @@ class Renderer:
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
font = o['fontfamily']
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
# should not have access to.
if font not in self.event_fonts:
logger.warning(f'Unauthorized use of font "{font}"')
font = 'Open Sans'
if o['bold']:
font += ' B'
if o['italic']:

View File

@@ -203,7 +203,7 @@ error_messages = {
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
'min'
),
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'),
'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'),
'seat_required': gettext_lazy('You need to select a specific seat.'),
@@ -1358,7 +1358,7 @@ class CartManager:
return err
def recompute_final_prices_and_taxes(self):
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
positions = sorted(list(self.positions), key=lambda cp: (-(cp.addon_to_id or 0), cp.pk))
diff = Decimal('0.00')
for cp in positions:
if cp.listed_price is None:

View File

@@ -31,6 +31,7 @@ from pretix.base.models import CachedCombinedTicket, CachedTicket
from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource
from ..signals import periodic_task
@@ -75,3 +76,9 @@ def clearsessions(sender, **kwargs):
@scopes_disabled()
def clear_oidc_data(sender, **kwargs):
CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete()
@receiver(signal=periodic_task)
@scopes_disabled()
def clear_old_login_sources(sender, **kwargs):
UserKnownLoginSource.objects.filter(last_seen__lt=now() - timedelta(days=365)).delete()

View File

@@ -29,8 +29,8 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from pretix.base.models import (
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
SubEvent,
AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order,
OrderPosition, SubEvent,
)
from pretix.helpers import OF_SELF
@@ -82,7 +82,8 @@ def create_membership(customer: Customer, position: OrderPosition):
)
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False):
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False,
valid_from_not_chosen=False):
"""
Validate that a set of cart or order positions. This currently does not validate
@@ -92,6 +93,8 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
:param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships
:param ignored_order: An order that should be ignored for usage counting
:param testmode: If ``True``, only test mode memberships are allowed. If ``False``, test mode memberships are not allowed.
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
"""
tz = event.timezone
applicable_positions = [
@@ -132,7 +135,11 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
qs = qs.exclude(order_id=ignored_order.pk)
m._used_at_dates = [
(op.subevent or op.order.event).date_from
for op in qs
for op in qs if not op.valid_from or not op.valid_until
]
m._used_for_ranges = [
(op.valid_from, op.valid_until)
for op in qs if op.valid_from or op.valid_until
]
for p in applicable_positions:
@@ -147,22 +154,44 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
_('You selected membership that has been canceled.')
)
if m.testmode != testmode:
if m.testmode and not testmode:
raise ValidationError(
_('You can only use a test mode membership for test mode tickets.')
_('You can not use a test mode membership for tickets that are not in test mode.')
)
elif not m.testmode and testmode:
raise ValidationError(
_('You need to add a test mode membership to the customer account to use it in test mode.')
)
ev = p.subevent or event
if not m.is_valid(ev):
raise ValidationError(
_('You selected a membership that is valid from {start} to {end}, but selected an event '
'taking place at {date}.').format(
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
if isinstance(p, (OrderPosition, CartPosition)):
# override_ variants are for usage of fake cart in OrderChangeManager
valid_from = getattr(p, 'override_valid_from', p.valid_from)
valid_until = getattr(p, 'override_valid_until', p.valid_until)
else: # future safety, not technically defined on AbstractPosition
valid_from = None
valid_until = None
if not m.is_valid(ev, valid_from, valid_from_not_chosen=p.item.validity_dynamic_start_choice and valid_from_not_chosen):
if valid_from:
raise ValidationError(
_('You selected a membership that is valid from {start} to {end}, but selected a ticket that '
'starts to be valid on {date}.').format(
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
date=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
else:
raise ValidationError(
_('You selected a membership that is valid from {start} to {end}, but selected an event '
'taking place at {date}.').format(
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
)
if p.variation and p.variation.require_membership:
types = p.variation.require_membership_types.all()
@@ -188,13 +217,34 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
m.usages += 1
if not m.membership_type.allow_parallel_usage:
df = ev.date_from
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
raise ValidationError(
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
'however you already used the same membership for a different ticket at the same time.').format(
type=m.membership_type.name,
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
if (valid_from or valid_until) and not (p.item.validity_dynamic_start_choice and valid_from_not_chosen):
for used_range in m._used_for_ranges:
if valid_from and valid_from > used_range[1]:
continue
if valid_until and valid_until < used_range[0]:
continue
raise ValidationError(
_('You are trying to use a membership of type "{type}" for a ticket valid from {valid_from} '
'until {valid_until}, however you already used the same membership for a different ticket '
'that overlaps with this time frame ({conflict_from} {conflict_until}).').format(
type=m.membership_type.name,
valid_from=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_from else _('start'),
valid_until=date_format(valid_until.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_until else _('open end'),
conflict_from=date_format(used_range[0].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[0] else _('start'),
conflict_until=date_format(used_range[1].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[1] else _('open end'),
)
)
)
m._used_at_dates.append(ev.date_from)
m._used_for_ranges.append((p.valid_from, p.valid_until))
if not valid_from or not valid_until:
df = ev.date_from
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
raise ValidationError(
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
'however you already used the same membership for a different ticket at the same time.').format(
type=m.membership_type.name,
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
m._used_at_dates.append(ev.date_from)

View File

@@ -19,9 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import csv
import io
from decimal import Decimal
from typing import List
from django.conf import settings as django_settings
from django.core.exceptions import ValidationError
@@ -29,13 +28,15 @@ from django.db import transaction
from django.utils.timezone import now
from django.utils.translation import gettext as _
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.i18n import language
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
from pretix.base.modelimport_orders import get_order_import_columns
from pretix.base.modelimport_vouchers import get_voucher_import_columns
from pretix.base.models import (
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
User,
User, Voucher,
)
from pretix.base.models.orders import Transaction
from pretix.base.orderimport import get_all_columns
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.locking import lock_objects
from pretix.base.services.tasks import ProfiledEventTask
@@ -43,47 +44,36 @@ from pretix.base.signals import order_paid, order_placed
from pretix.celery_app import app
class DataImportError(LazyLocaleException):
def __init__(self, *args):
msg = args[0]
msgargs = args[1] if len(args) > 1 else None
self.args = args
if msgargs:
msg = _(msg) % msgargs
else:
msg = _(msg)
super().__init__(msg)
def parse_csv(file, length=None, mode="strict", charset=None):
file.seek(0)
data = file.read(length)
if not charset:
try:
import chardet
charset = chardet.detect(data)['encoding']
except ImportError:
charset = file.charset
data = data.decode(charset or "utf-8", mode)
# If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n')
def _validate(cf: CachedFile, charset: str, cols: List[ImportColumn], settings: dict):
try:
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
except csv.Error:
return None
if dialect is None:
return None
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
return reader
def setif(record, obj, attr, setting):
if setting.startswith('csv:'):
setattr(obj, attr, record[setting[4:]] or '')
parsed = parse_csv(cf.file, charset=charset)
except UnicodeDecodeError as e:
raise DataImportError(
_(
'Error decoding special characters in your file: {message}').format(
message=str(e)
)
)
data = []
for i, record in enumerate(parsed):
if not any(record.values()):
continue
values = {}
for c in cols:
val = c.resolve(settings, record)
if isinstance(val, str):
val = val.strip()
try:
values[c.identifier] = c.clean(val, values)
except ValidationError as e:
raise DataImportError(
_(
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
)
)
data.append(values)
return data
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
@@ -91,45 +81,17 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
cf = CachedFile.objects.get(id=fileid)
user = User.objects.get(pk=user)
with language(locale, event.settings.region):
cols = get_all_columns(event)
try:
parsed = parse_csv(cf.file, charset=charset)
except UnicodeDecodeError as e:
raise DataImportError(
_(
'Error decoding special characters in your file: {message}').format(
message=str(e)
)
)
orders = []
order = None
data = []
# Run validation
for i, record in enumerate(parsed):
if not any(record.values()):
continue
values = {}
for c in cols:
val = c.resolve(settings, record)
if isinstance(val, str):
val = val.strip()
try:
values[c.identifier] = c.clean(val, values)
except ValidationError as e:
raise DataImportError(
_(
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
)
)
data.append(values)
cols = get_order_import_columns(event)
data = _validate(cf, charset, cols, settings)
if settings['orders'] == 'one' and len(data) > django_settings.PRETIX_MAX_ORDER_SIZE:
raise DataImportError(
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
)
orders = []
order = None
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality…
lock_seats = []
@@ -149,16 +111,16 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
position = OrderPosition(positionid=len(order._positions) + 1)
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
position.meta_info = {}
if position.seat is not None:
lock_seats.append(position.seat)
order._positions.append(position)
position.assign_pseudonymization_id()
for c in cols:
c.assign(record.get(c.identifier), order, position, order._address)
except ImportError as e:
raise ImportError(
if position.seat is not None:
lock_seats.append(position.seat)
except (ValidationError, ImportError) as e:
raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
)
@@ -169,7 +131,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
lock_objects(lock_seats, shared_lock_objects=[event])
for s in lock_seats:
if not s.is_available():
raise ImportError(_('The seat you selected has already been taken. Please select a different seat.'))
raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.'))
save_transactions = []
for o in orders:
@@ -232,3 +194,62 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
raise ValidationError(_('We were not able to process your request completely as the server was too busy. '
'Please try again.'))
cf.delete()
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user, charset=None) -> None:
cf = CachedFile.objects.get(id=fileid)
user = User.objects.get(pk=user)
with language(locale, event.settings.region):
cols = get_voucher_import_columns(event)
data = _validate(cf, charset, cols, settings)
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality…
vouchers = []
lock_seats = []
for i, record in enumerate(data):
try:
voucher = Voucher(event=event)
vouchers.append(voucher)
Voucher.clean_item_properties(
record,
event,
record.get('quota'),
record.get('item'),
record.get('variation'),
block_quota=record.get('block_quota')
)
Voucher.clean_subevent(record, event)
Voucher.clean_max_usages(record, 0)
for c in cols:
c.assign(record.get(c.identifier), voucher)
if voucher.seat is not None:
lock_seats.append(voucher.seat)
except (ValidationError, ImportError) as e:
raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
)
with transaction.atomic():
# We don't support quotas here, so we only need to lock if seats are in use
if lock_seats:
lock_objects(lock_seats, shared_lock_objects=[event])
for s in lock_seats:
if not s.is_available():
raise DataImportError(
_('The seat you selected has already been taken. Please select a different seat.'))
for v in vouchers:
v.save()
v.log_action(
'pretix.voucher.added',
user=user,
data={'source': 'import'}
)
for c in cols:
c.save(v)
cf.delete()

View File

@@ -197,7 +197,7 @@ error_messages = {
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
'min'
),
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'),
}
@@ -220,7 +220,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
is_available = order._is_still_available(now(), count_waitinglist=False, check_voucher_usage=True,
check_memberships=True, lock=True, force=force)
if is_available is True:
if order.payment_refund_sum >= order.total:
if order.payment_refund_sum >= order.total and not order.require_approval:
order.status = Order.STATUS_PAID
else:
order.status = Order.STATUS_PENDING
@@ -412,6 +412,11 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
email_subject, email_template, email_context,
'pretix.event.order.email.order_approved', user,
attach_tickets=True,
attach_ical=order.event.settings.mail_attach_ical and (
not order.event.settings.mail_attach_ical_paid_only or
order.total == Decimal('0.00') or
order.valid_if_pending
),
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
)
except SendMailException:
@@ -665,7 +670,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
deleted_positions.add(cp.pk)
cp.delete()
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
for cp in sorted_positions:
cp._cached_quotas = list(cp.quotas)
@@ -876,6 +881,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
# After applying discounts, add-on positions might still have a reference to the *old* version of the
# parent position, which can screw up ordering later since the system sees inconsistent data.
by_id = {cp.pk: cp for cp in sorted_positions}
for cp in sorted_positions:
if cp.addon_to_id:
cp.addon_to = by_id[cp.addon_to_id]
new_total = sum(cp.price for cp in sorted_positions)
if old_total != new_total:
err = err or error_messages['price_changed']
@@ -884,7 +896,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
for cp in sorted_positions:
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
cp.save(update_fields=['expires'])
if err:
raise OrderError(err)
@@ -1045,7 +1057,11 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
@@ -1064,7 +1080,11 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
log_entry,
invoices=[],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
@@ -1144,7 +1164,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
positions = list(
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
)
positions.sort(key=lambda k: position_ids.index(k.pk))
positions.sort(key=lambda c: c.sort_key)
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
@@ -2500,7 +2520,7 @@ class OrderChangeManager:
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
if offset_amount >= split_order.total:
if offset_amount >= split_order.total and not split_order.require_approval:
split_order.status = Order.STATUS_PAID
else:
split_order.status = Order.STATUS_PENDING
@@ -2671,6 +2691,7 @@ class OrderChangeManager:
for p in self.order.positions.all():
cp = CartPosition(
event=self.event,
item=p.item,
variation=p.variation,
attendee_name_parts=p.attendee_name_parts,
@@ -2691,16 +2712,23 @@ class OrderChangeManager:
positions_to_fake_cart[op.position].seat = op.seat
elif isinstance(op, self.MembershipOperation):
positions_to_fake_cart[op.position].used_membership = op.membership
elif isinstance(op, self.ChangeValidFromOperation):
positions_to_fake_cart[op.position].override_valid_from = op.valid_from
elif isinstance(op, self.ChangeValidUntilOperation):
positions_to_fake_cart[op.position].override_valid_until = op.valid_until
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation):
cp = CartPosition(
event=self.event,
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
cp.override_valid_from = op.valid_from
cp.override_valid_until = op.valid_until
fake_cart.append(cp)
try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)

View File

@@ -514,7 +514,7 @@ def base_placeholders(sender, **kwargs):
ph.append(SimpleFunctionalTextPlaceholder(
"name_for_salutation", ["waiting_list_entry"],
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
_("Mr Doe"),
lambda event: concatenation_for_salutation(name_scheme['sample']),
))
ph.append(SimpleFunctionalTextPlaceholder(
"name", ["waiting_list_entry"],
@@ -524,7 +524,7 @@ def base_placeholders(sender, **kwargs):
ph.append(SimpleFunctionalTextPlaceholder(
"name_for_salutation", ["position_or_address"],
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
_("Mr Doe"),
lambda event: concatenation_for_salutation(name_scheme['sample']),
))
for f, l, w in name_scheme['fields']:

View File

@@ -446,6 +446,11 @@ class QuotaAvailability:
self.results[q] = Quota.AVAILABILITY_RESERVED, 0
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
quotas = [
q for q in quotas
if not q.event.settings.waiting_list_auto_disable or q.event.settings.waiting_list_auto_disable.datetime(q.subevent or q.event) > now()
]
events = {q.event_id for q in quotas}
subevents = {q.subevent_id for q in quotas}
quota_ids = {q.pk for q in quotas}

View File

@@ -110,6 +110,9 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
continue
if wle.subevent and not wle.subevent.presale_is_running:
continue
if event.settings.waiting_list_auto_disable and event.settings.waiting_list_auto_disable.datetime(wle.subevent or event) <= now():
gone.add((wle.item, wle.variation, wle.subevent))
continue
if not wle.item.is_available():
gone.add((wle.item, wle.variation, wle.subevent))
continue

View File

@@ -89,7 +89,7 @@ def primary_font_kwargs():
choices = [('Open Sans', 'Open Sans')]
choices += sorted([
(a, {"title": a, "data": v}) for a, v in get_fonts().items() if not v.get('pdf_only', False)
(a, {"title": a, "data": v}) for a, v in get_fonts(pdf_support_required=False).items()
], key=lambda a: a[0])
return {
'choices': choices,
@@ -1397,6 +1397,19 @@ DEFAULTS = {
widget=forms.NumberInput(),
)
},
'waiting_list_auto_disable': {
'default': None,
'type': RelativeDateWrapper,
'form_class': RelativeDateTimeField,
'serializer_class': SerializerRelativeDateTimeField,
'form_kwargs': dict(
label=_("Disable waiting list"),
help_text=_("The waiting list will be fully disabled after this date. This means that nobody can add "
"themselves to the waiting list any more, but also that tickets will be available for sale "
"again if quota permits, even if there are still people on the waiting list. Vouchers that "
"have already been sent remain active."),
)
},
'waiting_list_names_asked': {
'default': 'False',
'type': bool,

View File

@@ -785,6 +785,15 @@ to define additional columns that can be read during import. You are expected to
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
voucher_import_columns = EventPluginSignal()
"""
This signal is sent out if the user performs an import of vouchers from an external source. You can use this
to define additional columns that can be read during import. You are expected to return a list of instances of
``ImportColumn`` subclasses.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
validate_event_settings = EventPluginSignal()
"""
Arguments: ``settings_dict``

View File

@@ -159,6 +159,18 @@ def timeline_for_event(event, subevent=None):
})
))
rd = event.settings.get('waiting_list_auto_disable', as_type=RelativeDateWrapper)
if rd and event.settings.waiting_list_enabled:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Waiting list is disabled'),
edit_url=reverse('control:event.settings', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
}) + '#waiting-list-open'
))
if not event.has_subevents:
days = event.settings.get('mail_days_download_reminder', as_type=int)
if days is not None and event.settings.ticket_download:

View File

@@ -79,6 +79,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.plugins.banktransfer.payment import BankTransfer
from pretix.presale.style import get_fonts
class EventWizardFoundationForm(forms.Form):
@@ -538,6 +539,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'region',
'show_quota_left',
'waiting_list_enabled',
'waiting_list_auto_disable',
'waiting_list_hours',
'waiting_list_auto',
'waiting_list_names_asked',
@@ -651,6 +653,9 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
del self.fields['event_list_available_only']
del self.fields['event_list_filters']
del self.fields['event_calendar_future_only']
self.fields['primary_font'].choices += [
(a, {"title": a, "data": v}) for a, v in get_fonts(self.event, pdf_support_required=False).items()
]
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
self.virtual_keys = []
@@ -931,6 +936,9 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
)
)
self.fields['invoice_generate'].choices = generate_choices
self.fields['invoice_renderer_font'].choices += [
(a, a) for a in get_fonts(event, pdf_support_required=True).keys()
]
def contains_web_channel_validate(val):

View File

@@ -309,11 +309,8 @@ class OrderFilterForm(FilterForm):
elif s in ('p', 'n', 'e', 'c', 'r'):
qs = qs.filter(status=s)
elif s == 'overpaid':
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
qs = qs.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))
)
qs = Order.annotate_overpayments(qs, refunds=False, results=True, sums=False)
qs = qs.filter(is_overpaid=True)
elif s == 'rc':
qs = qs.filter(
cancellation_requests__isnull=False

View File

@@ -22,10 +22,54 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from pretix.base.services.orderimport import get_all_columns
from pretix.base.modelimport_orders import get_order_import_columns
from pretix.base.modelimport_vouchers import get_voucher_import_columns
class ProcessForm(forms.Form):
def __init__(self, *args, **kwargs):
headers = kwargs.pop('headers')
initital = kwargs.pop('initial', {}) or {}
kwargs['initial'] = initital
columns = self.get_columns()
column_keys = {c.identifier for c in columns}
if not initital or all(k not in column_keys for k in initital.keys()):
for c in columns:
initital.setdefault(c.identifier, c.initial)
for h in headers:
if h == c.identifier or h == str(c.verbose_name):
initital[c.identifier] = 'csv:{}'.format(h)
break
super().__init__(*args, **kwargs)
header_choices = [
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
]
for c in columns:
choices = []
if c.default_value:
choices.append((c.default_value, c.default_label))
choices += header_choices
for k, v in c.static_choices():
choices.append(('static:{}'.format(k), v))
self.fields[c.identifier] = forms.ChoiceField(
label=str(c.verbose_name),
choices=choices,
widget=forms.Select(
attrs={'data-static': 'true'}
)
)
def get_columns(self):
raise NotImplementedError() # noqa
class OrdersProcessForm(ProcessForm):
orders = forms.ChoiceField(
label=_('Import mode'),
choices=(
@@ -46,29 +90,21 @@ class ProcessForm(forms.Form):
)
def __init__(self, *args, **kwargs):
headers = kwargs.pop('headers')
initital = kwargs.pop('initial', {})
self.event = kwargs.pop('event')
initital = kwargs.pop('initial', {})
initital['testmode'] = self.event.testmode
kwargs['initial'] = initital
super().__init__(*args, **kwargs)
header_choices = [
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
]
def get_columns(self):
return get_order_import_columns(self.event)
for c in get_all_columns(self.event):
choices = []
if c.default_value:
choices.append((c.default_value, c.default_label))
choices += header_choices
for k, v in c.static_choices():
choices.append(('static:{}'.format(k), v))
self.fields[c.identifier] = forms.ChoiceField(
label=str(c.verbose_name),
choices=choices,
widget=forms.Select(
attrs={'data-static': 'true'}
)
)
class VouchersProcessForm(ProcessForm):
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
def get_columns(self):
return get_voucher_import_columns(self.event)

View File

@@ -257,7 +257,7 @@ class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
fields = ['name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards', 'can_manage_customers',
'can_manage_reusable_media',

View File

@@ -455,9 +455,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.control.auth.user.created': _('The user has been created.'),
'pretix.control.auth.user.new_source': _('A first login using {agent_type} on {os_type} from {country} has '
'been detected.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '

View File

@@ -48,7 +48,8 @@ from pretix.base.models import Event, Organizer
from pretix.base.models.auth import SuperuserPermissionSet, User
from pretix.helpers.http import redirect_to_url
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
SessionReauthRequired, assert_session_valid,
)
@@ -67,6 +68,7 @@ class PermissionMiddleware:
"auth.forgot.recover",
"auth.invite",
"user.settings.notifications.off",
"auth.bad_origin_report",
)
EXCEPTIONS_FORCED_PW_CHANGE = (
@@ -83,6 +85,7 @@ class PermissionMiddleware:
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"user.settings.2fa.delete",
"user.settings.2fa.leaveteams",
"auth.logout",
"user.reauth"
)
@@ -134,13 +137,12 @@ class PermissionMiddleware:
except SessionReauthRequired:
if url_name not in ('user.reauth', 'auth.logout'):
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
and url_name not in self.EXCEPTIONS_2FA and not request.user.needs_password_change:
return redirect_to_url(reverse('control:user.settings.2fa'))
except SessionPasswordChangeRequired:
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
except Session2FASetupRequired:
if url_name not in self.EXCEPTIONS_2FA:
return redirect_to_url(reverse('control:user.settings.2fa'))
if 'event' in url.kwargs and 'organizer' in url.kwargs:
if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-':

View File

@@ -58,5 +58,6 @@
{{ poweredby }} {# removing or hiding this might be in violation of pretix' license #}
</footer>
</div>
<script type="text/javascript" src="{% static "pretixcontrol/js/auth.js" %}"></script>
</body>
</html>

View File

@@ -46,5 +46,7 @@
{% endif %}
</div>
</form>
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% load i18n %}{% blocktrans with url=url|safe os=source.os_type agent=source.agent_type %}Hello,
a login to your {{ instance }} account from an unusual or new location was detected. The login was performed using {{ agent }} on {{ os }} from {{ country }}.
If this was you, you can safely ignore this email.
If this was not you, we recommend that you change your password in your account settings:
{{ url }}
Best regards,
Your {{ instance }} team
{% endblocktrans %}

View File

@@ -7,7 +7,7 @@
{% block title %}{% trans "General settings" %}{% endblock %}
{% block custom_header %}
{{ block.super }}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" organizer=request.organizer.slug event=request.event.slug %}">
{% endblock %}
{% block inside %}
<h1>{% trans "General settings" %}</h1>
@@ -335,7 +335,7 @@
{% bootstrap_field sform.max_items_per_order layout="control" %}
{% bootstrap_field sform.redirect_to_checkout_directly layout="control" %}
</fieldset>
<fieldset>
<fieldset id="waiting-list">
<legend>{% trans "Waiting list" %}</legend>
<div class="alert alert-info">
{% blocktrans trimmed %}
@@ -361,6 +361,7 @@
{% 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_auto_disable 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" %}

View File

@@ -267,6 +267,7 @@
{% bootstrap_field form.show_quota_left layout="control" %}
{% for f in plugin_forms %}
{% if not f.is_layouts and not f.title %}
<hr />
{% if f.template and not "template" in f.fields %}
{% include f.template with form=f %}
{% else %}

View File

@@ -124,6 +124,13 @@
</button>
</div>
</form>
{% elif order.status == "e" and order.payment_refund_sum != 0 %}
<div class="alert alert-warning">
{% blocktrans trimmed with amount=order.payment_refund_sum|money:request.event.currency %}
This order is expired even though it received payments of {{ amount }}. You can choose to refund
the money below or reactivate it by extending the payment deadline.
{% endblocktrans %}
</div>
{% endif %}
<div class="row">

View File

@@ -18,6 +18,7 @@
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.require_2fa layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Organizer permissions" %}</legend>

View File

@@ -8,7 +8,7 @@
{% compress css %}
<link type="text/css" rel="stylesheet" href="{% static "pretixcontrol/scss/pdfeditor.css" %}">
{% endcompress %}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" organizer=request.organizer.slug event=request.event.slug %}">
{% endblock %}
{% block content %}
<h1>
@@ -183,6 +183,7 @@
</div>
{% endif %}
<div class="row control-group pdf-info">
<hr/>
<div class="col-sm-6">
<label>{% trans "Width (mm)" %}</label><br>
<input type="number" id="pdf-info-width" class="input-block-level form-control">
@@ -224,6 +225,7 @@
</div>
</div>
<div class="row control-group pdf-info">
<hr/>
<div class="col-sm-12">
<label>{% trans "Preferred language" %}</label><br>
<select class="form-control" id="pdf-info-locale">

View File

@@ -1,4 +1,5 @@
{% load static %}
@font-face {
font-family: 'AND';
font-style: normal;
@@ -14,7 +15,7 @@
{% for family, styles in fonts.items %}
{% for style, formats in styles.items %}
{% if "sample" not in style %}
{% if "sample" not in style and "pdf_only" not in style %}
@font-face {
font-family: '{{ family }}';
{% if style == "italic" or style == "bolditalic" %}
@@ -27,9 +28,9 @@
{% else %}
font-weight: normal;
{% endif %}
src: {% if "woff2" in formats %}url('{% static formats.woff2 %}') format('woff2'),{% endif %}
{% if "woff" in formats %}url('{% static formats.woff %}') format('woff'),{% endif %}
{% if "truetype" in formats %}url('{% static formats.truetype %}') format('truetype'){% endif %};
src: {% if "woff2" in formats %}{% if '//' in formats.woff2 %}url('{{ formats.woff2 }}'){% else %}url('{% static formats.woff2 %}'){% endif %} format('woff2'),{% endif %}
{% if "woff" in formats %}{% if '//' in formats.woff %}url('{{ formats.woff }}'){% else %}url('{% static formats.woff %}'){% endif %} format('woff'),{% endif %}
{% if "truetype" in formats %}{% if '//' in formats.truetype %}url('{{ formats.truetype }}'){% else %}url('{% static formats.truetype %}'){% endif %} format('truetype'){% endif %};
}
.preload-font[data-family="{{family}}"][data-style="{{style}}"] {
font-family: '{{ family }}', 'AND';

View File

@@ -0,0 +1,30 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Two-factor authentication" %}{% endblock %}
{% block content %}
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>
<strong>{% trans "Do you really want to leave the following teams?" %}</strong>
</p>
<ul>
{% for t in obligatory_teams %}
<li>
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
Team "{{ team }}" of organizer "{{ organizer }}"
{% endblocktrans %}
</li>
{% endfor %}
</ul>
<div class="form-group submit-group">
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Leave" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -11,23 +11,55 @@
smartphone or a hardware token generator and that changes on a regular basis.
{% endblocktrans %}
</p>
{% if settings.PRETIX_OBLIGATORY_2FA %}
{% if obligatory and not user.require_2fa %}
<div class="panel panel-warning">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Obligatory usage of two-factor authentication" %}</h3>
<h3 class="panel-title">
<span class="fa fa-warning"></span>
{% trans "Obligatory usage of two-factor authentication" %}
</h3>
</div>
<div class="panel-body">
{% if obligatory == "system" %}
<p>
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
</p>
{% elif obligatory == "staff" %}
<p>
<strong>{% trans "As an administrator, you need to use two-factor authentication." %}</strong>
</p>
{% elif obligatory == "team" %}
<p>
<strong>{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}</strong>
</p>
<ul>
{% for t in obligatory_teams %}
<li>
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
Team "{{ team }}" of organizer "{{ organizer }}"
{% endblocktrans %}
</li>
{% endfor %}
</ul>
{% endif %}
<p>
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
{% if not devices %}
{% trans "Please set up at least one device below." %}
{% elif not user.require_2fa %}
{% trans "Please activate two-factor authentication using the button below." %}
{% endif %}
{% if obligatory == "team" %}
<a href="{% url "control:user.settings.2fa.leaveteams" %}">
{% blocktrans trimmed count count=obligatory_teams|length %}
Leave team instead
{% plural %}
Leave {{ count }} teams instead
{% endblocktrans %}
</a>
{% endif %}
</p>
{% if not devices %}
<p>{% trans "Please set up at least one device below." %}</p>
{% elif not user.require_2fa %}
<p>{% trans "Please activate two-factor authentication using the button below." %}</p>
{% endif %}
</div>
</div>
{% endif %}
{% if user.require_2fa %}
<div class="panel panel-success">
@@ -35,7 +67,18 @@
<h3 class="panel-title">{% trans "Two-factor status" %}</h3>
</div>
<div class="panel-body">
{% if not settings.PRETIX_OBLIGATORY_2FA %}
{% if obligatory %}
<button disabled class="btn btn-primary pull-right flip" data-toggle="tooltip"
title="{% spaceless %}{% if obligatory == "system" %}
{% trans "This system enforces the usage of two-factor authentication!" %}
{% elif obligatory == "staff" %}
{% trans "As an administrator, you need to use two-factor authentication." %}
{% elif obligatory == "team" %}
{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}
{% endif %}{% endspaceless %}">
{% trans "Disable" %}
</button>
{% else %}
<a href="{% url "control:user.settings.2fa.disable" %}" class="btn btn-primary pull-right flip">
{% trans "Disable" %}
</a>
@@ -73,7 +116,7 @@
{% for d in devices %}
<li class="list-group-item">
<a class="btn btn-danger btn-xs pull-right flip"
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
Delete
</a>
{% if d.devicetype == "totp" %}

View File

@@ -11,6 +11,12 @@
<button class="btn btn-default">{% trans "Send password reset email" %}</button>
</form>
{% endif %}
{% if user.require_2fa %}
<form action="{% url "control:users.emergencytoken" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Generate 2FA emergency token" %}</button>
</form>
{% endif %}
<form action="{% url "control:users.impersonate" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Impersonate user" %}</button>

View File

@@ -0,0 +1,61 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% load getitem %}
{% load bootstrap3 %}
{% block title %}{% trans "Import vouchers" %}{% endblock %}
{% block content %}
<h1>{% trans "Import vouchers" %}</h1>
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-long>
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Data preview" %}</h3>
</div>
<div class="table-responsive panel-body">
<table class="table table-condensed">
<thead>
<tr>
{% for fn in parsed.fieldnames %}
<th>{{ fn }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for r in sample_rows %}
<tr>
{% for fn in parsed.fieldnames %}
<td>{{ r|getitem:fn }}</td>
{% endfor %}
</tr>
{% endfor %}
<tr>
<td class="text-center" colspan="{{ parsed.fieldnames|length }}">
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Import settings" %}</h3>
</div>
<div class="panel-body">
{% bootstrap_form_errors form %}
{% bootstrap_form form layout="horizontal" %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
The import will be performed regardless of your quotas, so it will be possible to overbook your event using this option.
{% endblocktrans %}
</div>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Perform import" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Import vouchers" %}{% endblock %}
{% block content %}
<h1>{% trans "Import vouchers" %}</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
</div>
<div class="panel-body">
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{% blocktrans trimmed %}
The uploaded file should be a CSV file with a header row. You will be able to assign the
meanings of the different columns in the next step.
{% endblocktrans %}
</p>
<div class="form-group">
<label for="file">{% trans "Import file" %}: </label> <input id="file" type="file" name="file"/>
</div>
<div class="form-group">
<label for="file">{% trans "Character set" %}: </label>
<select name="charset" class="form-control">
<option>{% trans "Detect automatically" %}</option>
{% for e in encodings %}
<option value="{{ e }}">{{ e }}</option>
{% endfor %}
</select>
</div>
<div class="clearfix"></div>
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start import" %}
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -77,6 +77,8 @@
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create multiple new vouchers" %}</a>
<a href="{% url "control:event.vouchers.import" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-lg"><i class="fa fa-upload"></i> {% trans "Import vouchers" %}</a>
{% endif %}
</div>
{% else %}
@@ -87,6 +89,9 @@
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i>
{% trans "Create multiple new vouchers" %}</a>
<a href="{% url "control:event.vouchers.import" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-upload"></i>
{% trans "Import vouchers" %}</a>
{% endif %}
<a href="?{% url_replace request "download" "yes" %}"
class="btn btn-default"><i class="fa fa-download"></i>

View File

@@ -10,6 +10,10 @@
<div class="alert alert-danger">
{% trans "The waiting list is disabled, so if the event is sold out, people cannot add themselves to this list. If you want to enable it, go to the event settings." %}
</div>
{% elif not request.event.waiting_list_active and not request.event.has_subevents %}
<div class="alert alert-danger">
{% trans "The waiting list is no longer active for this event. The waiting list no longer affects quotas and no longer notifies waiting users." %}
</div>
{% endif %}
<div class="row">
{% if 'can_change_orders' in request.eventpermset %}

View File

@@ -38,13 +38,14 @@ from django.views.generic.base import RedirectView
from pretix.control.views import (
auth, checkin, dashboards, discounts, event, geo, global_settings, item,
main, oauth, orderimport, orders, organizer, pdf, search, shredder,
main, modelimport, oauth, orders, organizer, pdf, search, shredder,
subevents, typeahead, user, users, vouchers, waitinglist,
)
urlpatterns = [
re_path(r'^logout$', auth.logout, name='auth.logout'),
re_path(r'^login$', auth.login, name='auth.login'),
re_path(r'^login/bad_origin$', auth.bad_origin_report, name='auth.bad_origin_report'),
re_path(r'^login/2fa$', auth.Login2FAView.as_view(), name='auth.login.2fa'),
re_path(r'^register$', auth.register, name='auth.register'),
re_path(r'^invite/(?P<token>[a-zA-Z0-9]+)$', auth.invite, name='auth.invite'),
@@ -71,8 +72,9 @@ urlpatterns = [
re_path(r'^users/impersonate/stop', users.UserImpersonateStopView.as_view(), name='users.impersonate.stop'),
re_path(r'^users/(?P<id>\d+)/$', users.UserEditView.as_view(), name='users.edit'),
re_path(r'^users/(?P<id>\d+)/reset$', users.UserResetView.as_view(), name='users.reset'),
re_path(r'^users/(?P<id>\d+)/impersonate', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
@@ -94,6 +96,7 @@ urlpatterns = [
re_path(r'^settings/oauth/apps/(?P<pk>\d+)/roll$', oauth.OAuthApplicationRollView.as_view(),
name='user.settings.oauth.app.roll'),
re_path(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
re_path(r'^settings/2fa/leaveteams$', user.User2FALeaveTeamsView.as_view(), name='user.settings.2fa.leaveteams'),
re_path(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
re_path(r'^settings/2fa/enable', user.User2FAEnableView.as_view(), name='user.settings.2fa.enable'),
re_path(r'^settings/2fa/disable', user.User2FADisableView.as_view(), name='user.settings.2fa.disable'),
@@ -346,6 +349,8 @@ urlpatterns = [
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'),
re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
re_path(r'^vouchers/import/$', modelimport.VoucherImportView.as_view(), name='event.vouchers.import'),
re_path(r'^vouchers/import/(?P<file>[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
name='event.order.transition'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/resend$', orders.OrderResendLink.as_view(),
@@ -412,8 +417,8 @@ urlpatterns = [
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
name='event.invoice.download'),
re_path(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'),
re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'),
re_path(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'),
re_path(r'^orders/import/$', modelimport.OrderImportView.as_view(), name='event.orders.import'),
re_path(r'^orders/import/(?P<file>[^/]+)/$', modelimport.OrderProcessView.as_view(), name='event.orders.import.process'),
re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
re_path(r'^orders/export/(?P<pk>[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'),
re_path(r'^orders/export/(?P<pk>[^/]+)/delete$', orders.DeleteScheduledExportView.as_view(), name='event.orders.export.scheduled.delete'),

View File

@@ -36,7 +36,7 @@ import base64
import json
import logging
import time
from urllib.parse import quote, urlparse
from urllib.parse import quote, urljoin, urlparse
import webauthn
from django.conf import settings
@@ -47,11 +47,14 @@ from django.contrib.auth import (
from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from webauthn.helpers import generate_challenge
@@ -60,9 +63,11 @@ from pretix.base.auth import get_auth_backends
from pretix.base.forms.auth import (
LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm,
)
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.base.services.mail import SendMailException
from pretix.helpers.http import redirect_to_url
from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.security import handle_login_source
logger = logging.getLogger(__name__)
@@ -77,6 +82,7 @@ def process_login(request, user, keep_logged_in):
request.session['pretix_auth_long_session'] = settings.PRETIX_LONG_SESSIONS and keep_logged_in
next_url = get_auth_backends()[user.auth_backend].get_next_url(request)
if user.require_2fa:
logger.info(f"Backend login redirected to 2FA for user {user.pk}.")
request.session['pretix_auth_2fa_user'] = user.pk
request.session['pretix_auth_2fa_time'] = str(int(time.time()))
twofa_url = reverse('control:auth.login.2fa')
@@ -84,6 +90,9 @@ def process_login(request, user, keep_logged_in):
twofa_url += '?next=' + quote(next_url)
return redirect_to_url(twofa_url)
else:
logger.info(f"Backend login successful for user {user.pk}.")
pretix_successful_logins.inc(1)
handle_login_source(user, request)
auth_login(request, user)
request.session['pretix_auth_login_time'] = int(time.time())
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
@@ -105,6 +114,9 @@ def login(request):
return process_login(request, u, False)
b.url = b.authentication_url(request)
# Login should only happen on configured main domain
good_origin = urlparse(settings.SITE_URL).scheme + '://' + urlparse(settings.SITE_URL).hostname
backend = backenddict.get(request.GET.get('backend', 'native'), backends[0])
if not backend.visible:
backend = [b for b in backends if b.visible][0]
@@ -115,7 +127,23 @@ def login(request):
return redirect(reverse('control:index'))
if request.method == 'POST':
form = LoginForm(backend=backend, data=request.POST, request=request)
if form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier:
is_valid = form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier
if form.cleaned_data.get("origin"):
form_origin = form.cleaned_data.get("origin")
if good_origin != form_origin:
logger.warning(
f"Received login form submission with unexpected origin value. "
f"Origin sent from JavaScript: {form_origin} / "
f"Expected origin from configuration: {good_origin} / "
f"HTTP Host header: {request.headers.get('Host')} / "
f"HTTP origin header: {request.headers.get('Origin')} / "
f"HTTP referer header: {request.headers.get('Referer')} / "
f"IP address: {get_client_ip(request)} / "
f"Login result: {is_valid}"
)
if is_valid:
return process_login(request, form.user_cache, form.cleaned_data.get('keep_logged_in', False))
else:
form = LoginForm(backend=backend, request=request)
@@ -124,9 +152,35 @@ def login(request):
ctx['can_reset'] = settings.PRETIX_PASSWORD_RESET
ctx['backends'] = backends
ctx['backend'] = backend
ctx['good_origin'] = good_origin[::-1] # minimal obfuscation against standard link rewriting
ctx['bad_origin_report_url'] = urljoin(
# as an additional safeguard always use SITE_URL, not anything derived from request
settings.SITE_URL,
reverse('control:auth.bad_origin_report')
)[::-1]
return render(request, 'pretixcontrol/auth/login.html', ctx)
@csrf_exempt
@require_http_methods(["POST"])
def bad_origin_report(request):
good_origin = urlparse(settings.SITE_URL).scheme + '://' + urlparse(settings.SITE_URL).hostname
form_origin = request.POST.get("origin")
if good_origin != form_origin:
logger.warning(
f"Received report of unexpected origin value. "
f"Origin sent from JavaScript: {form_origin} / "
f"Expected origin from configuration: {good_origin} / "
f"HTTP Host header: {request.headers.get('Host')} / "
f"HTTP origin header: {request.headers.get('Origin')} / "
f"HTTP referer header: {request.headers.get('Referer')} / "
f"IP address: {get_client_ip(request)}"
)
resp = HttpResponse()
resp['Access-Control-Allow-Origin'] = '*'
return resp
def logout(request):
"""
Log the user out of the current session, then redirect to login page.
@@ -284,7 +338,7 @@ class Forgot(TemplateView):
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
except User.DoesNotExist:
logger.warning('Password reset for unregistered e-mail \"' + email + '\" requested.')
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
except SendMailException:
logger.exception('Sending password reset e-mail to \"' + email + '\" failed.')
@@ -411,6 +465,7 @@ class Login2FAView(TemplateView):
fail = True
logintime = int(request.session.get('pretix_auth_2fa_time', '1'))
if time.time() - logintime > 300:
pretix_failed_logins.inc(1, reason="2fa-timeout")
fail = True
if fail:
messages.error(request, _('Please try again.'))
@@ -443,6 +498,7 @@ class Login2FAView(TemplateView):
)
sign_count = webauthn_assertion_response.new_sign_count
if sign_count < credential_current_sign_count:
pretix_failed_logins.inc(1, reason="webauthn-replay")
raise Exception("Possible replay attack, sign count not higher")
except Exception:
if isinstance(d, U2FDevice):
@@ -460,11 +516,13 @@ class Login2FAView(TemplateView):
if webauthn_assertion_response.new_sign_count < 1:
raise Exception("Possible replay attack, sign count set")
except Exception:
pretix_failed_logins.inc(1, reason="u2f")
logger.exception('U2F login failed')
else:
valid = True
break
else:
pretix_failed_logins.inc(1, reason="webauthn")
logger.exception('Webauthn login failed')
else:
if isinstance(d, WebAuthnDevice):
@@ -476,6 +534,9 @@ class Login2FAView(TemplateView):
valid = match_token(self.user, token)
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
pretix_successful_logins.inc(1)
handle_login_source(self.user, request)
auth_login(request, self.user)
request.session['pretix_auth_login_time'] = int(time.time())
del request.session['pretix_auth_2fa_user']
@@ -484,6 +545,7 @@ class Login2FAView(TemplateView):
return redirect_to_url(request.GET.get("next"))
return redirect('control:index')
else:
pretix_failed_logins.inc(1, reason="2fa")
messages.error(request, _('Invalid code, please try again.'))
return redirect('control:auth.login.2fa')

View File

@@ -46,9 +46,13 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView, TemplateView
from pretix.base.models import CachedFile
from pretix.base.services.orderimport import import_orders, parse_csv
from pretix.base.services.modelimport import (
import_orders, import_vouchers, parse_csv,
)
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.orderimport import ProcessForm
from pretix.control.forms.modelimport import (
OrdersProcessForm, VouchersProcessForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.http import redirect_to_url
@@ -64,28 +68,16 @@ ENCODINGS = (
)
class ImportView(EventPermissionRequiredMixin, TemplateView):
template_name = 'pretixcontrol/orders/import_start.html'
permission = 'can_change_orders'
class BaseImportView(TemplateView):
def post(self, request, *args, **kwargs):
if 'file' not in request.FILES:
return redirect_to_url(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect_to_url(request.path)
if not request.FILES['file'].name.lower().endswith('.csv'):
messages.error(request, _('Please only upload CSV files.'))
return redirect_to_url(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect_to_url(request.path)
if request.FILES['file'].size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
messages.error(request, _('Please do not upload files larger than 10 MB.'))
return redirect_to_url(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect_to_url(request.path)
cf = CachedFile.objects.create(
expires=now() + timedelta(days=1),
@@ -100,41 +92,47 @@ class ImportView(EventPermissionRequiredMixin, TemplateView):
else:
charset = "auto"
return redirect(reverse('control:event.orders.import.process', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'file': cf.id
}) + "?charset=" + charset)
return redirect(self.get_process_url(request, cf, charset))
def get_context_data(self, **kwargs):
return super().get_context_data(encodings=ENCODINGS)
def get_process_url(self, request, cf, charset):
raise NotImplementedError() # noqa
class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
permission = 'can_change_orders'
template_name = 'pretixcontrol/orders/import_process.html'
form_class = ProcessForm
task = import_orders
class BaseProcessView(AsyncAction, FormView):
known_errortypes = ['DataImportError']
@property
def settings_key(self):
raise NotImplementedError() # noqa
@property
def settings_holder(self):
raise NotImplementedError() # noqa
def get_form_kwargs(self):
k = super().get_form_kwargs()
k.update({
'event': self.request.event,
'initial': self.request.event.settings.order_import_settings,
'initial': self.settings_holder.settings.get(self.settings_key, as_type=dict),
'headers': self.parsed.fieldnames
})
return k
def form_valid(self, form):
self.request.event.settings.order_import_settings = form.cleaned_data
self.settings_holder.settings.set(self.settings_key, form.cleaned_data)
if self.request.GET.get("charset") in ENCODINGS:
charset = self.request.GET.get("charset")
else:
charset = None
return self.do(
self.request.event.pk, self.file.id, form.cleaned_data, self.request.LANGUAGE_CODE,
self.request.user.pk, charset
self.settings_holder.pk,
self.file.id,
form.cleaned_data,
self.request.LANGUAGE_CODE,
self.request.user.pk,
charset,
)
@cached_property
@@ -176,28 +174,21 @@ class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
return _('The import was successful.')
def get_success_url(self, value):
return reverse('control:event.orders', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
raise NotImplementedError() # noqa
def get_form_url(self):
raise NotImplementedError() # noqa
def dispatch(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
if not self.parsed or not self.parsed_list:
messages.error(request, _('We\'ve been unable to parse the uploaded file as a CSV file.'))
return redirect(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect(self.get_form_url())
return super().dispatch(request, *args, **kwargs)
def get_error_url(self):
return reverse('control:event.orders.import.process', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
'file': self.file.id
})
return self.request.path
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
@@ -205,3 +196,89 @@ class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
ctx['parsed'] = self.parsed
ctx['sample_rows'] = self.parsed_list[:3]
return ctx
class OrderImportView(EventPermissionRequiredMixin, BaseImportView):
template_name = 'pretixcontrol/orders/import_start.html'
permission = 'can_change_orders'
def get_process_url(self, request, cf, charset):
return reverse('control:event.orders.import.process', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'file': cf.id
}) + "?charset=" + charset
class OrderProcessView(EventPermissionRequiredMixin, BaseProcessView):
permission = 'can_change_orders'
template_name = 'pretixcontrol/orders/import_process.html'
form_class = OrdersProcessForm
task = import_orders
settings_key = 'order_import_settings'
@property
def settings_holder(self):
return self.request.event
def get_form_kwargs(self):
k = super().get_form_kwargs()
k.update({
'event': self.request.event,
})
return k
def get_form_url(self):
return reverse('control:event.orders.import', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
def get_success_url(self, value):
return reverse('control:event.orders', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
class VoucherImportView(EventPermissionRequiredMixin, BaseImportView):
template_name = 'pretixcontrol/vouchers/import_start.html'
permission = 'can_change_vouchers'
def get_process_url(self, request, cf, charset):
return reverse('control:event.vouchers.import.process', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'file': cf.id
}) + "?charset=" + charset
class VoucherProcessView(EventPermissionRequiredMixin, BaseProcessView):
permission = 'can_change_vouchers'
template_name = 'pretixcontrol/vouchers/import_process.html'
form_class = VouchersProcessForm
task = import_vouchers
settings_key = 'voucher_import_settings'
@property
def settings_holder(self):
return self.request.event
def get_form_kwargs(self):
k = super().get_form_kwargs()
k.update({
'event': self.request.event,
})
return k
def get_form_url(self):
return reverse('control:event.vouchers.import', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
def get_success_url(self, value):
return reverse('control:event.vouchers', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})

View File

@@ -262,7 +262,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['fonts'] = get_fonts()
ctx['fonts'] = get_fonts(self.request.event, pdf_support_required=True)
ctx['pdf'] = self.get_current_background()
ctx['variables'] = self.get_variables()
ctx['images'] = self.get_images()
@@ -278,7 +278,7 @@ class FontsCSSView(TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['fonts'] = get_fonts()
ctx['fonts'] = get_fonts(self.request.event if hasattr(self.request, 'event') else None, pdf_support_required=True)
return ctx

View File

@@ -44,6 +44,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
@@ -323,6 +324,15 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
obj.devicetype = 'webauthn'
ctx['devices'] += objs
ctx['obligatory'] = None
if settings.PRETIX_OBLIGATORY_2FA is True:
ctx['obligatory'] = 'system'
elif settings.PRETIX_OBLIGATORY_2FA == "staff" and self.request.user.is_staff:
ctx['obligatory'] = 'staff'
elif teams := self.request.user.teams.filter(require_2fa=True).select_related('organizer'):
ctx['obligatory'] = 'team'
ctx['obligatory_teams'] = teams
return ctx
@@ -557,6 +567,28 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
}))
class User2FALeaveTeamsView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_leaveteams.html'
@transaction.atomic
def post(self, request, *args, **kwargs):
for team in self.request.user.teams.filter(require_2fa=True).select_related('organizer'):
team.members.remove(self.request.user)
team.log_action(
'pretix.team.member.removed', user=self.request.user, data={
'email': self.request.user.email,
'user': self.request.user.pk
}
)
messages.success(request, _('You have left all teams that require two-factor authentication.'))
return redirect(reverse('control:user.settings.2fa'))
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['obligatory_teams'] = self.request.user.teams.filter(require_2fa=True).select_related('organizer')
return ctx
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html'

View File

@@ -30,10 +30,12 @@ from django.contrib.auth import (
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import ListView, TemplateView
from django_otp.plugins.otp_static.models import StaticDevice
from hijack import signals
from pretix.base.auth import get_auth_backends
@@ -150,6 +152,32 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
return reverse('control:users.edit', kwargs=self.kwargs)
class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
def get(self, request, *args, **kwargs):
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency')
token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user)
messages.success(request, _(
'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit '
'this code only over an authenticated channel (other than email, if possible). Any previous emergency '
'tokens for this user remain active.'
).format(
token=token.token
))
return redirect(self.get_success_url())
def get_success_url(self):
return reverse('control:users.edit', kwargs=self.kwargs)
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
template_name = "pretixcontrol/users/anonymize.html"

View File

@@ -1,70 +0,0 @@
# This file is distributed under the same license as the Django package.
#
# Modified informal version by
# Raphael Michel <mail@raphaelmichel.de>, 2016
#
# Original Translators:
# André Hagenbruch, 2011-2012
# Florian Apolloner <florian@apolloner.eu>, 2011
# Jannis Vajen, 2011,2013
# Jannis Leidel <jannis@leidel.info>, 2013-2015
# Markus Holtermann <inyoka@markusholtermann.eu>, 2013,2015
msgid ""
msgstr ""
"Project-Id-Version: django\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-10-09 17:42+0200\n"
"PO-Revision-Date: 2016-03-18 21:22+0100\n"
"Last-Translator: Raphael Michel <mail@raphaelmichel.de>\n"
"Language-Team: German (http://www.transifex.com/django/django/language/de/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.8.7\n"
msgid "Please either submit a file or check the clear checkbox, not both."
msgstr ""
"Bitte wähle entweder eine Datei aus oder wähle \"Löschen\", nicht "
"beides."
msgid ""
"You are seeing this message because this HTTPS site requires a 'Referer "
"header' to be sent by your Web browser, but none was sent. This header is "
"required for security reasons, to ensure that your browser is not being "
"hijacked by third parties."
msgstr ""
"Du siehst diese Fehlermeldung da diese HTTPS-Seite einen „Referer“-Header "
"von deinem Webbrowser erwartet, aber keinen erhalten hat. Dieser Header ist "
"aus Sicherheitsgründen notwendig, um sicherzustellen, dass dein Webbrowser "
"nicht von Dritten missbraucht wird."
msgid ""
"If you have configured your browser to disable 'Referer' headers, please re-"
"enable them, at least for this site, or for HTTPS connections, or for 'same-"
"origin' requests."
msgstr ""
"Falls du deinen Webbrowser so konfiguriert hast, dass „Referer“-Header "
"nicht gesendet werden, musst du diese Funktion mindestens für diese Seite, "
"für sichere HTTPS-Verbindungen oder für „Same-Origin“-Verbindungen "
"reaktivieren."
msgid ""
"You are seeing this message because this site requires a CSRF cookie when "
"submitting forms. This cookie is required for security reasons, to ensure "
"that your browser is not being hijacked by third parties."
msgstr ""
"Du siehst Diese Nachricht, da diese Seite einen CSRF-Cookie beim Verarbeiten "
"von Formulardaten benötigt. Dieses Cookie ist aus Sicherheitsgründen "
"notwendig, um sicherzustellen, dass dein Webbrowser nicht von Dritten "
"missbraucht wird."
msgid ""
"If you have configured your browser to disable cookies, please re-enable "
"them, at least for this site, or for 'same-origin' requests."
msgstr ""
"Falls du Cookies in Ihren Webbrowser deaktiviert hast, musst du sie "
"mindestens für diese Seite oder für „Same-Origin“-Verbindungen reaktivieren."

View File

@@ -20,14 +20,23 @@
# <https://www.gnu.org/licenses/>.
#
import hashlib
import logging
import time
from django.conf import settings
from django.contrib.gis.geoip2 import GeoIP2
from django.core.cache import cache
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from geoip2.errors import AddressNotFoundError
from pretix.base.i18n import language
from pretix.base.services.mail import SendMailException, mail
from pretix.helpers.http import get_client_ip
from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__)
class SessionInvalid(Exception):
@@ -38,6 +47,14 @@ class SessionReauthRequired(Exception):
pass
class Session2FASetupRequired(Exception):
pass
class SessionPasswordChangeRequired(Exception):
pass
def get_user_agent_hash(request):
return hashlib.sha256(request.headers['User-Agent'].encode()).hexdigest()
@@ -71,6 +88,8 @@ def assert_session_valid(request):
if 'User-Agent' in request.headers:
if 'pinned_user_agent' in request.session:
if request.session.get('pinned_user_agent') != get_user_agent_hash(request):
logger.info(f"Backend session for user {request.user.pk} terminated due to user agent change. "
f"New agent: \"{request.headers['User-Agent']}\"")
raise SessionInvalid()
else:
request.session['pinned_user_agent'] = get_user_agent_hash(request)
@@ -82,9 +101,79 @@ def assert_session_valid(request):
if 'pinned_country' in request.session:
if request.session.get('pinned_country') != country:
logger.info(f"Backend session for user {request.user.pk} terminated due to country change. "
f"Old country: \"{request.session.get('pinned_countres')}\" New country: \"{country}\"")
raise SessionInvalid()
else:
request.session['pinned_country'] = country
request.session['pretix_auth_last_used'] = int(time.time())
if request.user.needs_password_change:
raise SessionPasswordChangeRequired()
force_2fa = not request.user.require_2fa and (
settings.PRETIX_OBLIGATORY_2FA is True or
(settings.PRETIX_OBLIGATORY_2FA == "staff" and request.user.is_staff) or
cache.get_or_set(
f'user_2fa_team_{request.user.pk}',
lambda: request.user.teams.filter(require_2fa=True).exists(),
timeout=300
)
)
if force_2fa:
raise Session2FASetupRequired()
return True
def handle_login_source(user, request):
from ua_parser import user_agent_parser
parsed_string = user_agent_parser.Parse(request.headers.get("User-Agent", ""))
country = None
if settings.HAS_GEOIP:
client_ip = get_client_ip(request)
hashed_client_ip = hashlib.sha256(client_ip.encode()).hexdigest()
country = cache.get_or_set(f'geoip_country_{hashed_client_ip}', lambda: _get_country(request), timeout=300)
if country == "None":
country = None
src, created = user.known_login_sources.update_or_create(
agent_type=parsed_string.get("user_agent").get("family"),
os_type=parsed_string.get("os").get("family"),
device_type=parsed_string.get("device").get("family"),
country=country,
defaults={
"last_seen": now(),
}
)
if created:
user.log_action('pretix.control.auth.user.new_source', user=user, data={
"agent_type": src.agent_type,
"os_type": src.os_type,
"device_type": src.device_type,
"country": str(src.country) if src.country else "?",
})
if user.known_login_sources.count() > 1:
# Do not send on first login or first login after introduction of this feature:
try:
with language(user.locale):
mail(
user.email,
_('Login from new source detected'),
'pretixcontrol/email/login_notice.txt',
{
'source': src,
'country': Country(str(country)).name if country else _('Unknown country'),
'instance': settings.PRETIX_INSTANCE_NAME,
'url': build_absolute_uri('control:user.settings')
},
event=None,
user=user,
locale=user.locale
)
except SendMailException:
pass # Not much we can do

View File

@@ -22,9 +22,9 @@
import logging
from django import template
from django.core.files import File
from django.core.files.storage import default_storage
from pretix import settings
from pretix.helpers.thumb import get_thumbnail
register = template.Library()
@@ -33,10 +33,16 @@ logger = logging.getLogger(__name__)
@register.filter
def thumb(source, arg):
if isinstance(source, File):
source = source.name
try:
return get_thumbnail(source, arg).thumb.url
formats = list(set().union(
settings.PILLOW_FORMATS_IMAGE,
settings.PILLOW_FORMATS_QUESTIONS_FAVICON,
settings.PILLOW_FORMATS_QUESTIONS_IMAGE
))
return get_thumbnail(source, arg, formats=formats).thumb.url
except:
logger.exception(f'Failed to create thumbnail of {source}')
return default_storage.url(source)
# HACK: source.url works for some types of files (e.g. FieldFile), and for all files retrieved from Hierarkey,
# default_storage.url works for all files in NanoCDNStorage. For others, this may return an invalid URL.
# But for a fallback, this can probably be accepted.
return source.url if hasattr(source, 'url') else default_storage.url(str(source))

View File

@@ -21,6 +21,7 @@
#
import hashlib
import math
import os
from io import BytesIO
from django.conf import settings
@@ -164,9 +165,16 @@ def resize_image(image, size):
return image
def create_thumbnail(sourcename, size, formats=None):
source = default_storage.open(sourcename)
image = Image.open(BytesIO(source.read()), formats=formats or settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
def create_thumbnail(source, size, formats=None):
source_name = str(source)
# HACK: this ensures that the file is opened in binary mode, which is not guaranteed otherwise, esp. for
# files retrieved from hierarkey. For Django Files in FileSystemStorage, where source.name is the absolute
# filesystem path, this only works because _open() uses safe_join, which accepts absolute paths if they match the
# expected base dir. For NanoCDN Files, this works because source.name is set to the storage path.
source_rb = default_storage.open(source_name, mode='rb')
image = Image.open(BytesIO(source_rb.read()), formats=formats or settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
try:
image.load()
except:
@@ -175,13 +183,14 @@ def create_thumbnail(sourcename, size, formats=None):
frames = [resize_image(frame, size) for frame in ImageSequence.Iterator(image)]
image_out = frames[0]
save_kwargs = {}
source_ext = os.path.splitext(source_name)[1].lower()
if source.name.lower().endswith('.jpg') or source.name.lower().endswith('.jpeg'):
if source_ext == '.jpg' or source_ext == '.jpeg':
# Yields better file sizes for photos
target_ext = 'jpeg'
quality = 95
elif source.name.lower().endswith('.gif') or source.name.lower().endswith('.png'):
target_ext = source.name.lower()[-3:]
elif source_ext == '.gif' or source_ext == '.png':
target_ext = source_name.lower()[-3:]
quality = None
image_out.info = image.info
save_kwargs = {
@@ -196,14 +205,14 @@ def create_thumbnail(sourcename, size, formats=None):
checksum = hashlib.md5(image.tobytes()).hexdigest()
name = checksum + '.' + size.replace('^', 'c') + '.' + target_ext
buffer = BytesIO()
if image_out.mode == "P" and source.name.lower().endswith('.png'):
if image_out.mode == "P" and source_ext == '.png':
image_out = image_out.convert('RGBA')
if image_out.mode not in ("1", "L", "RGB", "RGBA"):
image_out = image_out.convert('RGB')
image_out.save(fp=buffer, format=target_ext.upper(), quality=quality, **save_kwargs)
imgfile = ContentFile(buffer.getvalue())
t = Thumbnail.objects.create(source=sourcename, size=size)
t = Thumbnail.objects.create(source=source_name, size=size)
t.thumb.save(name, imgfile)
return t
@@ -211,6 +220,7 @@ def create_thumbnail(sourcename, size, formats=None):
def get_thumbnail(source, size, formats=None):
# Assumes files are immutable
try:
return Thumbnail.objects.get(source=source, size=size)
source_name = str(source)
return Thumbnail.objects.get(source=source_name, size=size)
except Thumbnail.DoesNotExist:
return create_thumbnail(source, size, formats=formats)

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -578,54 +578,54 @@ msgstr ""
msgid "Unknown error."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:311
#: pretix/static/pretixcontrol/js/ui/main.js:313
msgid "Your color has great contrast and is very easy to read!"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:315
#: pretix/static/pretixcontrol/js/ui/main.js:317
msgid "Your color has decent contrast and is probably good-enough to read!"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:319
#: pretix/static/pretixcontrol/js/ui/main.js:321
msgid ""
"Your color has bad contrast for text on white background, please choose a "
"darker shade."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:473
#: pretix/static/pretixcontrol/js/ui/main.js:493
#: pretix/static/pretixcontrol/js/ui/main.js:475
#: pretix/static/pretixcontrol/js/ui/main.js:495
msgid "Search query"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:491
#: pretix/static/pretixcontrol/js/ui/main.js:493
msgid "All"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:492
#: pretix/static/pretixcontrol/js/ui/main.js:494
msgid "None"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:496
#: pretix/static/pretixcontrol/js/ui/main.js:498
msgid "Selected only"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:791
#: pretix/static/pretixcontrol/js/ui/main.js:794
msgid "Enter page number between 1 and %(max)s."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:794
#: pretix/static/pretixcontrol/js/ui/main.js:797
msgid "Invalid page number."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:952
#: pretix/static/pretixcontrol/js/ui/main.js:955
msgid "Use a different name internally"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:992
#: pretix/static/pretixcontrol/js/ui/main.js:995
msgid "Click to close"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:1067
#: pretix/static/pretixcontrol/js/ui/main.js:1070
msgid "You have unsaved changes!"
msgstr ""
@@ -663,32 +663,32 @@ msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] ""
msgstr[1] ""
#: pretix/static/pretixpresale/js/ui/main.js:171
#: pretix/static/pretixpresale/js/ui/main.js:203
msgid "The organizer keeps %(currency)s %(amount)s"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:179
#: pretix/static/pretixpresale/js/ui/main.js:211
msgid "You get %(currency)s %(amount)s back"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:195
#: pretix/static/pretixpresale/js/ui/main.js:227
msgid "Please enter the amount the organizer can keep."
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:412
#: pretix/static/pretixpresale/js/ui/main.js:444
msgid "Please enter a quantity for one of the ticket types."
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:448
#: pretix/static/pretixpresale/js/ui/main.js:480
msgid "required"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:551
#: pretix/static/pretixpresale/js/ui/main.js:570
#: pretix/static/pretixpresale/js/ui/main.js:583
#: pretix/static/pretixpresale/js/ui/main.js:602
msgid "Time zone:"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:561
#: pretix/static/pretixpresale/js/ui/main.js:593
msgid "Your local time:"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -608,54 +608,54 @@ msgstr "توليد الرسائل …"
msgid "Unknown error."
msgstr "خطأ غير معروف."
#: pretix/static/pretixcontrol/js/ui/main.js:311
#: pretix/static/pretixcontrol/js/ui/main.js:313
msgid "Your color has great contrast and is very easy to read!"
msgstr "اللون يتمتع بتباين كبير وتسهل قراءته!"
#: pretix/static/pretixcontrol/js/ui/main.js:315
#: pretix/static/pretixcontrol/js/ui/main.js:317
msgid "Your color has decent contrast and is probably good-enough to read!"
msgstr "اللون يحظى بتباين معقول ويمكن أن يكون مناسب للقراءة!"
#: pretix/static/pretixcontrol/js/ui/main.js:319
#: pretix/static/pretixcontrol/js/ui/main.js:321
msgid ""
"Your color has bad contrast for text on white background, please choose a "
"darker shade."
msgstr "تباين اللون سيئ للخلفية البيضاء، الرجاء اختيار لون غامق."
#: pretix/static/pretixcontrol/js/ui/main.js:473
#: pretix/static/pretixcontrol/js/ui/main.js:493
#: pretix/static/pretixcontrol/js/ui/main.js:475
#: pretix/static/pretixcontrol/js/ui/main.js:495
msgid "Search query"
msgstr "البحث في الاستفسارات"
#: pretix/static/pretixcontrol/js/ui/main.js:491
#: pretix/static/pretixcontrol/js/ui/main.js:493
msgid "All"
msgstr "الكل"
#: pretix/static/pretixcontrol/js/ui/main.js:492
#: pretix/static/pretixcontrol/js/ui/main.js:494
msgid "None"
msgstr "لا شيء"
#: pretix/static/pretixcontrol/js/ui/main.js:496
#: pretix/static/pretixcontrol/js/ui/main.js:498
msgid "Selected only"
msgstr "المختارة فقط"
#: pretix/static/pretixcontrol/js/ui/main.js:791
#: pretix/static/pretixcontrol/js/ui/main.js:794
msgid "Enter page number between 1 and %(max)s."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:794
#: pretix/static/pretixcontrol/js/ui/main.js:797
msgid "Invalid page number."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:952
#: pretix/static/pretixcontrol/js/ui/main.js:955
msgid "Use a different name internally"
msgstr "قم باستخدم اسم مختلف داخليا"
#: pretix/static/pretixcontrol/js/ui/main.js:992
#: pretix/static/pretixcontrol/js/ui/main.js:995
msgid "Click to close"
msgstr "اضغط لاغلاق الصفحة"
#: pretix/static/pretixcontrol/js/ui/main.js:1067
#: pretix/static/pretixcontrol/js/ui/main.js:1070
msgid "You have unsaved changes!"
msgstr "لم تقم بحفظ التعديلات!"
@@ -707,32 +707,32 @@ msgstr[3] "سيتم حجز العناصر لك في سلة التسوق لعدة
msgstr[4] "سيتم حجز العناصر لك في سلة التسوق لدقائق {num}."
msgstr[5] "سيتم حجز العناصر لك في سلة التسوق لمدة {num}."
#: pretix/static/pretixpresale/js/ui/main.js:171
#: pretix/static/pretixpresale/js/ui/main.js:203
msgid "The organizer keeps %(currency)s %(amount)s"
msgstr "يحصل المنظم على %(currency) %(amount)"
#: pretix/static/pretixpresale/js/ui/main.js:179
#: pretix/static/pretixpresale/js/ui/main.js:211
msgid "You get %(currency)s %(amount)s back"
msgstr "ستسترد %(currency)%(amount)"
#: pretix/static/pretixpresale/js/ui/main.js:195
#: pretix/static/pretixpresale/js/ui/main.js:227
msgid "Please enter the amount the organizer can keep."
msgstr "الرجاء إدخال المبلغ الذي يمكن للمنظم الاحتفاظ به."
#: pretix/static/pretixpresale/js/ui/main.js:412
#: pretix/static/pretixpresale/js/ui/main.js:444
msgid "Please enter a quantity for one of the ticket types."
msgstr "الرجاء إدخال عدد لأحد أنواع التذاكر."
#: pretix/static/pretixpresale/js/ui/main.js:448
#: pretix/static/pretixpresale/js/ui/main.js:480
msgid "required"
msgstr "مطلوب"
#: pretix/static/pretixpresale/js/ui/main.js:551
#: pretix/static/pretixpresale/js/ui/main.js:570
#: pretix/static/pretixpresale/js/ui/main.js:583
#: pretix/static/pretixpresale/js/ui/main.js:602
msgid "Time zone:"
msgstr "المنطقة الزمنية:"
#: pretix/static/pretixpresale/js/ui/main.js:561
#: pretix/static/pretixpresale/js/ui/main.js:593
msgid "Your local time:"
msgstr "التوقيت المحلي:"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
"PO-Revision-Date: 2020-12-19 07:00+0000\n"
"Last-Translator: albert <albert.serra.monner@gmail.com>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
@@ -579,54 +579,54 @@ msgstr ""
msgid "Unknown error."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:311
#: pretix/static/pretixcontrol/js/ui/main.js:313
msgid "Your color has great contrast and is very easy to read!"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:315
#: pretix/static/pretixcontrol/js/ui/main.js:317
msgid "Your color has decent contrast and is probably good-enough to read!"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:319
#: pretix/static/pretixcontrol/js/ui/main.js:321
msgid ""
"Your color has bad contrast for text on white background, please choose a "
"darker shade."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:473
#: pretix/static/pretixcontrol/js/ui/main.js:493
#: pretix/static/pretixcontrol/js/ui/main.js:475
#: pretix/static/pretixcontrol/js/ui/main.js:495
msgid "Search query"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:491
#: pretix/static/pretixcontrol/js/ui/main.js:493
msgid "All"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:492
#: pretix/static/pretixcontrol/js/ui/main.js:494
msgid "None"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:496
#: pretix/static/pretixcontrol/js/ui/main.js:498
msgid "Selected only"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:791
#: pretix/static/pretixcontrol/js/ui/main.js:794
msgid "Enter page number between 1 and %(max)s."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:794
#: pretix/static/pretixcontrol/js/ui/main.js:797
msgid "Invalid page number."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:952
#: pretix/static/pretixcontrol/js/ui/main.js:955
msgid "Use a different name internally"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:992
#: pretix/static/pretixcontrol/js/ui/main.js:995
msgid "Click to close"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:1067
#: pretix/static/pretixcontrol/js/ui/main.js:1070
msgid "You have unsaved changes!"
msgstr ""
@@ -668,34 +668,34 @@ msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "El contingut de la cistella ja no el teniu reservat."
msgstr[1] "El contingut de la cistella ja no el teniu reservat."
#: pretix/static/pretixpresale/js/ui/main.js:171
#: pretix/static/pretixpresale/js/ui/main.js:203
msgid "The organizer keeps %(currency)s %(amount)s"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:179
#: pretix/static/pretixpresale/js/ui/main.js:211
msgid "You get %(currency)s %(amount)s back"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:195
#: pretix/static/pretixpresale/js/ui/main.js:227
msgid "Please enter the amount the organizer can keep."
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:412
#: pretix/static/pretixpresale/js/ui/main.js:444
msgid "Please enter a quantity for one of the ticket types."
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:448
#: pretix/static/pretixpresale/js/ui/main.js:480
#, fuzzy
#| msgid "Cart expired"
msgid "required"
msgstr "Cistella expirada"
#: pretix/static/pretixpresale/js/ui/main.js:551
#: pretix/static/pretixpresale/js/ui/main.js:570
#: pretix/static/pretixpresale/js/ui/main.js:583
#: pretix/static/pretixpresale/js/ui/main.js:602
msgid "Time zone:"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:561
#: pretix/static/pretixpresale/js/ui/main.js:593
msgid "Your local time:"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
"PO-Revision-Date: 2023-09-15 06:00+0000\n"
"Last-Translator: Michael <michael.happl@gmx.at>\n"
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -599,16 +599,16 @@ msgstr "Vytváření zpráv…"
msgid "Unknown error."
msgstr "Neznámá chyba."
#: pretix/static/pretixcontrol/js/ui/main.js:311
#: pretix/static/pretixcontrol/js/ui/main.js:313
msgid "Your color has great contrast and is very easy to read!"
msgstr "Tato barva má velmi dobrý kontrast a je velmi dobře čitelná!"
#: pretix/static/pretixcontrol/js/ui/main.js:315
#: pretix/static/pretixcontrol/js/ui/main.js:317
msgid "Your color has decent contrast and is probably good-enough to read!"
msgstr ""
"Tato barva má slušný kontrast a pravděpodobně je dostatečně dobře čitelná!"
#: pretix/static/pretixcontrol/js/ui/main.js:319
#: pretix/static/pretixcontrol/js/ui/main.js:321
msgid ""
"Your color has bad contrast for text on white background, please choose a "
"darker shade."
@@ -616,40 +616,40 @@ msgstr ""
"Tato barva je pro text na bílém pozadí špatně kontrastní, zvolte prosím "
"tmavší odstín."
#: pretix/static/pretixcontrol/js/ui/main.js:473
#: pretix/static/pretixcontrol/js/ui/main.js:493
#: pretix/static/pretixcontrol/js/ui/main.js:475
#: pretix/static/pretixcontrol/js/ui/main.js:495
msgid "Search query"
msgstr "Hledaný výraz"
#: pretix/static/pretixcontrol/js/ui/main.js:491
#: pretix/static/pretixcontrol/js/ui/main.js:493
msgid "All"
msgstr "Všechny"
#: pretix/static/pretixcontrol/js/ui/main.js:492
#: pretix/static/pretixcontrol/js/ui/main.js:494
msgid "None"
msgstr "Žádný"
#: pretix/static/pretixcontrol/js/ui/main.js:496
#: pretix/static/pretixcontrol/js/ui/main.js:498
msgid "Selected only"
msgstr "Pouze vybrané"
#: pretix/static/pretixcontrol/js/ui/main.js:791
#: pretix/static/pretixcontrol/js/ui/main.js:794
msgid "Enter page number between 1 and %(max)s."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:794
#: pretix/static/pretixcontrol/js/ui/main.js:797
msgid "Invalid page number."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:952
#: pretix/static/pretixcontrol/js/ui/main.js:955
msgid "Use a different name internally"
msgstr "Interně používat jiný název"
#: pretix/static/pretixcontrol/js/ui/main.js:992
#: pretix/static/pretixcontrol/js/ui/main.js:995
msgid "Click to close"
msgstr "Kliknutím zavřete"
#: pretix/static/pretixcontrol/js/ui/main.js:1067
#: pretix/static/pretixcontrol/js/ui/main.js:1070
msgid "You have unsaved changes!"
msgstr "Máte neuložené změny!"
@@ -694,32 +694,32 @@ msgstr[1] ""
msgstr[2] ""
"Produkty v nákupním košíku jsou pro vás rezervovány na dalších {num} minut."
#: pretix/static/pretixpresale/js/ui/main.js:171
#: pretix/static/pretixpresale/js/ui/main.js:203
msgid "The organizer keeps %(currency)s %(amount)s"
msgstr "Organizátor si ponechává %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js:179
#: pretix/static/pretixpresale/js/ui/main.js:211
msgid "You get %(currency)s %(amount)s back"
msgstr "Dostanete %(currency)s %(amount)s zpět"
#: pretix/static/pretixpresale/js/ui/main.js:195
#: pretix/static/pretixpresale/js/ui/main.js:227
msgid "Please enter the amount the organizer can keep."
msgstr "Zadejte částku, kterou si organizátor může ponechat."
#: pretix/static/pretixpresale/js/ui/main.js:412
#: pretix/static/pretixpresale/js/ui/main.js:444
msgid "Please enter a quantity for one of the ticket types."
msgstr "Zadejte prosím množství pro jeden z typů vstupenek."
#: pretix/static/pretixpresale/js/ui/main.js:448
#: pretix/static/pretixpresale/js/ui/main.js:480
msgid "required"
msgstr "povinný"
#: pretix/static/pretixpresale/js/ui/main.js:551
#: pretix/static/pretixpresale/js/ui/main.js:570
#: pretix/static/pretixpresale/js/ui/main.js:583
#: pretix/static/pretixpresale/js/ui/main.js:602
msgid "Time zone:"
msgstr "Časové pásmo:"
#: pretix/static/pretixpresale/js/ui/main.js:561
#: pretix/static/pretixpresale/js/ui/main.js:593
msgid "Your local time:"
msgstr "Místní čas:"

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -579,54 +579,54 @@ msgstr ""
msgid "Unknown error."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:311
#: pretix/static/pretixcontrol/js/ui/main.js:313
msgid "Your color has great contrast and is very easy to read!"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:315
#: pretix/static/pretixcontrol/js/ui/main.js:317
msgid "Your color has decent contrast and is probably good-enough to read!"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:319
#: pretix/static/pretixcontrol/js/ui/main.js:321
msgid ""
"Your color has bad contrast for text on white background, please choose a "
"darker shade."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:473
#: pretix/static/pretixcontrol/js/ui/main.js:493
#: pretix/static/pretixcontrol/js/ui/main.js:475
#: pretix/static/pretixcontrol/js/ui/main.js:495
msgid "Search query"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:491
#: pretix/static/pretixcontrol/js/ui/main.js:493
msgid "All"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:492
#: pretix/static/pretixcontrol/js/ui/main.js:494
msgid "None"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:496
#: pretix/static/pretixcontrol/js/ui/main.js:498
msgid "Selected only"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:791
#: pretix/static/pretixcontrol/js/ui/main.js:794
msgid "Enter page number between 1 and %(max)s."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:794
#: pretix/static/pretixcontrol/js/ui/main.js:797
msgid "Invalid page number."
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:952
#: pretix/static/pretixcontrol/js/ui/main.js:955
msgid "Use a different name internally"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:992
#: pretix/static/pretixcontrol/js/ui/main.js:995
msgid "Click to close"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:1067
#: pretix/static/pretixcontrol/js/ui/main.js:1070
msgid "You have unsaved changes!"
msgstr ""
@@ -664,32 +664,32 @@ msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] ""
msgstr[1] ""
#: pretix/static/pretixpresale/js/ui/main.js:171
#: pretix/static/pretixpresale/js/ui/main.js:203
msgid "The organizer keeps %(currency)s %(amount)s"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:179
#: pretix/static/pretixpresale/js/ui/main.js:211
msgid "You get %(currency)s %(amount)s back"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:195
#: pretix/static/pretixpresale/js/ui/main.js:227
msgid "Please enter the amount the organizer can keep."
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:412
#: pretix/static/pretixpresale/js/ui/main.js:444
msgid "Please enter a quantity for one of the ticket types."
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:448
#: pretix/static/pretixpresale/js/ui/main.js:480
msgid "required"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:551
#: pretix/static/pretixpresale/js/ui/main.js:570
#: pretix/static/pretixpresale/js/ui/main.js:583
#: pretix/static/pretixpresale/js/ui/main.js:602
msgid "Time zone:"
msgstr ""
#: pretix/static/pretixpresale/js/ui/main.js:561
#: pretix/static/pretixpresale/js/ui/main.js:593
msgid "Your local time:"
msgstr ""

File diff suppressed because it is too large Load Diff

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