Compare commits

...

41 Commits

Author SHA1 Message Date
Raphael Michel
5139eeb03b more work 2025-06-24 15:01:56 +02:00
Raphael Michel
a7edb16fc0 API: Generalize concept of including/excluding/expanding fields 2025-06-24 09:52:32 +02:00
dependabot[bot]
f6df03c427 Bump brace-expansion from 1.1.11 to 1.1.12 in /src/pretix/static/npm_dir (#5265)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 17:17:57 +02:00
dependabot[bot]
308eac20b2 Update redis requirement from ==5.2.* to ==6.2.* (#5181)
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v5.2.0...v6.2.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 6.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 17:03:41 +02:00
dependabot[bot]
ab3c03b278 Update fakeredis requirement from ==2.26.* to ==2.30.* (#5253)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.26.0...v2.30.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.30.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 16:27:21 +02:00
dependabot[bot]
161404f152 Update sentry-sdk requirement from ==2.29.* to ==2.30.* (#5241)
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/2.29.0...2.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 16:06:30 +02:00
dependabot[bot]
8b119b329c Update django-redis requirement from ==5.4.* to ==6.0.* (#5252)
Updates the requirements on [django-redis](https://github.com/jazzband/django-redis) to permit the latest version.
- [Release notes](https://github.com/jazzband/django-redis/releases)
- [Changelog](https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jazzband/django-redis/compare/5.4.0...6.0.0)

---
updated-dependencies:
- dependency-name: django-redis
  dependency-version: 6.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 15:44:22 +02:00
Raphael Michel
512ca1966d Remove a cache isolation issue during tests 2025-06-23 15:40:54 +02:00
dependabot[bot]
90ec82ea1a Update oauthlib requirement from ==3.2.* to ==3.3.* (#5254)
Updates the requirements on [oauthlib](https://github.com/oauthlib/oauthlib) to permit the latest version.
- [Release notes](https://github.com/oauthlib/oauthlib/releases)
- [Changelog](https://github.com/oauthlib/oauthlib/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/oauthlib/oauthlib/compare/v3.2.0...v3.3.0)

---
updated-dependencies:
- dependency-name: oauthlib
  dependency-version: 3.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 15:02:53 +02:00
Richard Schreiber
d55f411989 Add data-article-id to reference cart-item in product-list (#5244) 2025-06-23 11:49:23 +02:00
Raphael Michel
40855e14d9 Fix non-total ordering of items (fixes flaky test) (#5251) 2025-06-23 10:04:12 +02:00
Richard Schreiber
7bb2e4c170 Improve stats-UI fix (#5243)
* Improve stats-UI fix

* remove unused stats_json
2025-06-18 09:11:16 +02:00
Raphael Michel
dec07b2df1 Subevent calendar: Respect time machine (#5231) 2025-06-17 11:30:52 +02:00
Raphael Michel
9fc9aaa661 Event settings: Fix duplicate font choices (Z#23196687) (#5230) 2025-06-17 09:58:18 +02:00
Raphael Michel
70f71c8077 Email: Remove more characters from sender name (Z#23197264) (#5248)
* Email: Remove more characters from sender name (Z#23197264)

* fix typo

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-06-16 10:49:08 +02:00
Richard Schreiber
dc198d4ab6 Control: fix question graphs UI (#5242) 2025-06-13 11:05:10 +02:00
Richard Schreiber
fdbb03d038 Translations: Update German
Currently translated at 100.0% (5900 of 5900 strings)

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

powered by weblate
2025-06-13 11:02:25 +02:00
Raphael Michel
8418d03add Questions: Express percentage of tickets (Z#23196542) (#5239)
* Questions: Express percentage of tickets (Z#23196542)

* add missing td for sum

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-06-13 11:01:37 +02:00
luelista
b5f8438c18 Show warning on incompatible waiting list options (#5218)
If "Hide all products that are sold out" is enabled, the waiting list won't work.
2025-06-13 11:01:18 +02:00
Raphael Michel
5420f57aa2 Subevent bulk editing: Warn about deleted quotas (#5238)
* Subevent bulk editing: Warn about deleted quotas

* Fix condition

* Update alerts
2025-06-13 11:01:00 +02:00
luelista
b5e20df508 Use proper log entry types for waiting list emails (#5070) (#5219) 2025-06-12 14:03:40 +02:00
Raphael Michel
eba5c1b36d API: Fix crash on distributing a fee over tax rates with zero value (Z#23196669) (#5226) 2025-06-12 14:03:25 +02:00
Raphael Michel
7d30ecf527 API: Add items__in filter for quotas (Z#23195926) (#5232)
* API: Add items__in filter for quotas (Z#23195926)

* Update doc/api/resources/quotas.rst

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

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-06-12 12:24:45 +02:00
Raphael Michel
2359307462 Remove replaced docs 2025-06-12 10:54:28 +02:00
dependabot[bot]
325f7c565d Bump django-localflavor from 4.0 to 5.0 (#5234)
Bumps [django-localflavor](https://github.com/django/django-localflavor) from 4.0 to 5.0.
- [Changelog](https://github.com/django/django-localflavor/blob/master/docs/changelog.rst)
- [Commits](https://github.com/django/django-localflavor/compare/4.0...5.0)

---
updated-dependencies:
- dependency-name: django-localflavor
  dependency-version: '5.0'
  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>
2025-06-11 10:57:10 +02:00
luelista
df48adef1b Filter payment method sales channels when cloning event to new organizer (Z#23196085) (#5220) 2025-06-11 10:56:58 +02:00
Richard Schreiber
74cea09f6c [A11y] add missing autcomplete (#5236) 2025-06-11 10:47:17 +02:00
Richard Schreiber
e8abe5cad8 [A11y] fix variations toggle-button missing aria-controls (#5237) 2025-06-11 10:46:53 +02:00
조정화
6c9f66487d Translations: Update Korean
Currently translated at 51.4% (3036 of 5900 strings)

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

powered by weblate
2025-06-11 09:40:37 +02:00
조정화
5f828127bf Translations: Update Korean
Currently translated at 49.8% (2943 of 5900 strings)

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

powered by weblate
2025-06-11 09:40:37 +02:00
Michael Dao
c5b3093f20 Translations: Update Vietnamese
Currently translated at 89.1% (5261 of 5900 strings)

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

powered by weblate
2025-06-11 09:40:37 +02:00
Richard Schreiber
ae4073b3e4 [A11y] improve cart renew confirmation (#5206)
* [A11y] improve cart renew confirmation

* revert time

* add inline-dialog to cart-renewal-button so confirm-button has interactive meaning
2025-06-11 08:58:26 +02:00
Richard Schreiber
362ac8de6f [A11y] Widget: pass doc title in overlay to iframe.title (#5210) 2025-06-10 20:41:51 +02:00
Richard Schreiber
cced9cd768 Widget: remove role=alertdialog for checkout overlay as it is to obtrusive in NVDA (#5211) 2025-06-10 20:41:23 +02:00
Richard Schreiber
dfb45e13ca [A11y] Widget: make inputs min-height instead of fixed height (#5216) 2025-06-10 20:40:52 +02:00
Richard Schreiber
23489f50f8 [A11y] Widget: change calendar table aria-label to labelledby (#5217) 2025-06-10 20:40:33 +02:00
Richard Schreiber
80148a8435 [A11y] Widget: move dialog-focus to close-button (#5221) 2025-06-10 20:39:43 +02:00
Richard Schreiber
9f49b7747c [A11y] Checkout: fix semantics for addon-list, etc. (#5212) 2025-06-10 20:39:16 +02:00
Richard Schreiber
b75f8bf893 Widget: fix loading spinner not showing on API-request (#5228)
* Widget: fix loading spinner not showing while API-request

* remove not needed showModal as it is handled be frame_loading-watcher

* add double check if dialog is open before closing it
2025-06-10 20:35:11 +02:00
Richard Schreiber
d53af424cf Widget: fix prefill 1 with variation-product (#5229) 2025-06-10 20:34:47 +02:00
Richard Schreiber
24c02751cc Fix phone tel-country-code label and autocomplete (#5227)
* Fix phone tel-country-code label and autocomplete

* Add autocomplete sectioning for MultiWidget
2025-06-10 20:34:08 +02:00
64 changed files with 6557 additions and 4436 deletions

View File

@@ -203,9 +203,35 @@ Query parameters
Most list endpoints allow a filtering of the results using query parameters. In this case, booleans should be passed
as the string values ``true`` and ``false``.
Ordering
--------
If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed
fields. Prepend a ``-`` to the field name to reverse the sort order.
Filtering and expanding fields
------------------------------
On many endpoints, you can modify what fields are being returned:
- Using the ``include`` query parameter, you can chose which fields will be returned as part of the response.
For example, if you pass ``include=code&include=email`` to the list of orders, you will receive a list of only
order codes and email addresses.
- Using the ``exclude`` query parameter, you can chose which fields will not be returned as part of the response.
For example, if you pass ``exclude=payments&exclude=refunds`` to the list of orders, you will receive a list
without the payment and refund objects.
- Using the ``expand`` query parameter, you can chose which fields will be expanded into full objects. For example,
if you pass ``expand=voucher`` to the list of order positions, the response will contain a full voucher object
instead of just the ID. If you do not have permission to view vouchers, a 403 status code is returned.
For performance reasons, this option is only available for a limited number of fields that are noted as
"expandable" in the documentation of the respective object.
In all of these, you can use dotted notation to address fields of sub-objects, such as ``positions.checkins.gate``.
These options are not available everywhere as we are slowly rolling them out throughout the codebase. Please check
the individual endpoint documentation for availability.
Idempotency
-----------

View File

@@ -152,6 +152,8 @@ Endpoints
and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response
slow.
:query search: Only return events matching a given search query.
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:param organizer: The ``slug`` field of a valid organizer
:statuscode 200: no error
:statuscode 401: Authentication failure
@@ -223,6 +225,8 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.

View File

@@ -19,7 +19,7 @@ name multi-lingual string The item's vi
internal_name string An optional name that is only used in the backend
default_price money (string) The item price that is applied if the price is not
overwritten by variations or other options.
category integer The ID of the category this item belongs to
category integer (expandable) The ID of the category this item belongs to
(or ``null``).
active boolean If ``false``, the item is hidden from all public lists
and will not be sold.
@@ -33,7 +33,7 @@ free_price_suggestion money (string) A suggested p
``free_price`` is set (or ``null``).
tax_rate decimal (string) The VAT rate to be applied for this item (read-only,
set through ``tax_rule``).
tax_rule integer The internal ID of the applied tax rule (or ``null``).
tax_rule integer (expandable) The internal ID of the applied tax rule (or ``null``).
admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others
(such as add-ons or merchandise).
@@ -390,6 +390,9 @@ Endpoints
will be returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
Default: ``position``
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:query string expand: Expand an object reference with the referenced object. Can be passed multiple times.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
@@ -531,6 +534,9 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the item to fetch
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:query string expand: Expand an object reference with the referenced object. Can be passed multiple times.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.

View File

@@ -157,8 +157,8 @@ order string Order code of t
positionid integer Number of the position within the order
canceled boolean Whether or not this position has been canceled. Note that
by default, only non-canceled positions are shown.
item integer ID of the purchased item
variation integer ID of the purchased variation (or ``null``)
item integer (expandable) ID of the purchased item
variation integer (expandable) ID of the purchased variation (or ``null``)
price money (string) Price of this position
attendee_name string Specified attendee name for this position (or ``null``)
attendee_name_parts object of strings Decomposition of attendee name (i.e. given name, family name)
@@ -170,7 +170,7 @@ city string Attendee city (
country string Attendee country code (or ``null``)
state string Attendee state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
voucher integer Internal ID of the voucher used for this position (or ``null``)
voucher integer (expandable) Internal ID of the voucher used for this position (or ``null``)
voucher_budget_use money (string) Amount of money discounted by the voucher, corresponding
to how much of the ``budget`` of the voucher is consumed.
**Important:** Do not rely on this amount to be a useful
@@ -182,7 +182,7 @@ tax_code string Codified reason
tax_rule integer The ID of the used tax rule (or ``null``)
secret string Secret code printed on the tickets for validation
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
subevent integer (expandable) ID of the date inside an event series this position belongs to (or ``null``).
discount integer ID of a discount that has been used during the creation of this position in some way (or ``null``).
blocked list of strings A list of strings, or ``null``. Whenever not ``null``, the ticket may not be used (e.g. for check-in).
valid_from datetime The ticket will not be valid before this time. Can be ``null``.
@@ -1059,9 +1059,10 @@ Creating orders
prices. Note that this will not include other fees and is calculated once during order generation and will not
be respected automatically when the order changes later.)
* ``_split_taxes_like_products`` (Optional convenience flag. If set to ``true``, your ``tax_rule`` will be ignored
and the fee will be taxed like the products in the order. If the products have multiple tax rates, multiple fees
will be generated with weights adjusted to the net price of the products. Note that this will be calculated once
during order generation and is not respected automatically when the order changes later.)
and the fee will be taxed like the products in the order *unless* the total amount of the positions is zero.
If the products have multiple tax rates, multiple fees will be generated with weights adjusted to the net price
of the products. Note that this will be calculated once during order generation and is not respected automatically
when the order changes later.)
* ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of

View File

@@ -61,6 +61,8 @@ Endpoints
:query page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and
``name``. Default: ``slug``.
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:statuscode 200: no error
:statuscode 401: Authentication failure
@@ -91,6 +93,8 @@ Endpoints
}
:param organizer: The ``slug`` field of the organizer to fetch
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.

View File

@@ -81,7 +81,8 @@ Endpoints
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
Default: ``position``
:query integer subevent: Only return quotas of the sub-event with the given ID.
:query integer subevent__in: Only return quotas of sub-events with one the given IDs (comma-separated).
:query integer subevent__in: Only return quotas of sub-events with one of the given IDs (comma-separated).
:query integer items__in: Only return quotas that include a product with one of the given IDs (comma-separated).
:query string with_availability: Set to ``true`` to get availability information. Can lead to increased answer times.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch

View File

@@ -146,10 +146,70 @@ Endpoints
attribute with values of 100 for "tickets available", values less than 100 for "tickets sold out or reserved",
and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response
slow.
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
Returns information on one sub-event, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"presale_start": null,
"presale_end": null,
"location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"available_from": null,
"available_until": null,
"price": "12.00"
}
],
"variation_price_overrides": [],
"meta_data": {}
}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:param id: The ``id`` field of the sub-event to fetch
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/
Creates a new subevent.
@@ -237,63 +297,6 @@ Endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
Returns information on one sub-event, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"presale_start": null,
"presale_end": null,
"location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"available_from": null,
"available_until": null,
"price": "12.00"
}
],
"variation_price_overrides": [],
"meta_data": {}
}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:param id: The ``id`` field of the sub-event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
Updates a sub-event, identified by its ID. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to

View File

@@ -1,177 +0,0 @@
.. spelling:word-list::
AGPL
AGPLv3
GPL
LGPL
Apache
BSD
MIT
CLA
django
i18nfields
hierarkey
rami.io
rami
io
GmbH
License FAQ
===========
.. warning::
This FAQ tries to explain in simpler terms what the license of the pretix open source project does and does not
allow. It is based on our interpretation of the license and is not legal advice. The contents of this page are not
legally binding, only the original text of the license in the `license file`_ is legally binding.
How is pretix licensed?
-----------------------
pretix follows the popular dual licensing model. It is available under the `GNU Affero General Public License 3`_ (AGPL)
plus some additional terms, as well as under a proprietary license ("pretix Enterprise license") on request.
How can it be AGPL if there are additional terms?
-------------------------------------------------
Even though it is fairly unknown, the AGPL's section 7 is titled "Additional Terms" and outlines specific conditions
under which additional terms can be imposed on an AGPL-licensed work. In our case, we add three additional terms.
The first additional term for pretix is an additional **permission**. It allows you to do something that the AGPL would
generally not allow. As it doesn't restrict your freedoms granted by AGPL, if you don't like it, you can ignore it, and
if you distribute pretix further, you can remove it.
The second and third additional term for pretix are additional terms that restrict or specify other provisions of the
license. AGPL specifically requires that these terms can only restrict or specify very specific things and we believe
our additional terms are in compliance with that and are thus valid and may not be removed.
Why did you choose this license model?
--------------------------------------
pretix was born in the open source community and we're deeply committed to building the best open source ticketing
solution in the world. It is important to us that pretix is available with a comprehensive feature set under term that
are compatible with the `Open Source Definition`_. This enables event organizers from all industries and regions
to have access to a self-hosted, privacy-friendly and secure option to host their events.
However, developing and maintaining pretix is a lot of work. Between 2014 and 2021, we've received external
contributions from more than 150 individuals. Not counting translations over 90 % of the development was
done by staff engineers of rami.io GmbH, the company that started pretix. While we're very happy to receive many more
contributions in the future, we also want to ensure that we continue to be able to pay people working on pretix
full-time.
We believe our model creates a good balance between ensuring pretix is available freely as well as protecting our
business interests. Unlike licenses chosen by other projects recently, such as the Server-Side Public License, our
choice does not restrict using pretix for any possible use case, it just sets a few rules that you have to play by
if you do.
What do I need to do if I use pretix unmodified?
------------------------------------------------
If you use pretix without any modifications or plugins, you can use it for whatever you want, as long as you keep
all copyright notices (including the link to pretix at the bottom of the site) intact.
You are also allowed to make copies of the unmodified source code and distribute them to others as long as you keep
all copyright and license information intact.
If you install **plugins**, you must follow the same terms as when using a **modified** version (see below).
What do I need to do if I modify pretix?
----------------------------------------
If you want to modify pretix, you have the right to do so. However, you need to follow the following rules:
* If you **run it for your own events** (events run by you or your company as well as companies from the same
corporate groups) our additional permission allows you to do so **without needing to share your source code
modifications** as long as you keep the link to pretix at the bottom of the site intact.
* If you **run it for others**, for example as part of a Software-as-a-Service offering or a managed hosting service
you **must** make the source code **including all your modifications and all installed plugins** available under the
same license as pretix to every visitor of your site. You need to do so in a prominent place such as a link at the bottom of the
site. You also **must** keep the existing link intact.
You **may not** add additional restrictions on the result as a whole. You **may** add additional permissions, but
only on the parts you added. You **must** make clear which changes you made and you must not give the impression that
your modified version is an official version of pretix.
* If you **distribute** the modified version, for example as a source code or software package, you **must** license it
under the AGPL license with the same additional terms. You **may not** add additional restrictions on the result as a
whole. You **may** add additional permissions, but only on the parts you added. You **must** make clear which changes
you made and you must not give the impression that your modified version is an official version of pretix.
Does the AGPL copyleft mechanism extend to plugins?
---------------------------------------------------
Yes. pretix plugins are tightly integrated with pretix, so when running pretix together with a plugin in the same
environment they form a `combined work`_ and the copyleft mechanism of AGPL applies.
Can I create proprietary or secret plugins?
-------------------------------------------
Yes, you can create a proprietary or secret plugin, but it may only ever be **used** in an environment that is covered
by the additional permission from our license. As soon as the plugin is installed in an installation that is not covered
by our additional permission (e.g. when it is used in a SaaS environment) or covered by an active pretix Enterprise
license it **must** be released to the visitors of the site under the same license as pretix (like a modified version
of pretix).
What licenses can plugins use?
------------------------------
Technically, you can distribute a plugin under any free or proprietary license as long as it is distributed separately.
However, once it is either **distributed together with pretix or used in an environment not covered by our
additional permission** or an active pretix Enterprise license, you **must** release it to all recipients of the
distribution or all visitors of your site under the same license as pretix (like a modified version of pretix).
If you release a plugin publicly, it is therefore most practical to use a license that is `compatible to AGPL`_.
This includes most open source licenses such as AGPL, GPL, Apache, 3-clause BSD or MIT.
Note however that when you license a plugin with pure AGPL, it will be incompatible with our additional permission.
Therefore, if you want to use an AGPL-licensed plugin, you'll need to publish the source code of **all** your plugins
under AGPL terms **even if you only use it for your own events**. A plugin would add its `own additional permission`_
to its license to allow combining it with pretix for this use case.
To make things less complicated, if you want to distribute a plugin freely, we therefore recommend distributing the
plugin under **Apache License 2.0**, like we do for most plugins we distribute as open source.
What do I need to do if I want to contribute my changes back?
-------------------------------------------------------------
In order to retain the possibility for us to offer pretix in a dual licensing model, we unfortunately need you to sign
a Contributor License Agreement (CLA) that gives us permission to use your contribution in all present and future
distributions of pretix. We know the bureaucracy sucks. Sorry.
What if I want to re-use a minor part of pretix in my project?
--------------------------------------------------------------
This is the main part we dislike about AGPL: If you see a specific thing in pretix that you'd like to use in another
project, you'll need to distribute your other project under AGPL terms as well which is often not practical.
In this case, feel free to get in touch with us! We're happy to grant you special permission or pull the component
out into a separately, permissively licensed repository. We already did that with `django-hierarkey`_ and
`django-i18nfield`_ which have previously been parts of pretix.
What can I use the name "pretix" for?
-------------------------------------
The name pretix is a registered trademark by rami.io GmbH.
* You **may** use it to **indicate copyright**, such as in the "powered by pretix" or "based on pretix" line, or when
indicating that a distribution is based on pretix.
* You **may** use it to **indicate compatibility**, for example you are allowed to name your plugin "<name> for pretix"
or you may state that an external service is compatible with pretix.
* You **may not** give the impression that your modified version, plugin or compatible service is official or authorized
by rami.io GmbH or pretix unless we specifically allowed you to do so.
* You **may not** use it to name your modified version of pretix. End-users must be able to easily identify whether
a version of pretix is distributed by us.
* You **may not** use any variations of the name, such as "MyPretix".
.. _license file: https://github.com/pretix/pretix/blob/master/LICENSE
.. _GNU Affero General Public License 3: https://www.gnu.org/licenses/agpl-3.0.en.html
.. _compatible to AGPL: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses
.. _Open Source Definition: https://opensource.org/osd
.. _combined work: https://www.gnu.org/licenses/gpl-faq.html#GPLPlugins
.. _own additional permission: https://www.gnu.org/licenses/gpl-faq.html#GPLIncompatibleLibs
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
.. _django-i18nfield: https://github.com/raphaelm/django-i18nfield

View File

@@ -46,12 +46,12 @@ dependencies = [
"django-hijack==3.7.*",
"django-i18nfield==1.10.*",
"django-libsass==0.9",
"django-localflavor==4.0",
"django-localflavor==5.0",
"django-markup",
"django-oauth-toolkit==2.3.*",
"django-otp==1.6.*",
"django-phonenumber-field==7.3.*",
"django-redis==5.4.*",
"django-redis==6.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.6.*",
"djangorestframework==3.16.*",
@@ -67,7 +67,7 @@ dependencies = [
"markdown==3.8", # 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.*",
"oauthlib==3.3.*",
"openpyxl==3.1.*",
"packaging",
"paypalrestsdk==1.13.*",
@@ -88,10 +88,10 @@ dependencies = [
"pytz-deprecation-shim==0.1.*",
"pyuca",
"qrcode==8.2",
"redis==5.2.*",
"redis==6.2.*",
"reportlab==4.4.*",
"requests==2.31.*",
"sentry-sdk==2.29.*",
"sentry-sdk==2.30.*",
"sepaxml==2.6.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -110,7 +110,7 @@ dev = [
"aiohttp==3.12.*",
"coverage",
"coveralls",
"fakeredis==2.26.*",
"fakeredis==2.30.*",
"flake8==7.2.*",
"freezegun",
"isort==6.0.*",

View File

@@ -23,7 +23,7 @@ import json
from django.db.models import prefetch_related_objects
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError
class AsymmetricField(serializers.Field):
@@ -132,6 +132,136 @@ class SalesChannelMigrationMixin:
s.identifier for s in
self.organizer.sales_channels.all()
])
else:
elif "limit_sales_channels" in value:
value["sales_channels"] = value["limit_sales_channels"]
return value
class ConfigurableSerializerMixin:
expand_fields = {}
def get_exclude_requests(self):
if hasattr(self, "initial_data"):
# Do not support include requests when the serializer is used for writing
# TODO: think about this
return set()
if getattr(self, "parent", None):
# Field selection is always handled by top-level serializer
return set()
if 'exclude' in self.context:
return self.context['exclude']
elif 'request' in self.context:
return self.context['request'].query_params.getlist('exclude')
raise TypeError("Could not discover list of fields to exclude")
def get_include_requests(self):
if hasattr(self, "initial_data"):
# Do not support include requests when the serializer is used for writing
# TODO: think about this
return set()
if getattr(self, "parent", None):
# Field selection is always handled by top-level serializer
return set()
if 'include' in self.context:
return self.context['include']
elif 'request' in self.context:
return self.context['request'].query_params.getlist('include')
raise TypeError("Could not discover list of fields to include")
def get_expand_requests(self):
if hasattr(self, "initial_data"):
# Do not support expand requests when the serializer is used for writing
# TODO: think about this
return set()
if getattr(self, "parent", None):
# Field selection is always handled by top-level serializer
return set()
if 'expand' in self.context:
return self.context['expand']
elif 'request' in self.context:
return self.context['request'].query_params.getlist('expand')
raise TypeError("Could not discover list of fields to expand")
def _exclude_field(self, serializer, path):
if path[0] not in serializer.fields:
return # field does not exist, nothing to do
if len(path) == 1:
del serializer.fields[path[0]]
elif len(path) >= 2 and hasattr(serializer.fields[path[0]], "child"):
self._exclude_field(serializer.fields[path[0]].child, path[1:])
elif len(path) >= 2 and isinstance(serializer.fields[path[0]], serializers.Serializer):
self._exclude_field(serializer.fields[path[0]], path[1:])
def _filter_fields_to_included(self, serializer, includes):
any_field_remaining = False
for fname, field in list(serializer.fields.items()):
if fname in includes:
any_field_remaining = True
continue
elif hasattr(field, 'child'): # Nested list serializers
child_includes = {i.removeprefix(f'{fname}.') for i in includes if i.startswith(f'{fname}.')}
if child_includes and self._filter_fields_to_included(field.child, child_includes):
any_field_remaining = True
continue
serializer.fields.pop(fname)
elif isinstance(field, serializers.Serializer): # Nested serializers
child_includes = {i.removeprefix(f'{fname}.') for i in includes if i.startswith(f'{fname}.')}
if child_includes and self._filter_fields_to_included(field, child_includes):
any_field_remaining = True
continue
serializer.fields.pop(fname)
else:
serializer.fields.pop(fname)
return any_field_remaining
def _expand_field(self, serializer, path, original_field):
if path[0] not in serializer.fields or not self.is_field_expandable(original_field):
return False # field does not exist, nothing to do
if len(path) == 1:
serializer.fields[path[0]] = self.get_expand_serializer(original_field)
return True
elif len(path) >= 2 and hasattr(serializer.fields[path[0]], "child"):
return self._expand_field(serializer.fields[path[0]].child, path[1:], original_field)
elif len(path) >= 2 and isinstance(serializer.fields[path[0]], serializers.Serializer):
return self._expand_field(serializer.fields[path[0]], path[1:], original_field)
def is_field_expandable(self, field):
return field in self.expand_fields
def get_expand_serializer(self, field):
from pretix.base.models import Device, TeamAPIToken
ef = self.expand_fields[field]
if "permission" in ef:
request = self.context["request"]
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if not perm_holder.has_event_permission(request.organizer, request.event, ef["permission"], request=request):
raise PermissionDenied(f"No permission to expand field {field}")
if hasattr(self, "instance") and "prefetch" in ef:
for prefetch in ef["prefetch"]:
prefetch_related_objects(
self.instance if hasattr(self.instance, '__iter__') else [self.instance],
prefetch
)
return ef["serializer"](
read_only=True,
context=self.context,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
expanded = False
for expand in sorted(list(self.get_expand_requests())):
expanded = self._expand_field(self, expand.split('.'), expand) or expanded
includes = set(self.get_include_requests())
if includes:
self._filter_fields_to_included(self, includes)
for exclude_field in self.get_exclude_requests():
self._exclude_field(self, exclude_field.split('.'))

View File

@@ -23,15 +23,19 @@ from django.utils.translation import gettext as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers import ConfigurableSerializerMixin
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList
class CheckinListSerializer(I18nAwareModelSerializer):
class CheckinListSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer):
checkin_count = serializers.IntegerField(read_only=True)
position_count = serializers.IntegerField(read_only=True)
expand_fields = {
"subevent": SubEventSerializer,
}
class Meta:
model = CheckinList
@@ -42,17 +46,6 @@ class CheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True)
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')
if p[0] in self.fields:
if len(p) == 1:
del self.fields[p[0]]
elif len(p) == 2:
self.fields[p[0]].child.fields.pop(p[1])
def validate(self, data):
data = super().validate(data)
event = self.context['event']

View File

@@ -48,7 +48,8 @@ from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import (
CompatibleJSONField, SalesChannelMigrationMixin,
CompatibleJSONField, ConfigurableSerializerMixin,
SalesChannelMigrationMixin,
)
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer
@@ -167,7 +168,7 @@ class ValidKeysField(Field):
}
class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class EventSerializer(SalesChannelMigrationMixin, ConfigurableSerializerMixin, I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
item_meta_properties = MetaPropertyField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
@@ -198,10 +199,11 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not hasattr(self.context['request'], 'event'):
self.fields.pop('valid_keys')
self.fields.pop('valid_keys', None)
if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET:
self.fields.pop('best_availability_state')
self.fields['limit_sales_channels'].child_relation.queryset = self.context['organizer'].sales_channels.all()
self.fields.pop('best_availability_state', None)
if 'limit_sales_channels' in self.fields:
self.fields['limit_sales_channels'].child_relation.queryset = self.context['organizer'].sales_channels.all()
def validate(self, data):
data = super().validate(data)
@@ -483,7 +485,7 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
fields = ('variation', 'price', 'disabled', 'available_from', 'available_until')
class SubEventSerializer(I18nAwareModelSerializer):
class SubEventSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
@@ -502,7 +504,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET:
self.fields.pop('best_availability_state')
self.fields.pop('best_availability_state', None)
def validate(self, data):
data = super().validate(data)

View File

@@ -42,8 +42,10 @@ from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers import (
ConfigurableSerializerMixin, SalesChannelMigrationMixin,
)
from pretix.api.serializers.event import MetaDataField, TaxRuleSerializer
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
@@ -246,7 +248,29 @@ class ItemTaxRateField(serializers.Field):
return str(Decimal('0.00'))
class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta:
model = ItemCategory
fields = (
'id', 'name', 'internal_name', 'description', 'position',
'is_addon', 'cross_selling_mode',
'cross_selling_condition', 'cross_selling_match_products'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('is_addon') and full_data.get('cross_selling_mode'):
raise ValidationError('is_addon and cross_selling_mode are mutually exclusive')
return data
class ItemSerializer(SalesChannelMigrationMixin, ConfigurableSerializerMixin, I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False)
@@ -262,6 +286,16 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
allow_empty=True,
many=True,
)
expand_fields = {
"category": {
"serializer": ItemCategorySerializer,
"prefetch": ["category"],
},
"tax_rule": {
"serializer": TaxRuleSerializer,
"prefetch": ["tax_rule"],
},
}
class Meta:
model = Item
@@ -284,13 +318,18 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['default_price'].allow_null = False
self.fields['default_price'].required = True
if 'default_price' in self.fields:
self.fields['default_price'].allow_null = False
self.fields['default_price'].required = True
if not self.read_only:
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
self.fields['variations'].child.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
if 'require_membership_types' in self.fields:
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
if 'grant_membership_type' in self.fields:
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
if 'limit_sales_channels' in self.fields:
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
if 'variations' in self.fields and 'limit_sales_channels' in self.fields['variations'].child.fields:
self.fields['variations'].child.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
def validate(self, data):
data = super().validate(data)
@@ -437,28 +476,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
return item
class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta:
model = ItemCategory
fields = (
'id', 'name', 'internal_name', 'description', 'position',
'is_addon', 'cross_selling_mode',
'cross_selling_condition', 'cross_selling_match_products'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('is_addon') and full_data.get('cross_selling_mode'):
raise ValidationError('is_addon and cross_selling_mode are mutually exclusive')
return data
class QuestionOptionSerializer(I18nAwareModelSerializer):
identifier = serializers.CharField(allow_null=True)

View File

@@ -40,12 +40,15 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers import (
CompatibleJSONField, ConfigurableSerializerMixin,
)
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
)
from pretix.api.serializers.voucher import VoucherSerializer
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
@@ -175,7 +178,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
def to_representation(self, instance):
r = super().to_representation(instance)
if r['answer'].startswith('file://') and instance.orderposition:
if r.get('answer') and r.get('answer').startswith('file://') and instance.orderposition:
r['answer'] = reverse('api-v1:orderposition-answer', kwargs={
'organizer': instance.orderposition.order.event.organizer.slug,
'event': instance.orderposition.order.event.slug,
@@ -757,7 +760,7 @@ class OrderPluginDataField(serializers.Field):
return d
class OrderSerializer(I18nAwareModelSerializer):
class OrderSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True)
@@ -775,6 +778,39 @@ class OrderSerializer(I18nAwareModelSerializer):
required=False,
)
plugin_data = OrderPluginDataField(source='*', allow_null=True, read_only=True)
expand_fields = {
"positions.voucher": {
"serializer": VoucherSerializer,
"permission": "can_view_vouchers",
"prefetch": ["positions__voucher"],
},
"positions.item": {
"serializer": ItemSerializer,
"prefetch": [
"positions__item",
"positions__item__addons",
"positions__item__bundles",
"positions__item__meta_values",
"positions__item__variations",
"positions__item__tax_rule",
],
},
"positions.variation": {
"serializer": ItemSerializer,
"prefetch": ["positions__variation", "positions__variation__meta_values"],
},
"positions.subevent": {
"serializer": SubEventSerializer,
"prefetch": [
"positions__subevent",
"positions__subevent__event",
"positions__subevent__subeventitem_set",
"positions__subevent__subeventitemvariation_set",
"positions__subevent__seat_category_mappings",
"positions__subevent__meta_values",
],
},
}
class Meta:
model = Order
@@ -793,47 +829,14 @@ class OrderSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "organizer" in self.context:
self.fields["sales_channel"].queryset = self.context["organizer"].sales_channels.all()
else:
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
if not self.context['pdf_data']:
if "sales_channel" in self.fields:
if "organizer" in self.context:
self.fields["sales_channel"].queryset = self.context["organizer"].sales_channels.all()
else:
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
if not self.context['pdf_data'] and "positions" in self.fields:
self.fields['positions'].child.fields.pop('pdf_data', None)
includes = set(self.context['include'])
if includes:
for fname, field in list(self.fields.items()):
if fname in includes:
continue
elif hasattr(field, 'child'): # Nested list serializers
found_any = False
for childfname, childfield in list(field.child.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.child.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
elif isinstance(field, serializers.Serializer): # Nested serializers
found_any = False
for childfname, childfield in list(field.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
else:
self.fields.pop(fname)
for exclude_field in self.context['exclude']:
p = exclude_field.split('.')
if p[0] in self.fields:
if len(p) == 1:
del self.fields[p[0]]
elif len(p) == 2:
self.fields[p[0]].child.fields.pop(p[1])
def validate_locale(self, l):
if l not in set(k for k in self.instance.event.settings.locales):
raise ValidationError('"{}" is not a supported locale for this event.'.format(l))
@@ -1600,7 +1603,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
self.context['event'].currency)
is_split_taxes = fee_data.pop('_split_taxes_like_products', False)
if is_split_taxes:
if is_split_taxes and order.total:
d = defaultdict(lambda: Decimal('0.00'))
trz = TaxRule.zero()
for p in pos_map.values():

View File

@@ -31,7 +31,7 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.auth.devicesecurity import get_all_security_profiles
from pretix.api.serializers import AsymmetricField
from pretix.api.serializers import AsymmetricField, ConfigurableSerializerMixin
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from pretix.api.serializers.settings import SettingsSerializer
@@ -51,7 +51,7 @@ from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__)
class OrganizerSerializer(I18nAwareModelSerializer):
class OrganizerSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer):
public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True)
def get_organizer_url(self, organizer):

View File

@@ -121,6 +121,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['request'] = self.request
return ctx
def perform_update(self, serializer):
@@ -485,8 +486,17 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
with scopes_disabled():
class QuotaFilter(FilterSet):
items__in = NumberInFilter(
field_name='items__id',
lookup_expr='in',
)
class Meta:
model = Quota
fields = {
@@ -508,7 +518,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
return self.request.event.quotas.all()
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
queryset = self.filter_queryset(self.get_queryset()).distinct()
page = self.paginate_queryset(queryset)

View File

@@ -308,7 +308,10 @@ class WrappedPhonePrefixSelect(Select):
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
super().__init__(choices=choices, attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
super().__init__(choices=choices, attrs={
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
'autocomplete': 'tel-country-code',
})
def render(self, name, value, *args, **kwargs):
return super().render(name, value or self.initial, *args, **kwargs)
@@ -331,11 +334,11 @@ class WrappedPhonePrefixSelect(Select):
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def __init__(self, attrs=None, initial=None):
attrs = {
'aria-label': pgettext_lazy('phonenumber', 'Phone number (without international area code)')
}
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput(attrs=attrs))
super(PhoneNumberPrefixWidget, self).__init__(widgets, attrs)
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput(attrs={
'aria-label': pgettext_lazy('phonenumber', 'Phone number (without international area code)'),
'autocomplete': 'tel-national',
}))
super(PhoneNumberPrefixWidget, self).__init__(widgets)
def render(self, name, value, attrs=None, renderer=None):
output = super().render(name, value, attrs, renderer)
@@ -992,6 +995,13 @@ class BaseQuestionsForm(forms.Form):
value.initial = data.get('question_form_data', {}).get(key)
for k, v in self.fields.items():
if isinstance(v.widget, forms.MultiWidget):
for w in v.widget.widgets:
autocomplete = w.attrs.get('autocomplete', '')
if autocomplete.strip() == "off":
w.attrs['autocomplete'] = 'off'
else:
w.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
autocomplete = v.widget.attrs.get('autocomplete', '')
if autocomplete.strip() == "off":

View File

@@ -26,7 +26,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import (
Discount, Item, ItemCategory, Order, Question, Quota, SubEvent, TaxRule,
Voucher,
Voucher, WaitingListEntry,
)
from .logentrytype_registry import ( # noqa
@@ -145,3 +145,15 @@ class TaxRuleLogEntryType(EventLogEntryType):
object_link_viewname = 'control:event.settings.tax.edit'
object_link_argname = 'rule'
content_type = TaxRule
class WaitingListEntryLogEntryType(EventLogEntryType):
object_link_wrapper = _('{val}')
object_link_viewname = 'control:event.orders.waitinglist'
content_type = WaitingListEntry
def get_object_link_info(self, logentry) -> Optional[dict]:
info = super().get_object_link_info(logentry)
if info and 'href' in info:
info['href'] += '?status=a&entry=' + str(logentry.content_object.pk)
return info

View File

@@ -1084,6 +1084,7 @@ class Event(EventMixin, LoggedModel):
s.product = item_map[s.product_id]
s.save(force_insert=True)
valid_sales_channel_identifers = set(self.organizer.sales_channels.values_list("identifier", flat=True))
skip_settings = (
'ticket_secrets_pretix_sig1_pubkey',
'ticket_secrets_pretix_sig1_privkey',
@@ -1119,6 +1120,11 @@ class Event(EventMixin, LoggedModel):
settings_to_save.append(s)
except ValueError:
pass
elif s.key.startswith('payment_') and s.key.endswith('__restrict_to_sales_channels'):
data = other.settings._unserialize(s.value, as_type=list)
data = [ident for ident in data if ident in valid_sales_channel_identifers]
s.value = other.settings._serialize(data)
settings_to_save.append(s)
else:
settings_to_save.append(s)
other.settings._objects.bulk_create(settings_to_save)

View File

@@ -793,7 +793,7 @@ class Item(LoggedModel):
class Meta:
verbose_name = _("Product")
verbose_name_plural = _("Products")
ordering = ("category__position", "category", "position")
ordering = ("category__position", "category", "position", "pk")
def __str__(self):
return str(self.internal_name or self.name)

View File

@@ -218,7 +218,6 @@ class WaitingListEntry(LoggedModel):
'waitinglistentry': self.pk,
'subevent': self.subevent.pk if self.subevent else None,
}, user=user, auth=auth)
self.log_action('pretix.event.orders.waitinglist.voucher_assigned', user=user, auth=auth)
self.voucher = v
self.save()
@@ -234,6 +233,7 @@ class WaitingListEntry(LoggedModel):
),
user=user,
auth=auth,
log_entry_type='pretix.event.orders.waitinglist.voucher_assigned',
)
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],

View File

@@ -96,12 +96,19 @@ class SendMailException(Exception):
def clean_sender_name(sender_name: str) -> str:
# Even though we try to properly escape sender names, some characters seem to cause problems when the escaping
# fails due to some forwardings, etc.
# Emails with @ in their sender name are rejected by some mailservers (e.g. Microsoft) because it looks like
# a phishing attempt.
sender_name = sender_name.replace("@", " ")
# Emails with : in their sender name are treated by Microsoft like emails with no From header at all, leading
# to a higher spam likelihood.
sender_name = sender_name.replace(":", " ")
# Emails with , in their sender name look like multiple senders
sender_name = sender_name.replace(",", "")
# Emails with " in their sender name could be escaped, but somehow create issues in reality
sender_name = sender_name.replace("\"", "")
# Emails with excessively long sender names are rejected by some mailservers
if len(sender_name) > 75:

View File

@@ -665,9 +665,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 += [
self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([
(a, {"title": a, "data": v}) for a, v in get_fonts(self.event, pdf_support_required=False).items()
]
], key=lambda a: a[0])
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
self.virtual_keys = []

View File

@@ -50,7 +50,7 @@ from pretix.base.logentrytypes import (
DiscountLogEntryType, EventLogEntryType, ItemCategoryLogEntryType,
ItemLogEntryType, LogEntryType, OrderLogEntryType, QuestionLogEntryType,
QuotaLogEntryType, TaxRuleLogEntryType, VoucherLogEntryType,
log_entry_types,
WaitingListEntryLogEntryType, log_entry_types,
)
from pretix.base.models import (
Checkin, CheckinList, Event, ItemVariation, LogEntry, OrderPosition,
@@ -697,11 +697,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'the last request was less than 24 hours ago.'),
'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'),
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'), # legacy
'pretix.event.orders.waitinglist.voucher_assigned': _('A voucher has been sent to a person on the waiting list.'),
'pretix.event.orders.waitinglist.deleted': _('An entry has been removed from the waiting list.'),
'pretix.event.order.waitinglist.transferred': _('An entry has been transferred to another waiting list.'), # legacy
'pretix.event.orders.waitinglist.changed': _('An entry has been changed on the waiting list.'),
'pretix.event.orders.waitinglist.added': _('An entry has been added to the waiting list.'),
'pretix.team.created': _('The team has been created.'),
'pretix.team.changed': _('The team settings have been changed.'),
'pretix.team.deleted': _('The team has been deleted.'),
@@ -903,3 +899,13 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
datetime=dt_formatted,
list=checkin_list
)
@log_entry_types.new_from_dict({
'pretix.event.orders.waitinglist.voucher_assigned': _('A voucher has been sent to a person on the waiting list.'),
'pretix.event.orders.waitinglist.deleted': _('An entry has been removed from the waiting list.'),
'pretix.event.orders.waitinglist.changed': _('An entry has been changed on the waiting list.'),
'pretix.event.orders.waitinglist.added': _('An entry has been added to the waiting list.'),
})
class CoreWaitingListEntryLogEntryType(WaitingListEntryLogEntryType):
pass

View File

@@ -247,6 +247,17 @@
{% bootstrap_field sform.show_variations_expanded layout="control" %}
{% bootstrap_field sform.hide_sold_out layout="control" %}
<div data-display-dependency="#id_settings-waiting_list_enabled">
<div data-display-dependency="#id_settings-hide_sold_out">
<div class="alert alert-danger dynamic">
<h4>{% trans "Incompatible settings" %}</h4>
{% blocktrans trimmed %}
Customers won't be able to add themselves to the waiting list, because "Hide all products that are sold out" is enabled.
{% endblocktrans %}
</div>
</div>
</div>
<h4>{% trans "Calendar and list views" context "subevents" %}</h4>
{% if sform.frontpage_subevent_ordering %}
{% bootstrap_field sform.frontpage_subevent_ordering layout="control" %}
@@ -372,6 +383,16 @@
</strong>
</div>
{% bootstrap_field sform.waiting_list_enabled layout="control" %}
<div data-display-dependency="#id_settings-hide_sold_out">
<div data-display-dependency="#id_settings-waiting_list_enabled">
<div class="alert alert-danger dynamic">
<h4>{% trans "Incompatible settings" %}</h4>
{% blocktrans trimmed %}
Customers won't be able to add themselves to the waiting list, because "Hide all products that are sold out" is enabled.
{% endblocktrans %}
</div>
</div>
</div>
{% bootstrap_field sform.waiting_list_auto layout="control" %}
<div class="form-group">
<label class="control-label col-md-3">

View File

@@ -59,7 +59,7 @@
</form>
</div>
<div class="row" id="question-stats">
<div class="row">
{% if not stats %}
<div class="empty-collection col-md-10 col-xs-12">
<p>
@@ -81,7 +81,7 @@
<div class="chart" id="question_chart" data-type="{{ question.type }}">
</div>
<script type="application/json" id="question-chart-data">{{ stats_json|escapejson }}</script>
{{ stats|json_script:"question-chart-data" }}
</div>
<div class="col-md-5 col-xs-12">
<table class="table table-bordered table-hover">
@@ -89,7 +89,8 @@
<tr>
<th>{% trans "Answer" %}</th>
<th class="text-right">{% trans "Count" %}</th>
<th class="text-right">{% trans "Percentage" %}</th>
<th class="text-right">{% trans "% of answers" %}</th>
<th class="text-right">{% trans "% of tickets" %}</th>
</tr>
</thead>
<tbody>
@@ -102,6 +103,7 @@
</td>
<td class="text-right">{{ stat.count }}</td>
<td class="text-right">{{ stat.percentage|floatformat:1 }} %</td>
<td class="text-right">{{ stat.percentage_attendees|floatformat:1 }} %</td>
</tr>
{% endfor %}
</tbody>
@@ -110,6 +112,7 @@
<td><strong>{% trans "Sum" %}</strong></td>
<td class="text-right"><strong>{{ total }}</strong></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>

View File

@@ -5,6 +5,7 @@
{% load captureas %}
{% load static %}
{% load eventsignal %}
{% load dialog %}
{% block title %}{% trans "Change multiple dates" context "subevent" %}{% endblock %}
{% block content %}
<h1>
@@ -182,21 +183,22 @@
</fieldset>
<fieldset>
<legend>{% trans "Quotas" %}</legend>
{% if sampled_quotas|default_if_none:"NONE" == "NONE" %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
You selected a set of dates that currently have different quota setups. You can therefore
not change their quotas in bulk. If you want, you can set up a new set of quotas to
<strong>replace</strong> the quota setup of all selected dates.
{% endblocktrans %}
</div>
{% endif %}
<div class="bulk-edit-field-group">
<div class="bulk-edit-field-group"
{% if sampled_quotas|default_if_none:"NONE" == "NONE" %}
data-confirm-dialog="#confirm-override-quotas"
{% endif %}>
<label class="field-toggle">
<input type="checkbox" name="_bulk" value="__quotas" {% if "__quotas" in bulk_selected %}checked{% endif %}>
{% trans "change" context "form_bulk" %}
</label>
<div class="field-content">
{% if sampled_quotas|default_if_none:"NONE" == "NONE" %}
<div class="alert alert-warning">
{% trans "You selected a set of dates that currently have different quota setups." %}
{% trans "Using this option will <strong>delete all current quotas</strong> from <strong>all selected dates</strong>." %}
</div>
{% endif %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
@@ -271,7 +273,7 @@
<fieldset>
<legend>{% trans "Check-in lists" %}</legend>
{% if sampled_lists|default_if_none:"NONE" == "NONE" %}
<div class="alert alert-warning">
<div class="alert alert-info">
{% blocktrans trimmed %}
You selected a set of dates that currently have different check-in list setups. You can
therefore not change their check-in lists in bulk.
@@ -367,4 +369,17 @@
</button>
</div>
</form>
{% trans "Delete existing quotas" as dialog_title %}
{% trans "Using this option will <strong>delete all current quotas</strong> from <strong>all selected dates</strong>." as dialog_text %}
{% trans "This cannot be reverted. Are you sure to proceed?" as dialog_text2 %}
{% dialog "confirm-override-quotas" dialog_title dialog_text|add:" "|add:dialog_text2 icon="trash" %}
<p class="modal-card-confirm modal-card-confirm-spread">
<button class="btn btn-lg btn-default" value="no">
{% trans "Cancel" %}
</button>
<button class="btn btn-lg btn-danger" value="yes">
{% trans "Proceed" %}
</button>
</p>
{% enddialog %}
{% endblock %}

View File

@@ -64,8 +64,9 @@ from pretix.api.serializers.item import (
)
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemVariation, Order, Question,
QuestionAnswer, QuestionOption, Quota, SeatCategoryMapping, Voucher,
CartPosition, Item, ItemCategory, ItemVariation, Order, OrderPosition,
Question, QuestionAnswer, QuestionOption, Quota, SeatCategoryMapping,
Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
@@ -665,36 +666,41 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
template_name_field = 'question'
def get_answer_statistics(self):
opqs = OrderPosition.objects.filter(
order__event=self.request.event,
)
qs = QuestionAnswer.objects.filter(
question=self.object, orderposition__isnull=False,
orderposition__order__event=self.request.event
)
if self.request.GET.get("subevent", "") != "":
qs = qs.filter(orderposition__subevent=self.request.GET["subevent"])
opqs = opqs.filter(subevent=self.request.GET["subevent"])
s = self.request.GET.get("status", "np")
if s != "":
if s == 'o':
qs = qs.filter(orderposition__order__status=Order.STATUS_PENDING,
orderposition__order__expires__lt=now().replace(hour=0, minute=0, second=0))
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
qs = qs.filter(
Q(orderposition__order__status=Order.STATUS_PAID) |
Q(orderposition__order__status=Order.STATUS_PENDING, orderposition__order__valid_if_pending=True)
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == 'ne':
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
qs = qs.filter(orderposition__order__status=s)
opqs = opqs.filter(order__status=s)
if s not in (Order.STATUS_CANCELED, ""):
qs = qs.filter(orderposition__canceled=False)
opqs = opqs.filter(canceled=False)
if self.request.GET.get("item", "") != "":
i = self.request.GET.get("item", "")
qs = qs.filter(orderposition__item_id__in=(i,))
opqs = opqs.filter(item_id__in=(i,))
qs = qs.filter(orderposition__in=opqs)
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
if self.object.type == Question.TYPE_FILE:
qs = [
@@ -734,6 +740,7 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
total = sum(a['count'] for a in r)
for a in r:
a['percentage'] = (a['count'] / total * 100.) if total else 0
a['percentage_attendees'] = (a['count'] / op_cnt * 100.) if op_cnt else 0
return r, total
def get_context_data(self, **kwargs):
@@ -741,7 +748,6 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
ctx['items'] = self.object.items.all()
stats = self.get_answer_statistics()
ctx['stats'], ctx['total'] = stats
ctx['stats_json'] = json.dumps(stats)
return ctx
def get_object(self, queryset=None) -> Question:

View File

@@ -5,8 +5,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-30 10:35+0000\n"
"PO-Revision-Date: 2025-05-30 11:15+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"PO-Revision-Date: 2025-06-12 17:00+0000\n"
"Last-Translator: Richard Schreiber <schreiber@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/de/"
">\n"
"Language: de\n"
@@ -33401,7 +33401,7 @@ msgstr "Übersicht über die bestellten Produkte"
#: pretix/presale/templates/pretixpresale/event/fragment_cart_box.html:50
msgid "Continue with order process"
msgstr "Mit dem Bestellprozess fortfahren"
msgstr "Fortfahren mit dem Bestellprozess"
#: pretix/presale/templates/pretixpresale/event/fragment_cart_box.html:55
#: pretix/presale/templates/pretixpresale/event/index.html:232

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,8 @@ from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled
from pretix.base.logentrytypes import (
EventLogEntryType, OrderLogEntryType, log_entry_types,
EventLogEntryType, OrderLogEntryType, WaitingListEntryLogEntryType,
log_entry_types,
)
from pretix.base.models import SubEvent
from pretix.base.signals import (
@@ -130,6 +131,11 @@ class SendmailPluginOrderLogEntryType(OrderLogEntryType):
pass
@log_entry_types.new('pretix.plugins.sendmail.waitinglist.email.sent', _('The person on the waiting list received a mass email.'))
class SendmailPluginWaitingListLogEntryType(WaitingListEntryLogEntryType):
pass
@log_entry_types.new('pretix.plugins.sendmail.rule.added', _('An email rule was created'))
@log_entry_types.new('pretix.plugins.sendmail.rule.changed', _('An email rule was updated'))
@log_entry_types.new('pretix.plugins.sendmail.rule.order.email.sent', _('A scheduled email was sent to the order'))

View File

@@ -201,4 +201,5 @@ def send_mails_to_waitinglist(event: Event, user: int, subject: dict, message: d
),
user=user,
attach_cached_files=attachments,
log_entry_type='pretix.plugins.sendmail.waitinglist.email.sent',
)

View File

@@ -342,6 +342,7 @@ class ResetPasswordForm(forms.Form):
}
email = forms.EmailField(
label=_('Email'),
widget=forms.EmailInput(attrs={'autocomplete': 'email'}),
)
def __init__(self, request=None, *args, **kwargs):
@@ -389,12 +390,12 @@ class ChangePasswordForm(forms.Form):
)
password_current = forms.CharField(
label=_('Your current password'),
widget=forms.PasswordInput,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
required=True
)
password = forms.CharField(
label=_('New password'),
widget=forms.PasswordInput,
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
max_length=4096,
required=True
)
@@ -458,7 +459,7 @@ class ChangeInfoForm(forms.ModelForm):
}
password_current = forms.CharField(
label=_('Your current password'),
widget=forms.PasswordInput,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
help_text=_('Only required if you change your email address'),
max_length=4096,
required=False
@@ -472,6 +473,8 @@ class ChangeInfoForm(forms.ModelForm):
self.request = request
super().__init__(*args, **kwargs)
self.fields['email'].widget.attrs['autocomplete'] = 'email'
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=True,

View File

@@ -57,6 +57,8 @@ class WaitingListForm(forms.ModelForm):
event = self.event
self.fields['email'].widget.attrs['autocomplete'] = 'email'
if event.settings.waiting_list_names_asked:
self.fields['name_parts'] = NamePartsFormField(
max_length=255,

View File

@@ -19,9 +19,9 @@
<div class="panel-body questions-form">
{% if form.position.seat %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Seat" %}
</label>
<div class="col-md-3 control-label">
<strong role="heading" aria-level="5">{% trans "Seat" %}</strong>
</div>
<div class="col-md-9 form-control-text">
{% include "icons/seat.svg" with cls="svg-icon" %}
{{ form.position.seat }}
@@ -30,9 +30,9 @@
{% endif %}
{% if form.position.addons.all %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Selected add-ons" %}
</label>
<div class="col-md-3 control-label">
<strong role="heading" aria-level="5">{% trans "Selected add-ons" %}</strong>
</div>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
{% for a in form.position.addons.all %}
@@ -44,13 +44,13 @@
{% endif %}
{% if form.position.subevent %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Date" context "subevent" %}
</label>
<div class="col-md-3 control-label">
<strong role="heading" aria-level="5">{% trans "Date" context "subevent" %}</strong>
</div>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
<p class="addon-list">
{{ form.position.subevent.name }} &middot; {{ form.position.subevent.get_date_range_display_with_times_as_html }}
</ul>
</p>
</div>
</div>
{% endif %}

View File

@@ -95,9 +95,9 @@
{% endif %}
{% if pos.seat %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Seat" %}
</label>
<div class="col-md-3 control-label">
<strong role="heading" aria-level="4">{% trans "Seat" %}</strong>
</div>
<div class="col-md-9 form-control-text">
{% include "icons/seat.svg" with cls="svg-icon" %}
{{ pos.seat }}
@@ -106,9 +106,9 @@
{% endif %}
{% if pos.addons_without_bundled %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Selected add-ons" %}
</label>
<div class="col-md-3 control-label">
<strong role="heading" aria-level="4">{% trans "Selected add-ons" %}</strong>
</div>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
{% regroup pos.addons_without_bundled by item_and_variation as addons_by_itemvar %}
@@ -121,13 +121,13 @@
{% endif %}
{% if pos.subevent %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Date" context "subevent" %}
</label>
<div class="col-md-3 control-label">
<strong role="heading" aria-level="4">{% trans "Date" context "subevent" %}</strong>
</div>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
<p class="addon-list">
{{ pos.subevent.name }} &middot; {{ pos.subevent.get_date_range_display_with_times_as_html }}
</ul>
</p>
</div>
</div>
{% endif %}

View File

@@ -96,8 +96,8 @@
{% if not event.settings.show_variations_expanded %}
<button type="button" data-toggle="variations" class="btn btn-default btn-block js-only"
data-label-alt="{% trans "Hide variants" %}"
aria-expanded="false"
aria-label="{% blocktrans trimmed with item=item.name count=item.available_variations|length %}Show {{count}} variants of {{item}}{% endblocktrans %}">
aria-expanded="false" aria-controls="cp-{{ form.pos.pk }}-item-{{ item.pk }}-variations"
aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-legend">
<i class="fa fa-angle-down collapse-indicator" aria-hidden="true"></i>
<span>{% trans "Show variants" %}</span>
</button>
@@ -105,7 +105,7 @@
</div>
<div class="clearfix"></div>
</div>
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}">
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}" id="cp-{{ form.pos.pk }}-item-{{ item.pk }}-variations">
{% for var in item.available_variations %}
<article aria-labelledby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row-fluid product-row variation"
{% if not item.free_price %}

View File

@@ -1,4 +1,5 @@
{% load i18n %}
{% load icon %}
{% load eventurl %}
{% load daterange %}
{% load safelink %}
@@ -24,7 +25,7 @@
</div>
<div role="rowgroup" class="firstchild-in-panel">
{% for line in cart.positions %}
<div role="row" class="row cart-row {% if hide_prices %}hide-prices{% endif %} {% if download %}has-downloads{% endif %}{% if editable %}editable{% endif %}">
<div role="row" class="row cart-row {% if hide_prices %}hide-prices{% endif %} {% if download %}has-downloads{% endif %}{% if editable %}editable{% endif %}" data-article-id="item-{{ line.item.id }}{% if line.variation %}-{{ line.variation.id }}{% endif %}">
<div role="cell" class="product">
<p>
{% if line.addon_to %}
@@ -506,10 +507,23 @@
</p>
<p>
<button class="btn btn-default" type="submit" id="cart-extend-button" aria-describedby="cart-deadline">
<i class="fa fa-refresh" aria-hidden="true"></i> {% trans "Renew reservation" %}
{% icon "refresh" %} {% trans "Renew reservation" %}
</button>
</p>
</form>
<dialog role="alertdialog" id="cart-extend-confirmation-dialog" class="inline-dialog" aria-labelledby="cart-deadline">
<form method="dialog">
<p>
<button class="btn btn-success" autofocus value="OK">
<span role="img" aria-label="{% trans "OK" %}.">
{% icon "check" %}
</span>
{% trans "Reservation renewed" %}
</button>
</p>
</form>
</dialog>
{% else %}
<p class="sr-only" id="cart-description">{% trans "Overview of your ordered products." %}</p>
{% endif %}

View File

@@ -16,13 +16,13 @@
<div class="form-order-change-main">
{% if position.subevent %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Date" context "subevent" %}
</label>
<div class="col-md-3 control-label">
<strong role="heading" aria-level="4">{% trans "Date" context "subevent" %}</strong>
</div>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
<p class="addon-list">
{{ position.subevent.name }} &middot; {{ position.subevent.get_date_range_display_with_times_as_html }}
</ul>
</p>
</div>
</div>
{% endif %}

View File

@@ -101,8 +101,8 @@
{% endif %}
<button type="button" data-toggle="variations" class="btn btn-default btn-block js-only"
data-label-alt="{% trans "Hide variants" %}"
aria-expanded="false"
aria-label="{% blocktrans trimmed with item=item.name count=item.available_variations|length %}Show {{count}} variants of {{ item }}{% endblocktrans %}">
aria-expanded="false" aria-controls="{{ form_prefix }}item-{{ item.pk }}-variations"
aria-describedby="{{ form_prefix }}item-{{ item.pk }}-legend">
<i class="fa fa-angle-down collapse-indicator" aria-hidden="true"></i>
<span>{% trans "Show variants" %}</span>
</button>
@@ -110,7 +110,7 @@
</div>
<div class="clearfix"></div>
</div>
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}">
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}" id="{{ form_prefix }}item-{{ item.pk }}-variations">
{% for var in item.available_variations %}
<article aria-labelledby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row product-row variation" id="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}"
{% if not item.free_price %}

View File

@@ -15,8 +15,9 @@
<div class="input-group{% if "voucher_invalid" in request.GET %} has-error{% endif %}">
<span class="input-group-addon"><i class="fa fa-ticket fa-fw" aria-hidden="true"></i></span>
<input type="text" class="form-control{% if "voucher_invalid" in request.GET %} has-error{% endif %}" name="voucher" id="voucher"
{% if "voucher_invalid" in request.GET %} aria-describedby="error-message"{% endif %}
placeholder="{% trans "Voucher code" %}" required="required">
{% if "voucher_invalid" in request.GET %} aria-describedby="error-message"{% endif %}
autocomplete="off"
placeholder="{% trans "Voucher code" %}" required="required">
</div>
</div>
<input type="hidden" name="subevent" value="{{ subevent.id|default_if_none:"" }}" />

View File

@@ -34,13 +34,13 @@
{% endblocktrans %}
{% endif %}
</p>
<p class="help-block">
<p class="help-block" id="add-to-list-description">
{% blocktrans trimmed %}
You will <strong>not</strong> receive a confirmation email after you have been added to the waiting list. We will only contact you once a spot opens up.
{% endblocktrans %}
</p>
<p>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" aria-describedby="add-to-list-description">
{% trans "Add me to the list" %}
</button>
</p>

View File

@@ -48,6 +48,10 @@
<p class="modal-card-confirm"><button class="btn btn-lg btn-primary">{% trans "Renew reservation" %}</button></p>
{% enddialog %}
{% dialog "dialog-cart-extended" "" "" icon="clock-o" alert=true %}
<p class="modal-card-confirm"><button class="btn btn-lg btn-primary">{% trans "OK" %}</button></p>
{% enddialog %}
<dialog id="lightbox-dialog" class="modal-card" role="alertdialog" aria-labelledby="lightbox-label">
<form method="dialog" class="modal-card-inner form-horizontal">
<div class="modal-card-content">

View File

@@ -64,6 +64,7 @@ from pretix.base.models import (
Event, EventMetaValue, Organizer, Quota, SubEvent, SubEventMetaValue,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import time_machine_now
from pretix.helpers.compat import date_fromisocalendar
from pretix.helpers.daterange import daterange
from pretix.helpers.formats.en.formats import (
@@ -228,7 +229,7 @@ class EventListMixin:
def _set_month_to_next_subevent(self):
tz = self.request.event.timezone
now_dt = now()
now_dt = time_machine_now()
next_sev = self.request.event.subevents.using(settings.DATABASE_REPLICA).annotate(
effective_date=Case(
When(date_from__lt=now_dt, date_to__isnull=False, date_to__gte=now_dt, then=Value(now_dt)),
@@ -245,8 +246,8 @@ class EventListMixin:
self.year = datetime_from.astimezone(tz).year
self.month = datetime_from.astimezone(tz).month
else:
self.year = now().year
self.month = now().month
self.year = now_dt.year
self.month = now_dt.month
def _set_month_to_next_event(self):
now_dt = now()
@@ -296,7 +297,7 @@ class EventListMixin:
try:
date = dateutil.parser.isoparse(self.request.GET.get('date')).date()
except ValueError:
date = now().date()
date = time_machine_now().date()
self.year = date.year
self.month = date.month
else:
@@ -306,7 +307,7 @@ class EventListMixin:
self._set_month_to_next_event()
def _set_week_to_next_subevent(self):
now_dt = now()
now_dt = time_machine_now()
tz = self.request.event.timezone
next_sev = self.request.event.subevents.using(settings.DATABASE_REPLICA).annotate(
effective_date=Case(
@@ -324,8 +325,8 @@ class EventListMixin:
self.year = datetime_from.astimezone(tz).isocalendar()[0]
self.week = datetime_from.astimezone(tz).isocalendar()[1]
else:
self.year = now().isocalendar()[0]
self.week = now().isocalendar()[1]
self.year = now_dt.isocalendar()[0]
self.week = now_dt.isocalendar()[1]
def _set_week_to_next_event(self):
now_dt = now()
@@ -375,7 +376,7 @@ class EventListMixin:
try:
iso = dateutil.parser.isoparse(self.request.GET.get('date')).isocalendar()
except ValueError:
iso = now().isocalendar()
iso = time_machine_now().isocalendar()
self.year = iso[0]
self.week = iso[1]
else:

View File

@@ -1887,9 +1887,10 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
@@ -5009,9 +5010,9 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"optional": true,
"requires": {
"balanced-match": "^1.0.0",

View File

@@ -117,7 +117,7 @@ setup_collapsible_details = function (el) {
el.find("article button[data-toggle=variations]").click(function (e) {
var $button = $(this);
var $details = $button.closest("article");
var $detailsNotSummary = $(".variations", $details);
var $detailsNotSummary = $button.attr("aria-controls") ? $('#' + $button.attr("aria-controls")) : $(".variations", $details);
var isOpen = !$detailsNotSummary.prop("hidden");
if ($detailsNotSummary.is(':animated')) {
e.preventDefault();
@@ -125,7 +125,7 @@ setup_collapsible_details = function (el) {
}
var altLabel = $button.attr("data-label-alt");
$button.attr("data-label-alt", $button.text());
$button.attr("data-label-alt", $button.text().trim());
$button.find("span").text(altLabel);
$button.attr("aria-expanded", !isOpen);

View File

@@ -1,3 +1,10 @@
dialog.inline-dialog {
position: static;
padding: 0;
margin: 0;
border: none;
}
/* Modal dialogs using HTML5 dialog tags for accessibility */
dialog.modal-card {
border: none;

View File

@@ -648,18 +648,47 @@ var form_handlers = function (el) {
var $checkbox = $(this).find("input[type=checkbox][name=_bulk]");
var $content = $(this).find(".field-content");
var $fields = $content.find("input, select, textarea, button");
var $dialog = $(this).attr("data-confirm-dialog") ? $($(this).attr("data-confirm-dialog")) : null;
var warningShown = false;
if ($dialog) {
$dialog.on("close", function () {
if ($dialog.get(0).returnValue === "yes") {
$checkbox.prop("checked", true);
} else {
$checkbox.prop("checked", false);
warningShown = false;
}
update();
});
}
var update = function () {
var isChecked = $checkbox.prop("checked");
$content.toggleClass("enabled", isChecked);
$fields.attr("tabIndex", isChecked ? 0 : -1);
}
$content.on("focusin change click", function () {
if ($checkbox.prop("checked")) return;
$checkbox.prop("checked", true);
update();
if ($dialog && !warningShown) {
warningShown = true;
$dialog.get(0).showModal();
} else {
$checkbox.prop("checked", true);
update();
}
});
$checkbox.on('change', update)
$checkbox.on('change', function () {
var isChecked = $checkbox.prop("checked");
if (isChecked && $dialog && !warningShown) {
warningShown = true;
$dialog.get(0).showModal();
} else if (!isChecked) {
warningShown = false;
}
update();
})
update();
});

View File

@@ -1,23 +1,22 @@
/*global $, Morris, gettext*/
$(function () {
// Question view
if (!$("#question-stats").length) {
if (!$("#question_chart").length) {
return;
}
$(".chart").css("height", "250px");
var data_type = $("#question_chart").attr("data-type"),
data = JSON.parse($("#question-chart-data").html()),
data = JSON.parse($("#question-chart-data").text() || "[]"),
others_sum = 0,
max_num = 8;
for (var i in data) {
data[i].value = data[i].count;
data[i].label = data[i].answer;
if (data[i].label.length > 20) {
data[i].label = data[i].label.substring(0, 20) + '…';
data = data.map(function (d) {
return {
'value': d.count,
'label': d.answer.length > 20 ? d.answer.substring(0, 20) + '…' : d.answer,
}
}
});
if (data_type == 'N') {
// Sort
@@ -36,7 +35,7 @@ $(function () {
// Limit shown options
if (data.length > max_num) {
for (var i = max_num; i < data.length; i++) {
others_sum += data[i].count;
others_sum += data[i].value;
}
data = data.slice(0, max_num);
data.push({'value': others_sum, 'label': gettext('Others')});
@@ -78,7 +77,7 @@ $(function () {
data: data,
resize: true,
xkey: 'label',
ykeys: ['count'],
ykeys: ['value'],
labels: [gettext('Count')]
});
}

View File

@@ -20,7 +20,7 @@ $(function () {
.attr("href", "#" + tid)
.text($fieldset.find("legend").text())
.appendTo($tabli);
if ($fieldset.find(".has-error, .alert-danger").length > 0) {
if ($fieldset.find(".has-error, .alert-danger:not(.dynamic)").length > 0) {
$tablink.append(" ");
$tablink.append($("<span>").addClass("fa fa-warning text-danger"));
if (preselect === null) {

View File

@@ -5,7 +5,6 @@ var cart = {
_deadline_call: 0,
_time_offset: 0,
_prev_diff_minutes: 0,
_renewed_message: "",
_get_now: function () {
return moment().add(cart._time_offset, 'ms');
@@ -59,7 +58,6 @@ var cart = {
$("#cart-deadline").text(gettext("Your cart is about to expire."))
} else {
$("#cart-deadline").text(
cart._renewed_message + " " +
ngettext(
"The items in your cart are reserved for you for one minute.",
"The items in your cart are reserved for you for {num} minutes.",
@@ -74,7 +72,6 @@ var cart = {
pad(diff_minutes.toString(), 2) + ':' + pad(diff_seconds.toString(), 2)
);
cart._renewed_message = "";
cart._deadline_timeout = window.setTimeout(cart.draw_deadline, 500);
}
var already_expired = diff_total_seconds <= 0;
@@ -112,7 +109,6 @@ var cart = {
}
cart._deadline_timeout = null;
cart._max_extend = moment(max_extend);
cart._renewed_message = renewed_message || "";
cart.draw_deadline();
}
};
@@ -122,22 +118,41 @@ $(function () {
if ($("#cart-deadline").length) {
cart.init();
$("#cart-extend-confirmation-button").hide().on("blur", function() {
$(this).hide();
});
}
$("#cart-extend-form").on("pretix:async-task-success", function(e, data) {
if (data.success) {
cart.set_deadline(data.expiry, data.max_expiry_extend, data.message);
var cart_panel_heading = $(this).closest(".panel").find(".panel-heading").get(0);
if (cart_panel_heading) {
cart_panel_heading.focus();
}
cart.set_deadline(data.expiry, data.max_expiry_extend);
} else {
alert(data.message);
}
});
// renew-button in cart-panel is clicked, show inline dialog
$("#cart-extend-button").on("click", function() {
$("#cart-extend-form").one("pretix:async-task-success", function(e, data) {
if (data.success) {
document.getElementById("cart-extend-confirmation-dialog").show();
}
});
});
$("#cart-extend-confirmation-dialog").on("keydown", function (e) {
if(e.key === "Escape") {
this.close();
}
});
// renew-button in modal dialog is clicked, show modal dialog
$("#dialog-cart-extend form").submit(function() {
$("#cart-extend-form").submit();
$("#cart-extend-form").one("pretix:async-task-success", function(e, data) {
if (data.success) {
$("#dialog-cart-extended-title").text(data.message);
$("#dialog-cart-extended-description").text($("#cart-deadline").text());
document.getElementById("dialog-cart-extended").showModal();
}
}).submit();
});
$(".toggle-container").each(function() {

View File

@@ -7,4 +7,12 @@ var inIframe = function () {
};
if (inIframe()) {
document.documentElement.classList.add('in-iframe');
try {
window.parent.postMessage({
type: "pretix:widget:title",
title: document.title,
}, "*");
} catch (e) {
console.error("Could not post message to parent.", e);
}
}

View File

@@ -252,7 +252,7 @@ Vue.component('availbox', {
variation: Object
},
mounted: function() {
if (this.$root.itemnum === 1 && !this.$root.has_seating_plan ? 1 : 0) {
if (this.$root.itemnum === 1 && (!this.$root.categories[0].items[0].has_variations || this.$root.categories[0].items[0].variations.length < 2) && !this.$root.has_seating_plan ? 1 : 0) {
this.$refs.quantity.value = 1;
if (this.order_max === 1) {
this.$refs.quantity.checked = true;
@@ -823,7 +823,7 @@ var shared_loading_fragment = (
);
var shared_iframe_fragment = (
'<dialog :class="frameClasses" role="alertdialog" aria-label="'+strings.checkout+'" @close="close" @cancel="cancel">'
'<dialog :class="frameClasses" aria-label="'+strings.checkout+'" @close="close" @cancel="cancel">'
+ '<div class="pretix-widget-frame-loading" v-show="$root.frame_loading">'
+ '<svg width="256" height="256" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path class="pretix-widget-primary-color" d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z"/></svg>'
+ '<p :class="cancelBlockedClasses"><strong>'+strings.cancel_blocked+'</strong></p>'
@@ -903,6 +903,14 @@ Vue.component('pretix-overlay', {
}
}
},
'$root.frame_shown': function (newValue) {
if (newValue) {
var btn = this.$el?.querySelector('.pretix-widget-frame-close button');
this.$nextTick(function() {
btn.focus();
});
}
},
},
computed: {
frameClasses: function () {
@@ -931,7 +939,18 @@ Vue.component('pretix-overlay', {
}
},
},
mounted () {
window.addEventListener('message', this.onMessage, false);
},
unmounted () {
window.removeEventListener('message', this.onMessage, false);
},
methods: {
onMessage: function(e) {
if (e.data.type && e.data.type == "pretix:widget:title") {
this.$el.querySelector("iframe").title = e.data.title;
}
},
lightboxClose: function () {
this.$root.lightbox = null;
},
@@ -1563,14 +1582,14 @@ Vue.component('pretix-widget-event-calendar', {
+ '<a class="pretix-widget-event-calendar-previous-month" href="#" @click.prevent.stop="prevmonth">&laquo; '
+ strings['previous_month']
+ '</a> '
+ '<strong>{{ monthname }}</strong> '
+ '<strong :id="aria_labelledby">{{ monthname }}</strong> '
+ '<a class="pretix-widget-event-calendar-next-month" href="#" @click.prevent.stop="nextmonth">'
+ strings['next_month']
+ ' &raquo;</a>'
+ '</div>'
// Calendar
+ '<table class="pretix-widget-event-calendar-table" :id="id" tabindex="0" v-bind:aria-label="monthname">'
+ '<table class="pretix-widget-event-calendar-table" :id="id" tabindex="0" v-bind:aria-labelledby="aria_labelledby">'
+ '<thead>'
+ '<tr>'
+ '<th aria-label="' + strings['days']['MONDAY'] + '">' + strings['days']['MO'] + '</th>'
@@ -1597,6 +1616,9 @@ Vue.component('pretix-widget-event-calendar', {
id: function () {
return this.$root.html_id + "-event-calendar-table";
},
aria_labelledby: function () {
return this.$root.html_id + "-event-calendar-table-label";
},
},
methods: {
back_to_list: function () {
@@ -2182,11 +2204,22 @@ var create_overlay = function (app) {
// show loading spinner only when previously no frame_src was set
if (newValue && !oldValue) {
this.frame_loading = true;
this.$el?.querySelector('dialog.pretix-widget-frame-holder').showModal();
}
// to close and unload the iframe, frame_src can be empty -> make it valid HTML with about:blank
this.$el.querySelector("iframe").src = newValue || "about:blank";
},
frame_loading: function (newValue) {
var dialog = this.$el?.querySelector('dialog.pretix-widget-frame-holder');
if (newValue) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (!this.frame_src && dialog.open) {// finished loading, but no iframe to display => close
dialog.close();
}
}
},
}
});
app.$root.overlay = framechild;

View File

@@ -113,7 +113,7 @@
line-height: normal;
border: 1px solid $input-border;
border-radius: $input-border-radius;
height: $input-height-base;
min-height: $input-height-base;
padding: $padding-base-vertical $padding-base-horizontal;
color: $input-color;
background-color: $input-bg;

View File

@@ -44,6 +44,7 @@ from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
from tests import assert_num_queries
from tests.api.utils import _test_configurable_serializer
from tests.const import SAMPLE_PNG
from pretix.base.models import (
@@ -215,6 +216,15 @@ def test_event_list_filter(token_client, organizer, event):
assert resp.status_code == 200
assert resp.data['count'] == 0
_test_configurable_serializer(
token_client,
"/api/v1/organizers/{}/events/".format(organizer.slug),
[
"slug", "live", "meta_data", "seating_plan", "item_meta_properties"
],
expands=[]
)
@pytest.mark.django_db
def test_event_list_name_filter(token_client, organizer, event):

View File

@@ -42,6 +42,7 @@ from django.conf import settings
from django.core.files.base import ContentFile
from django_countries.fields import Country
from django_scopes import scopes_disabled
from tests.api.utils import _test_configurable_serializer
from tests.const import SAMPLE_PNG
from pretix.base.models import (
@@ -359,10 +360,17 @@ TEST_ITEM_RES = {
@pytest.mark.django_db
def test_item_list(token_client, organizer, event, team, item):
def test_item_list(token_client, organizer, event, team, item, taxrule):
cat = event.categories.create(name="foo")
cat2 = event.categories.create(name="bar")
item.category = cat2
item.tax_rule = taxrule
item.save()
res = dict(TEST_ITEM_RES)
res["id"] = item.pk
res["category"] = cat2.pk
res["tax_rule"] = taxrule.pk
res["tax_rate"] = "19.00"
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@@ -400,11 +408,11 @@ def test_item_list(token_client, organizer, event, team, item):
assert resp.status_code == 200
assert [] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=0'.format(organizer.slug, event.slug))
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=19'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=19'.format(organizer.slug, event.slug))
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=0'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert [] == resp.data['results']
@@ -419,6 +427,15 @@ def test_item_list(token_client, organizer, event, team, item):
assert resp.status_code == 200
assert [] == resp.data['results']
_test_configurable_serializer(
token_client,
"/api/v1/organizers/{}/events/{}/items/".format(organizer.slug, event.slug),
[
"name", "free_price", "variations",
],
expands=["category", "tax_rule"],
)
@pytest.mark.django_db
def test_item_detail(token_client, organizer, event, team, item):
@@ -1901,10 +1918,11 @@ TEST_QUOTA_RES = {
@pytest.mark.django_db
def test_quota_list(token_client, organizer, event, quota, item, subevent):
def test_quota_list(token_client, organizer, event, quota, item, item3, subevent):
quota.items.add(item3)
res = dict(TEST_QUOTA_RES)
res["id"] = quota.pk
res["items"] = [item.pk]
res["items"] = [item.pk, item3.pk]
resp = token_client.get('/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
@@ -1922,6 +1940,13 @@ def test_quota_list(token_client, organizer, event, quota, item, subevent):
'/api/v1/organizers/{}/events/{}/quotas/?subevent={}'.format(organizer.slug, event.slug, se2.pk))
assert [] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/quotas/?items__in={},{},0'.format(organizer.slug, event.slug, item.pk, item3.pk))
assert [res] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/quotas/?items__in=0'.format(organizer.slug, event.slug))
assert [] == resp.data['results']
@pytest.mark.django_db
def test_quota_detail(token_client, organizer, event, quota, item):

View File

@@ -961,6 +961,42 @@ def test_order_create_fee_as_percentage(token_client, organizer, event, item, qu
assert o.total == Decimal('25.30')
@pytest.mark.django_db
def test_order_create_fee_as_percentage_with_zero(token_client, organizer, event, item, quota, question):
with scopes_disabled():
voucher = event.vouchers.create(price_mode="set", value=Decimal("0.00"))
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['fees'][0]['_treat_value_as_percentage'] = True
res['fees'][0]['_split_taxes_like_products'] = True
res['fees'][0]['value'] = '10.00'
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['voucher'] = voucher.code
del res['positions'][0]['price']
res['simulate'] = True
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
assert resp.data["total"] == "0.00"
res['simulate'] = False
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
fee = o.fees.first()
assert fee.value == Decimal('0.00')
assert o.total == Decimal('0.00')
@pytest.mark.django_db
def test_order_create_fee_with_auto_tax(token_client, organizer, event, item, quota, question, taxrule):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)

View File

@@ -31,6 +31,7 @@ from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scopes_disabled
from stripe import error
from tests.api.utils import _test_configurable_serializer
from tests.plugins.stripe.test_checkout import apple_domain_create
from tests.plugins.stripe.test_provider import MockedCharge
@@ -400,13 +401,18 @@ def test_order_list_filter_subevent_date(token_client, device, organizer, event,
@pytest.mark.django_db
def test_order_list(token_client, organizer, event, order, item, taxrule, question, device):
def test_order_list(token_client, organizer, event, order, item, team, taxrule, question, device):
res = dict(TEST_ORDER_RES)
with scopes_disabled():
voucher = event.vouchers.create(code="FOO")
opos = order.positions.first()
opos.voucher = voucher
opos.save()
res["positions"][0]["id"] = order.positions.first().pk
res["fees"][0]["id"] = order.fees.first().pk
res["positions"][0]["print_logs"][0]["id"] = order.positions.first().print_logs.first().pk
res["positions"][0]["print_logs"][0]["device_id"] = device.device_id
res["positions"][0]["voucher"] = voucher.pk
res["positions"][0]["item"] = item.pk
res["positions"][0]["answers"][0]["question"] = question.pk
res["last_modified"] = order.last_modified.isoformat().replace('+00:00', 'Z')
@@ -514,6 +520,22 @@ def test_order_list(token_client, organizer, event, order, item, taxrule, questi
assert resp.status_code == 200
assert len(resp.data['results'][0]['fees']) == 2
_test_configurable_serializer(
token_client,
"/api/v1/organizers/{}/events/{}/orders/".format(organizer.slug, event.slug),
[
"status", "invoice_address.company", "fees.value", "payments.state",
"positions.print_logs.type", "positions.answers.answer"
],
expands=["positions.voucher"],
)
team.can_view_vouchers = False
team.save()
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?expand=positions.voucher'.format(organizer.slug, event.slug))
assert resp.status_code == 403
assert resp.data["detail"] == "No permission to expand field positions.voucher"
@pytest.mark.django_db
def test_order_detail(token_client, organizer, event, order, item, taxrule, question):
@@ -521,6 +543,7 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques
with scopes_disabled():
res["positions"][0]["id"] = order.positions.first().pk
res["positions"][0]["print_logs"][0]["id"] = order.positions.first().print_logs.first().pk
res["positions"][0]["print_logs"][0]["device_id"] = order.positions.first().print_logs.first().device_id
res["fees"][0]["id"] = order.fees.first().pk
res["positions"][0]["item"] = item.pk
res["fees"][0]["tax_rule"] = taxrule.pk

View File

@@ -21,6 +21,7 @@
#
import pytest
from django.core.files.base import ContentFile
from tests.api.utils import _test_configurable_serializer
from tests.const import SAMPLE_PNG
TEST_ORGANIZER_RES = {
@@ -36,6 +37,15 @@ def test_organizer_list(token_client, organizer):
assert resp.status_code == 200
assert TEST_ORGANIZER_RES in resp.data['results']
_test_configurable_serializer(
token_client,
"/api/v1/organizers/",
[
"name", "public_url"
],
expands=[],
)
@pytest.mark.django_db
def test_organizer_detail(token_client, organizer):

View File

@@ -26,6 +26,7 @@ from unittest import mock
import pytest
from django_countries.fields import Country
from django_scopes import scopes_disabled
from tests.api.utils import _test_configurable_serializer
from pretix.base.models import (
InvoiceAddress, ItemVariation, Order, OrderPosition, SeatingPlan, SubEvent,
@@ -157,6 +158,15 @@ def test_subevent_list(token_client, organizer, event, subevent):
assert resp.status_code == 200
assert resp.data['results'][0]['best_availability_state'] is None
_test_configurable_serializer(
token_client,
"/api/v1/organizers/{}/events/{}/subevents/".format(organizer.slug, event.slug),
[
"name", "active", "item_price_overrides",
],
expands=[]
)
@pytest.mark.django_db
def test_subevent_list_filter(token_client, organizer, event, subevent):

89
src/tests/api/utils.py Normal file
View File

@@ -0,0 +1,89 @@
#
# 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 urllib.parse
def _add_params(url, params):
url_parts = list(urllib.parse.urlparse(url))
query = urllib.parse.parse_qs(url_parts[4])
query = [*query, *params]
url_parts[4] = urllib.parse.urlencode(query)
return urllib.parse.urlunparse(url_parts)
def _find_field_names(d: dict, path):
names = set()
for k, v in d.items():
names.add(".".join([*path, k]))
if isinstance(v, dict):
names |= _find_field_names(v, path=(*path, k))
elif isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
names |= _find_field_names(v[0], path=(*path, k))
return names
def _test_configurable_serializer(client, url, field_name_samples, expands):
# Test include
resp = client.get(_add_params(url, [("include", f) for f in field_name_samples]))
if "results" in resp.data:
o = resp.data["results"][0]
else:
o = resp.data
found_field_names = _find_field_names(o, tuple())
# Assert no unexpected fields
for f in found_field_names:
depth = f.count(".")
assert (f in field_name_samples or
any(f.rsplit(".", c)[0] in field_name_samples for c in range(depth + 1)) or
any(fn.startswith(f + ".") for fn in field_name_samples))
# Assert all fields are there
for f in field_name_samples:
assert f in found_field_names, f"{f} not in {found_field_names}"
# Test exclude
resp = client.get(_add_params(url, [("exclude", f) for f in field_name_samples]))
if "results" in resp.data:
o = resp.data["results"][0]
else:
o = resp.data
found_field_names = _find_field_names(o, [])
# Assert all fields are not there
for f in found_field_names:
assert f not in field_name_samples
# Test expand
if expands:
resp = client.get(_add_params(url, [("expand", f) for f in expands]))
if "results" in resp.data:
o = resp.data["results"][0]
else:
o = resp.data
for e in expands:
path = e.split(".")
obj = o
while len(path) > 1:
obj = o[path[0]]
if isinstance(obj, list):
obj = obj[0]
path = path[1:]
assert isinstance(obj[path[0]], dict), f"{e} is not a dictionary, but {type(obj[path[0]])}"

View File

@@ -225,8 +225,11 @@ def test_full_clone_cross_organizer_differences():
organizer2 = Organizer.objects.create(name='Dummy2', slug='dummy2')
membership_type = organizer.membership_types.create(name="Membership")
plan = SeatingPlan.objects.create(name="Plan", organizer=organizer, layout="{}")
sc = organizer.sales_channels.get(identifier="web")
sc2 = organizer2.sales_channels.get(identifier="web")
sc1_a = organizer.sales_channels.get(identifier="web")
sc1_b = organizer.sales_channels.create(identifier="b")
sc1_c = organizer.sales_channels.create(identifier="c")
sc2_a = organizer2.sales_channels.get(identifier="web")
sc2_c = organizer2.sales_channels.create(identifier="c")
event = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
@@ -237,15 +240,20 @@ def test_full_clone_cross_organizer_differences():
seating_plan=plan,
all_sales_channels=False,
)
event.limit_sales_channels.add(sc)
event.limit_sales_channels.add(sc1_a)
event.limit_sales_channels.add(sc1_b)
event.limit_sales_channels.add(sc1_c)
item1 = event.items.create(name="Ticket", default_price=23,
grant_membership_type=membership_type,
all_sales_channels=False)
item1.limit_sales_channels.add(sc)
item1.limit_sales_channels.add(sc1_a)
item2 = event.items.create(name="T-shirt", default_price=15)
item2.require_membership_types.add(membership_type)
event.settings.payment_giftcard__enabled = True
event.settings.payment_giftcard__restrict_to_sales_channels = ['web', 'b', 'c']
copied_event = Event.objects.create(
organizer=organizer2, name='Dummy2', slug='dummy2',
date_from=datetime.datetime(2022, 4, 15, 9, 0, 0, tzinfo=datetime.timezone.utc),
@@ -257,11 +265,14 @@ def test_full_clone_cross_organizer_differences():
assert organizer2.seating_plans.count() == 1
assert organizer2.seating_plans.get().layout == plan.layout
assert copied_event.seating_plan.organizer == organizer2
assert copied_event.limit_sales_channels.get() == sc2
assert set(copied_event.limit_sales_channels.all()) == {sc2_a, sc2_c}
assert event.seating_plan.organizer == organizer
copied_item1 = copied_event.items.get(name=item1.name)
copied_item2 = copied_event.items.get(name=item2.name)
assert copied_item1.grant_membership_type is None
assert copied_item2.require_membership_types.count() == 0
assert copied_item1.limit_sales_channels.get() == sc2
assert copied_item1.limit_sales_channels.get() == sc2_a
assert event.settings.get('payment_giftcard__restrict_to_sales_channels', as_type=list) == ['web', 'b', 'c']
assert copied_event.settings.get('payment_giftcard__restrict_to_sales_channels', as_type=list) == ['web', 'c']

View File

@@ -23,6 +23,7 @@ import inspect
import os
import pytest
from django.core.cache import cache
from django.test import override_settings
from django.utils import translation
from django_scopes import scopes_disabled
@@ -115,6 +116,7 @@ def fakeredis_client(monkeypatch):
},
}
):
cache.clear()
redis = get_redis_connection("default", True)
redis.flushall()
monkeypatch.setattr('django_redis.get_redis_connection', get_redis_connection, raising=False)