Compare commits

..
99 changed files with 1182 additions and 2943 deletions
+2 -1
View File
@@ -57,7 +57,8 @@ COPY vite.config.ts /pretix/vite.config.ts
RUN pip3 install -U \ RUN pip3 install -U \
pip \ pip \
setuptools && \ setuptools \
wheel && \
cd /pretix && \ cd /pretix && \
PRETIX_DOCKER_BUILD=TRUE pip3 install \ PRETIX_DOCKER_BUILD=TRUE pip3 install \
-e ".[memcached]" \ -e ".[memcached]" \
+1 -1
View File
@@ -192,7 +192,7 @@ Cart position endpoints
* ``attendee_email`` (optional) * ``attendee_email`` (optional)
* ``subevent`` (optional) * ``subevent`` (optional)
* ``expires`` (optional) * ``expires`` (optional)
* ``includes_tax`` (optional, **DEPRECATED**, do not use, will be removed) * ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional) * ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code) * ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions) * ``addons`` (optional, expect a list of nested objects of cart positions)
+1 -8
View File
@@ -46,14 +46,12 @@ Checking a ticket in
this request twice with the same nonce, the second request will also succeed but will always this request twice with the same nonce, the second request will also succeed but will always
create only one check-in object even when the previous request was successful as well. This create only one check-in object even when the previous request was successful as well. This
allows for a certain level of idempotency and enables you to re-try after a connection failure. allows for a certain level of idempotency and enables you to re-try after a connection failure.
:<json string exchange_medium_type: To perform an exchange to a reusable medium, pass the type of the new reusable medium
:<json string exchange_medium_identifier: To perform an exchange to a reusable media, pass the identifier of the new medium
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the :<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
order) when building texts (currently only the ``reason_explanation`` response field). order) when building texts (currently only the ``reason_explanation`` response field).
Defaults to ``false`` in which case the server will determine the language (currently Defaults to ``false`` in which case the server will determine the language (currently
the event default language, might change in the future with support for the the event default language, might change in the future with support for the
``Accept-Language`` header). ``Accept-Language`` header).
:>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"`` :>json string status: ``"ok"``, ``"incomplete"``, or ``"error"``
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values. :>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null. :>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
:>json object position: Copy of the matching order position (if any was found). The contents are the same as the :>json object position: Copy of the matching order position (if any was found). The contents are the same as the
@@ -69,8 +67,6 @@ Checking a ticket in
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found), :>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``. including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``.
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``. :>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
**Example request**: **Example request**:
@@ -228,9 +224,6 @@ Checking a ticket in
* ``ambiguous`` - Multiple tickets match scan, rejected. * ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
* ``unapproved`` - Order has not yet been approved. * ``unapproved`` - Order has not yet been approved.
* ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in.
* ``medium_invalid`` - Reusable medium identifier given was not found or is not valid.
* ``medium_exists`` - Reusable medium identifier already exists, but expected to be new.
* ``error`` - Internal error. * ``error`` - Internal error.
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation`` In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
+1 -5
View File
@@ -602,8 +602,7 @@ Order position endpoints
We no longer recommend using this API if you're building a ticket scanning application, as it has a few design We no longer recommend using this API if you're building a ticket scanning application, as it has a few design
flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not
URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead. Advanced features like medium URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead.
exchange are only supported on the new API.
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never :query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
as an ``id``. This should be always set if you are passing through untrusted, scanned as an ``id``. This should be always set if you are passing through untrusted, scanned
@@ -742,9 +741,6 @@ Order position endpoints
* ``ambiguous`` - Multiple tickets match scan, rejected. * ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
* ``unapproved`` - Order has not yet been approved. * ``unapproved`` - Order has not yet been approved.
* ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in.
* ``medium_invalid`` - Reusable medium identifier given was not found and could not be automatically created.
* ``medium_exists`` - Reusable medium identifier already exists, but expected to be new.
In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation`` In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation``
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``. with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
+1 -1
View File
@@ -131,7 +131,7 @@ allow_waitinglist boolean If ``false``,
product when it is sold out. product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card. issue_giftcard boolean If ``true``, buying this product will yield a gift card.
media_policy string Policy on how to handle reusable media (experimental feature). media_policy string Policy on how to handle reusable media (experimental feature).
Possible values are ``null``, ``"new"``, ``"reuse"``, ``"reuse_or_new"``, ``"append"``, and ``"append_or_new"``. Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``.
media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices. media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices.
show_quota_left boolean Publicly show how many tickets are still available. show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used. If this is ``null``, the event default is used.
+1 -1
View File
@@ -1069,7 +1069,7 @@ Creating orders
* ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected) * ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
* ``use_reusable_medium`` (optional, causes the new ticket to be connected to the given reusable medium, identified by its ID) * ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run) * ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
* ``answers`` * ``answers``
+9 -30
View File
@@ -21,16 +21,12 @@ id integer Internal ID of
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``. type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
organizer string Organizer slug of the organizer who "owns" this medium. organizer string Organizer slug of the organizer who "owns" this medium.
identifier string Unique identifier of the medium. The format depends on the ``type``. identifier string Unique identifier of the medium. The format depends on the ``type``.
claim_token string Secret token to claim ownership of the medium (or ``null``)
label string Label to identify the medium, usually something human readable (or ``null``)
active boolean Whether this medium may be used. active boolean Whether this medium may be used.
created datetime Date of creation created datetime Date of creation
updated datetime Date of last modification updated datetime Date of last modification
expires datetime Expiry date (or ``null``) expires datetime Expiry date (or ``null``)
customer string Identifier of a customer account this medium belongs to. customer string Identifier of a customer account this medium belongs to.
linked_orderpositions list of integers Internal IDs of tickets this medium is linked to. linked_orderposition integer Internal ID of a ticket this medium is linked to.
linked_orderposition integer **DEPRECATED.** ID of the ticket the medium is linked to, if it is linked to
only one ticket. ``null``, if the medium is linked to none or multiple tickets.
linked_giftcard integer Internal ID of a gift card this medium is linked to. linked_giftcard integer Internal ID of a gift card this medium is linked to.
info object Additional data, content depends on the ``type``. Consider info object Additional data, content depends on the ``type``. Consider
this internal to the system and don't use it for your own data. this internal to the system and don't use it for your own data.
@@ -43,14 +39,6 @@ Existing media types are:
- ``nfc_uid`` - ``nfc_uid``
- ``nfc_mf0aes`` - ``nfc_mf0aes``
.. versionchanged:: 2026.5
The ``claim_token``, ``label``, ``linked_orderpositions`` attributes have been added, the ``linked_orderposition`` attribute has been
deprecated. Note: To maintain backwards compatibility ``linked_orderposition`` contains the internal ID of the linked order position
if the medium has exactly one order position in ``linked_orderpositions``.
Endpoints Endpoints
--------- ---------
@@ -89,7 +77,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -105,13 +92,10 @@ Endpoints
:query string customer: Only show media linked to the given customer. :query string customer: Only show media linked to the given customer.
:query string created_since: Only show media created since a given date. :query string created_since: Only show media created since a given date.
:query string updated_since: Only show media updated since a given date. :query string updated_since: Only show media updated since a given date.
:query integer linked_orderpositions: Only show media linked to the given tickets. Note: you can pass multiple ticket IDs by passing
``linked_orderpositions`` multiple times. Any medium matching any linked orderposition will be returned.
:query integer linked_orderposition: Only show media linked to the given ticket. :query integer linked_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card. :query integer linked_giftcard: Only show media linked to the given gift card.
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``, :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
``"linked_orderposition"`` (**DEPRECATED**), or ``"customer"``, the respective field will be shown or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
as a nested value instead of just an ID.
The nested objects are identical to the respective resources, except that order positions The nested objects are identical to the respective resources, except that order positions
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
matching easier. The parameter can be given multiple times. matching easier. The parameter can be given multiple times.
@@ -150,7 +134,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -208,7 +191,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -216,9 +198,9 @@ Endpoints
} }
:param organizer: The ``slug`` field of the organizer to look up a medium for :param organizer: The ``slug`` field of the organizer to look up a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderpositions`` each will have an attribute of the the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times. can be given multiple times.
:statuscode 201: no error :statuscode 201: no error
@@ -245,7 +227,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -270,7 +251,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -278,7 +258,7 @@ Endpoints
} }
:param organizer: The ``slug`` field of the organizer to create a medium for :param organizer: The ``slug`` field of the organizer to create a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
@@ -307,7 +287,7 @@ Endpoints
Content-Length: 94 Content-Length: 94
{ {
"linked_orderpositions": [13, 29] "linked_orderposition": 13
} }
**Example response**: **Example response**:
@@ -328,8 +308,7 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [13, 29], "linked_orderposition": 13,
"linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
"info": {} "info": {}
@@ -337,7 +316,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the medium to modify :param id: The ``id`` field of the medium to modify
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
+2 -2
View File
@@ -64,8 +64,8 @@ Backend
.. automodule:: pretix.control.signals .. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, :members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, order_approve_info, event_settings_widget, oauth_application_registered, order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
order_position_buttons, subevent_forms, item_formsets, order_search_filter_q, order_search_forms, subevent_detail_html item_formsets, order_search_filter_q, order_search_forms
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index: :no-index:
+8 -6
View File
@@ -29,11 +29,11 @@ classifiers = [
dependencies = [ dependencies = [
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab "arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"babel", "babel",
"BeautifulSoup4==4.15.*", "BeautifulSoup4==4.14.*",
"bleach==6.4.*", "bleach==6.3.*",
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=48.0.1", "cryptography>=48.0.0",
"css-inline==0.20.*", "css-inline==0.20.*",
"defusedcsv>=3.0.0", "defusedcsv>=3.0.0",
"dnspython==2.*", "dnspython==2.*",
@@ -93,7 +93,7 @@ dependencies = [
"redis==7.4.*", "redis==7.4.*",
"reportlab==4.5.*", "reportlab==4.5.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.62.*", "sentry-sdk==2.60.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
@@ -108,10 +108,10 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
dev = [ dev = [
"aiohttp==3.14.*", "aiohttp==3.13.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.36.*", "fakeredis==2.35.*",
"flake8==7.3.*", "flake8==7.3.*",
"freezegun", "freezegun",
"isort==8.0.*", "isort==8.0.*",
@@ -139,6 +139,8 @@ build-backend = "backend"
backend-path = ["_build"] backend-path = ["_build"]
requires = [ requires = [
"setuptools", "setuptools",
"setuptools-rust",
"wheel",
"importlib_metadata", "importlib_metadata",
"tomli", "tomli",
] ]
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.6.0.dev0" __version__ = "2026.5.2"
-2
View File
@@ -110,8 +110,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'), ('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
('PATCH', 'api-v1:reusablemedium-detail')
) )
-8
View File
@@ -88,19 +88,11 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
nonce = serializers.CharField(required=False, allow_null=True) nonce = serializers.CharField(required=False, allow_null=True)
datetime = serializers.DateTimeField(required=False, allow_null=True) datetime = serializers.DateTimeField(required=False, allow_null=True)
answers = serializers.JSONField(required=False, allow_null=True) answers = serializers.JSONField(required=False, allow_null=True)
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
exchange_medium_identifier = serializers.CharField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event') self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')
def validate(self, attrs):
exchange_fields = ["exchange_medium_type", "exchange_medium_identifier"]
if any(attrs.get(k) is None for k in exchange_fields) and not all(attrs.get(k) is None for k in exchange_fields):
raise ValidationError("If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.")
return attrs
class MiniCheckinListSerializer(I18nAwareModelSerializer): class MiniCheckinListSerializer(I18nAwareModelSerializer):
event = serializers.SlugRelatedField(slug_field='slug', read_only=True) event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
-3
View File
@@ -871,7 +871,6 @@ class EventSettingsSerializer(SettingsSerializer):
'og_image', 'og_image',
'name_scheme', 'name_scheme',
'reusable_media_active', 'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode', 'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length', 'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
@@ -886,7 +885,6 @@ class EventSettingsSerializer(SettingsSerializer):
readonly_fields = [ readonly_fields = [
# These are read-only since they are currently only settable on organizers, not events # These are read-only since they are currently only settable on organizers, not events
'reusable_media_active', 'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode', 'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length', 'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
@@ -972,7 +970,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_mf0aes', 'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid', 'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order', 'system_question_order',
'tax_rule_payment', 'tax_rule_payment',
'tax_rule_cancellation', 'tax_rule_cancellation',
+12 -54
View File
@@ -66,14 +66,13 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
expand_nested = self.context['request'].query_params.getlist('expand')
if 'linked_giftcard' in expand_nested: if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_giftcards"]: if not self.context["can_read_giftcards"]:
raise PermissionDenied("No permission to access gift card details.") raise PermissionDenied("No permission to access gift card details.")
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context) self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
if 'linked_giftcard.owner_ticket' in expand_nested: if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context) self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
else: else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
@@ -82,27 +81,17 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
queryset=self.context['organizer'].issued_gift_cards.all() queryset=self.context['organizer'].issued_gift_cards.all()
) )
# keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( # Permission Check performed in to_representation
required=False, self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
)
if 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
self.fields['linked_orderpositions'] = NestedOrderPositionSerializer(
many=True,
read_only=True
)
else: else:
self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField( self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
many=True,
required=False, required=False,
allow_null=True, allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']), queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
) )
if 'customer' in expand_nested: if 'customer' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_customers"]: if not self.context["can_read_customers"]:
raise PermissionDenied("No permission to access customer details.") raise PermissionDenied("No permission to access customer details.")
@@ -117,21 +106,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
if 'linked_orderposition' in data:
linked_orderposition = data['linked_orderposition']
# backwards-compatibility
if 'linked_orderpositions' in data:
raise ValidationError({
'linked_orderposition': 'You cannot use linked_orderposition and linked_orderpositions at the same time.'
})
if self.instance and self.instance.linked_orderpositions.count() > 1:
raise ValidationError({
'linked_orderposition': 'There are more than one linked_orderposition. You need to use linked_orderpositions.'
})
data['linked_orderpositions'] = [linked_orderposition] if linked_orderposition else []
del data['linked_orderposition']
if 'type' in data and 'identifier' in data: if 'type' in data and 'identifier' in data:
qs = self.context['organizer'].reusable_media.filter( qs = self.context['organizer'].reusable_media.filter(
identifier=data['identifier'], type=data['type'] identifier=data['identifier'], type=data['type']
@@ -147,28 +121,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
r = super().to_representation(instance) r = super().to_representation(instance)
request = self.context.get('request') request = self.context.get('request')
ops = r.get('linked_orderpositions', [])
# late permission evaluations for checks that depend on the actual linked events # late permission evaluations for checks that depend on the actual linked events
expand_nested = self.context['request'].query_params.getlist('expand') expand_nested = self.context['request'].query_params.getlist('expand')
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested: if 'linked_orderposition' in expand_nested:
ops_noperm = [] if instance.linked_orderposition is not None:
for lop in instance.linked_orderpositions.all(): event = instance.linked_orderposition.order.event
event = lop.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request): if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
ops_noperm.append(lop.id) r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
if ops_noperm:
ops = [
{'id': op['id']} if op['id'] in ops_noperm
else op
for op in ops
]
r['linked_orderpositions'] = ops
# add linked_orderposition (singular) for backwards compatibility
if len(ops) < 2:
r['linked_orderposition'] = ops[0] if ops else None
if 'linked_giftcard.owner_ticket' in expand_nested: if 'linked_giftcard.owner_ticket' in expand_nested:
gc = instance.linked_giftcard gc = instance.linked_giftcard
@@ -188,12 +148,10 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
'updated', 'updated',
'type', 'type',
'identifier', 'identifier',
'claim_token',
'label',
'active', 'active',
'expires', 'expires',
'customer', 'customer',
'linked_orderpositions', 'linked_orderposition',
'linked_giftcard', 'linked_giftcard',
'info', 'info',
'notes', 'notes',
+4 -15
View File
@@ -1149,7 +1149,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError( raise ValidationError(
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']} {'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
) )
return data return data
@@ -1589,7 +1588,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos_data['attendee_name_parts'] = { pos_data['attendee_name_parts'] = {
'_legacy': attendee_name '_legacy': attendee_name
} }
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium')}) pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
if simulate: if simulate:
pos.order = order._wrapped pos.order = order._wrapped
else: else:
@@ -1704,25 +1703,15 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ.options.add(*options) answ.options.add(*options)
if use_reusable_medium: if use_reusable_medium:
if pos.item.media_policy not in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW): use_reusable_medium.linked_orderposition = pos
for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True): use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
data={
'linked_orderposition': op_pk,
}
)
use_reusable_medium.linked_orderpositions.set([pos])
else:
use_reusable_medium.linked_orderpositions.add(pos)
use_reusable_medium.log_action( use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.added', 'pretix.reusable_medium.linked_orderposition.changed',
data={ data={
'by_order': order.code, 'by_order': order.code,
'linked_orderposition': pos.pk, 'linked_orderposition': pos.pk,
} }
) )
use_reusable_medium.touch()
if not simulate: if not simulate:
for cp in delete_cps: for cp in delete_cps:
-1
View File
@@ -605,7 +605,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no', 'cookie_consent_dialog_button_no',
'reusable_media_active', 'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode', 'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length', 'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
+25 -137
View File
@@ -69,10 +69,8 @@ from pretix.base.models import (
from pretix.base.models.orders import PrintLog from pretix.base.models.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf from pretix.base.permissions import AnyPermissionOf
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
CheckInError, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic, CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
perform_checkin,
) )
from pretix.base.services.media import perform_media_exchange
from pretix.base.signals import checkin_annulled from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
@@ -456,8 +454,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False, source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False):
exchange_medium_type=None, exchange_medium_identifier=None):
if not checkinlists: if not checkinlists:
raise ValidationError('No check-in list passed.') raise ValidationError('No check-in list passed.')
@@ -466,7 +463,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
device = auth if isinstance(auth, Device) else None device = auth if isinstance(auth, Device) else None
gate = gate or (auth.gate if isinstance(auth, Device) else None) gate = gate or (auth.gate if isinstance(auth, Device) else None)
medium = None
context = { context = {
'request': request, 'request': request,
@@ -495,7 +491,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
) )
raw_barcode_for_checkin = None raw_barcode_for_checkin = None
from_revoked_secret = False from_revoked_secret = False
reusable_medium_used = None
if simulate: if simulate:
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
@@ -526,12 +521,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# with respecting the force option), or it's a reusable medium (-> proceed with that) # with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates: if not op_candidates:
try: try:
medium = ReusableMedium.objects.active().filter( media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
).get(
organizer_id=checkinlists[0].event.organizer_id, organizer_id=checkinlists[0].event.organizer_id,
type=source_type, type=source_type,
identifier=raw_barcode, identifier=raw_barcode,
linked_orderposition__isnull=False,
) )
raw_barcode_for_checkin = raw_barcode raw_barcode_for_checkin = raw_barcode
except ReusableMedium.DoesNotExist: except ReusableMedium.DoesNotExist:
@@ -634,9 +628,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400) }, status=400)
else: else:
linked_ops = medium.linked_orderpositions.all().select_related("order").prefetch_related("addons") if media.linked_orderposition.order.event_id not in list_by_event:
linked_event_ids = {op.order.event_id for op in linked_ops}
if not any(event_id in list_by_event for event_id in linked_event_ids):
# Medium exists but connected ticket is for the wrong event # Medium exists but connected ticket is for the wrong event
if not simulate: if not simulate:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
@@ -662,91 +654,28 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'checkin_texts': [], 'checkin_texts': [],
'list': MiniCheckinListSerializer(checkinlists[0]).data, 'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404) }, status=404)
op_candidates = [] op_candidates = [media.linked_orderposition]
for op in linked_ops: if list_by_event[media.linked_orderposition.order.event_id].addon_match:
if op.order.event_id in list_by_event: op_candidates += list(media.linked_orderposition.addons.all())
reusable_medium_used = medium
op_candidates.append(op)
if list_by_event[op.order.event_id].addon_match:
op_candidates += list(op.addons.all())
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary # 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
# key on the same list, we're probably dealing with multiple linked_orderpositions or the ``addon_match`` case # key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
# here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op. # which add-on has the right product.
if len(op_candidates) > 1: if len(op_candidates) > 1:
op_candidates_matching_product = [
op for op in op_candidates
if (
(list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and
(list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()})
)
]
if not reusable_medium_used: if len(op_candidates_matching_product) == 0:
# 3a. First, we clean up that we made an imprecise query above. If a scan is made for multiple check-in lists, # None of the found add-ons has the correct product, too bad! We could just error out here, but
# we have queried ``addon_to__secret=raw_barcode``, even if some of the lists in question do not allow addon
# matching. So we accept all candidates that match one of these cases:
# - Exactly the ticket secret we scanned (because that's always a possible result)
# - Exactly the ticket pk we scanned (on legacy endpoints)
# - An add-on on a list that allows add-on matching
# This is not necessary when a reusable media was used, since in that case we already obeyed list.addon_match
# correctly above.
op_candidates_filtered = [
op for op in op_candidates
if (
op.secret == raw_barcode or
list_by_event[op.order.event_id].addon_match or
(str(op.pk) == raw_barcode and legacy_url_support and not untrusted_input)
)
]
else:
op_candidates_filtered = op_candidates
if len(op_candidates_filtered) > 1:
# 3b. If we still have multiple candidates, we filter by product based on the check-in list configuration.
# This is relevant for the addon_match scenario where the scanned ticket has multiple add-ons, but only
# one is contained in the check-in list used to scan. It makes sense to filter this first, since it is a
# "static" check, i.e. scanning the same QR code on the same check-in list will always do the same, no matter
# when I scan it, and it is "intentional" filtering in the sense that the admin configured this behaviour
# into the check-in list.
op_candidates_filtered = [
op for op in op_candidates_filtered
if list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()}
]
if len(op_candidates_filtered) > 1:
# 3c. If we still have multiple candidates, we filter by validity date. This was introduced for the case where
# a reusable media refers to two tickets, one currently valid and one expired or in the future. Howeer,
# it could in theory also happen with two add-ons being on the same check-in list but without overlapping
# validity. It makes sense to filter this "after" the previous checks since it is not "intentional" filtering
# configured by the admin but "accidental" filtering that depends on the time of execution.
op_candidates_filtered = [
op for op in op_candidates_filtered
if (
(not op.valid_from or op.valid_from <= datetime) and
(not op.valid_until or op.valid_until > datetime)
)
]
if len(op_candidates_filtered) == 0:
# None of the ops is valid today or has the correct product, too bad! We could just error out here, but
# instead we just continue with *any* product and have it rejected by the check in perform_checkin. # instead we just continue with *any* product and have it rejected by the check in perform_checkin.
# To improve the error message, we select the op that will "work next" or - if none matches - "worked last". # This has the advantage of a better error message.
op_candidate = None op_candidates = [op_candidates[0]]
for op in op_candidates: elif len(op_candidates_matching_product) > 1:
if (
op.valid_from and op.valid_from > datetime and
(not op_candidate or op.valid_from < op_candidate.valid_from)
):
op_candidate = op
if not op_candidate:
# no candidate in the future, get closest in the past
for op in op_candidates:
if (
op.valid_until and op.valid_until < datetime and
(not op_candidate or op.valid_until > op_candidate.valid_until)
):
op_candidate = op
if not op_candidate:
op_candidate = op_candidates[0]
op_candidates = [op_candidate]
elif len(op_candidates_filtered) > 1:
# It's still ambiguous, we'll error out. # It's still ambiguous, we'll error out.
# We choose the first match (regardless of product) for the logging since it's most likely to be the # We choose the first match (regardless of product) for the logging since it's most likely to be the
# base product according to our order_by above. # base product according to our order_by above.
@@ -780,7 +709,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400) }, status=400)
else: else:
op_candidates = op_candidates_filtered op_candidates = op_candidates_matching_product
op = op_candidates[0] op = op_candidates[0]
common_checkin_args['list'] = list_by_event[op.order.event_id] common_checkin_args['list'] = list_by_event[op.order.event_id]
@@ -792,10 +721,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
if str(q.pk) in answers_data: if str(q.pk) in answers_data:
try: try:
if q.type == Question.TYPE_FILE: if q.type == Question.TYPE_FILE:
if answers_data[str(q.pk)]: given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
else:
given_answers[q] = None
else: else:
given_answers[q] = q.clean_answer(answers_data[str(q.pk)]) given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
except (ValidationError, BaseValidationError): except (ValidationError, BaseValidationError):
@@ -808,14 +734,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
locale = op.order.event.settings.locale locale = op.order.event.settings.locale
with language(locale): with language(locale):
try: try:
if exchange_medium_identifier and medium: perform_checkin(
# Cannot scan a medium and then request to exchange it
raise CheckInError(
gettext('You cannot exchange a medium for a medium.'),
'error'
)
checkin_args = dict(
op=op, op=op,
clist=list_by_event[op.order.event_id], clist=list_by_event[op.order.event_id],
given_answers=given_answers, given_answers=given_answers,
@@ -833,25 +752,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
from_revoked_secret=from_revoked_secret, from_revoked_secret=from_revoked_secret,
simulate=simulate, simulate=simulate,
gate=gate, gate=gate,
reusable_medium=medium,
) )
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
with transaction.atomic():
# Do exchange and check-in atomically, i.e. both succeed or both fail
medium = perform_media_exchange(
organizer=request.organizer,
media_type=exchange_medium_type,
identifier=exchange_medium_identifier,
link_orderposition=op,
user=user,
auth=auth,
)
source_type = medium.media_type.identifier
checkin_args['reusable_medium'] = medium
perform_checkin(**checkin_args)
else:
perform_checkin(**checkin_args)
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({
'status': 'incomplete', 'status': 'incomplete',
@@ -863,17 +764,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
], ],
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400) }, status=400)
except RequiredMediaExchangeError as e:
return Response({
'status': 'exchange',
'require_attention': op.require_checkin_attention,
'checkin_texts': op.checkin_texts,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'media_policy': e.media_policy,
'media_type': e.media_type,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
'reason_explanation': e.msg,
}, status=400)
except CheckInError as e: except CheckInError as e:
if not simulate: if not simulate:
op.order.log_action('pretix.event.checkin.denied', data={ op.order.log_action('pretix.event.checkin.denied', data={
@@ -1061,8 +951,6 @@ class CheckinRPCRedeemView(views.APIView):
canceled_supported=True, canceled_supported=True,
request=self.request, # this is not clean, but we need it in the serializers for URL generation request=self.request, # this is not clean, but we need it in the serializers for URL generation
legacy_url_support=False, legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
) )
+11 -36
View File
@@ -53,12 +53,10 @@ with scopes_disabled():
customer = django_filters.CharFilter(field_name='customer__identifier') customer = django_filters.CharFilter(field_name='customer__identifier')
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte') updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte') created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
# backwards-compatible
linked_orderposition = django_filters.NumberFilter(field_name='linked_orderpositions__id')
class Meta: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard'] fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
class ReusableMediaViewSet(viewsets.ModelViewSet): class ReusableMediaViewSet(viewsets.ModelViewSet):
@@ -77,7 +75,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
).order_by().values('card').annotate(s=Sum('value')).values('s') ).order_by().values('card').annotate(s=Sum('value')).values('s')
return self.request.organizer.reusable_media.prefetch_related( return self.request.organizer.reusable_media.prefetch_related(
Prefetch( Prefetch(
'linked_orderpositions', 'linked_orderposition',
queryset=OrderPosition.objects.select_related( queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat', 'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related( ).prefetch_related(
@@ -119,38 +117,14 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
@transaction.atomic() @transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
rm = ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk) ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
prev_linked_ops_pks = list(rm.linked_orderpositions.values_list("pk", flat=True))
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type) inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True) inst.log_action(
for op_pk in prev_linked_ops_pks: 'pretix.reusable_medium.changed',
if op_pk not in linked_ops_pks: user=self.request.user,
inst.log_action( auth=self.request.auth,
'pretix.reusable_medium.linked_orderposition.removed', data=self.request.data,
user=self.request.user, )
auth=self.request.auth,
data={
'linked_orderposition': op_pk,
}
)
for op_pk in linked_ops_pks:
if op_pk not in prev_linked_ops_pks:
inst.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=self.request.user,
auth=self.request.auth,
data={
'linked_orderposition': op_pk,
}
)
data = {k: v for k, v in self.request.data.items() if k not in ('linked_orderposition', 'linked_orderpositions')}
if data:
inst.log_action(
'pretix.reusable_medium.changed',
user=self.request.user,
auth=self.request.auth,
data=data,
)
return inst return inst
def perform_destroy(self, instance): def perform_destroy(self, instance):
@@ -183,6 +157,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
type=s.validated_data["type"], type=s.validated_data["type"],
identifier=s.validated_data["identifier"], identifier=s.validated_data["identifier"],
) )
m.linked_orderposition = None # not relevant for cross-organizer
m.customer = None # not relevant for cross-organizer m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m) s = self.get_serializer(m)
return Response({"result": s.data}) return Response({"result": s.data})
@@ -196,7 +171,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
return Response({"result": None}) return Response({"result": None})
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some performance @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs): def list(self, request, **kwargs):
date = serializers.DateTimeField().to_representation(now()) date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
+2 -2
View File
@@ -194,7 +194,7 @@ with scopes_disabled():
) )
).values('id') ).values('id')
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderpositions__order_id', flat=True) matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True)
mainq = ( mainq = (
code code
@@ -1034,7 +1034,7 @@ with scopes_disabled():
search = django_filters.CharFilter(method='search_qs') search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value): def search_qs(self, queryset, name, value):
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderpositions', flat=True) matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True)
return queryset.filter( return queryset.filter(
Q(secret__istartswith=value) Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value) | Q(attendee_name_cached__icontains=value)
+3 -6
View File
@@ -20,13 +20,12 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django.db.models import Prefetch
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
from ..exporter import ListExporter, OrganizerLevelExportMixin from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..models import OrderPosition, ReusableMedium from ..models import ReusableMedium
from ..signals import register_multievent_data_exporters from ..signals import register_multievent_data_exporters
@@ -45,9 +44,7 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
media = ReusableMedium.objects.filter( media = ReusableMedium.objects.filter(
organizer=self.organizer, organizer=self.organizer,
).select_related( ).select_related(
'customer', 'linked_giftcard', 'customer', 'linked_orderposition', 'linked_giftcard',
).prefetch_related(
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order"))
).order_by('created') ).order_by('created')
headers = [ headers = [
@@ -77,7 +74,7 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
_('Yes') if medium.active else _('No'), _('Yes') if medium.active else _('No'),
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '', date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
medium.customer.identifier if medium.customer_id else '', medium.customer.identifier if medium.customer_id else '',
', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]), f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
giftcard_secret, giftcard_secret,
medium.notes, medium.notes,
] ]
+76 -90
View File
@@ -22,9 +22,7 @@
import datetime import datetime
import logging import logging
import math import math
import re
import textwrap import textwrap
import unicodedata
from collections import defaultdict from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from io import BytesIO from io import BytesIO
@@ -58,8 +56,8 @@ from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import ( from pretix.helpers.reportlab import (
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new, FontFallbackParagraph, PlainTextParagraph, ThumbnailingImageReader,
reshaper, normalize_text, register_ttf_font_if_new, reshaper,
) )
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -259,18 +257,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype'])) register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
def _normalize(self, text): def _normalize(self, text):
# reportlab does not support unicode combination characters # alias kept for plugin compatibility
# It's important we do this before we use ArabicReshaper return normalize_text(text)
text = unicodedata.normalize("NFKC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
def _upper(self, val): def _upper(self, val):
# We uppercase labels, but not in every language # We uppercase labels, but not in every language
@@ -351,10 +339,15 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read() return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None): def _clean_text(self, text, tags=None):
return self._normalize(bleach.clean( # For backwards compatibility with customer content, we need to support tags like <br> and <b> in a few text
text, # fields. Therefore, we can't use PlainTextParagraph for these, but run bleach instead to limit the allowed
tags=set(tags) if tags else set() # tags.
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')) return self._normalize(
bleach.clean(
text,
tags=set(tags) if tags else set()
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
)
class PaidMarker(Flowable): class PaidMarker(Flowable):
@@ -405,8 +398,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas): def _draw_invoice_to(self, canvas):
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to), p = PlainTextParagraph(self.invoice.address_invoice_to, style=self.stylesheet['Normal'])
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height) p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height) p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
@@ -417,8 +409,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_from_top = 17 * mm invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
p = FontFallbackParagraph( p = PlainTextParagraph(
self._clean_text(self.invoice.full_invoice_from), self.invoice.full_invoice_from,
style=self.stylesheet['InvoiceFrom'] style=self.stylesheet['InvoiceFrom']
) )
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height) p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
@@ -548,13 +540,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event(self, canvas): def _draw_event(self, canvas):
def shorten(txt): def shorten(txt):
txt = str(txt) txt = str(txt)
txt = bleach.clean(txt, tags=set()).strip() p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading: while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + '' txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal']) p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
return txt return txt
@@ -572,7 +563,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
else: else:
p_str = shorten(self.invoice.event.name) p_str = shorten(self.invoice.event.name)
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal']) p = PlainTextParagraph(p_str, style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height) p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1]) p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
@@ -645,39 +636,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
type_info_text = self.invoice.transmission_type_instance.pdf_info_text() type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
if type_info_text: if type_info_text:
story.append(FontFallbackParagraph( story.append(PlainTextParagraph(
type_info_text, type_info_text,
self.stylesheet['WarningBlock'] self.stylesheet['WarningBlock']
)) ))
if self.invoice.custom_field: if self.invoice.custom_field:
story.append(FontFallbackParagraph( story.append(PlainTextParagraph(
'{}: {}'.format( '{}: {}'.format(
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)), str(self.invoice.event.settings.invoice_address_custom_field),
self._clean_text(self.invoice.custom_field), self.invoice.custom_field,
), ),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.internal_reference: if self.invoice.internal_reference:
story.append(FontFallbackParagraph( story.append(PlainTextParagraph(
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format( pgettext('invoice', 'Customer reference: {reference}').format(
reference=self._clean_text(self.invoice.internal_reference), reference=self.invoice.internal_reference,
)), ),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.invoice_to_vat_id: if self.invoice.invoice_to_vat_id:
story.append(FontFallbackParagraph( story.append(PlainTextParagraph(
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' + pgettext('invoice', 'Customer VAT ID') + ': ' + self.invoice.invoice_to_vat_id,
self._clean_text(self.invoice.invoice_to_vat_id),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.invoice_to_beneficiary: if self.invoice.invoice_to_beneficiary:
story.append(FontFallbackParagraph( story.append(PlainTextParagraph(
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' + pgettext('invoice', 'Beneficiary') + ':\n' + self.invoice.invoice_to_beneficiary,
self._clean_text(self.invoice.invoice_to_beneficiary),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
@@ -707,11 +696,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = [ story = [
NextPageTemplate('FirstPage'), NextPageTemplate('FirstPage'),
FontFallbackParagraph( PlainTextParagraph(
self._normalize( (
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU' pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice') else pgettext('invoice', 'Invoice')
) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')), ) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1'] self.stylesheet['Heading1']
), ),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
@@ -733,17 +722,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
] ]
if has_taxes: if has_taxes:
tdata = [( tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']), PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']), PlainTextParagraph(pgettext('invoice', 'Net'), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']), PlainTextParagraph(pgettext('invoice', 'Gross'), self.stylesheet['BoldRightNoSplit']),
)] )]
else: else:
tdata = [( tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']), PlainTextParagraph(pgettext('invoice', 'Amount'), self.stylesheet['BoldRightNoSplit']),
)] )]
def _group_key(line): def _group_key(line):
@@ -780,8 +769,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
max_height = self.stylesheet['Normal'].leading * 5 max_height = self.stylesheet['Normal'].leading * 5
p_style = self.stylesheet['Normal'] p_style = self.stylesheet['Normal']
for __ in range(1000): for __ in range(1000):
p = FontFallbackParagraph( p = PlainTextParagraph(
self._clean_text(curr_description, tags=['br']), curr_description,
p_style p_style
) )
h = p.wrap(max_width, doc.height)[1] h = p.wrap(max_width, doc.height)[1]
@@ -862,7 +851,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice # Group together at the end of the invoice
request_show_service_date = period_line request_show_service_date = period_line
elif period_line: elif period_line:
description_p_list.append(FontFallbackParagraph( description_p_list.append(PlainTextParagraph(
period_line, period_line,
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
@@ -874,7 +863,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency), net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency), gross_price=money_filter(gross_value, self.invoice.event.currency),
) )
description_p_list.append(FontFallbackParagraph( description_p_list.append(PlainTextParagraph(
single_price_line, single_price_line,
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
@@ -883,11 +872,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
description_p_list.pop(0), description_p_list.pop(0),
str(len(lines)), str(len(lines)),
localize(tax_rate) + " %", localize(tax_rate) + " %",
FontFallbackParagraph( PlainTextParagraph(
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
FontFallbackParagraph( PlainTextParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
@@ -904,14 +893,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
single_price_line = pgettext('invoice', 'Single price: {price}').format( single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency), price=money_filter(gross_value, self.invoice.event.currency),
) )
description_p_list.append(FontFallbackParagraph( description_p_list.append(PlainTextParagraph(
single_price_line, single_price_line,
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
tdata.append(( tdata.append((
description_p_list.pop(0), description_p_list.pop(0),
str(len(lines)), str(len(lines)),
FontFallbackParagraph( PlainTextParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
@@ -944,12 +933,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes: if has_taxes:
tdata.append([ tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '', PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
else: else:
tdata.append([ tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
@@ -958,12 +947,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pending_sum = self.invoice.order.pending_sum pending_sum = self.invoice.order.pending_sum
if pending_sum != total: if pending_sum != total:
tdata.append( tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] + [PlainTextParagraph(pgettext('invoice', 'Received payments'), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum - total, self.invoice.event.currency)] [money_filter(pending_sum - total, self.invoice.event.currency)]
) )
tdata.append( tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] + [PlainTextParagraph(pgettext('invoice', 'Outstanding payments'), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum, self.invoice.event.currency)] [money_filter(pending_sum, self.invoice.event.currency)]
) )
@@ -980,12 +969,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
s=Sum('amount') s=Sum('amount')
)['s'] or Decimal('0.00') )['s'] or Decimal('0.00')
tdata.append( tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] + [PlainTextParagraph(pgettext('invoice', 'Paid by gift card'), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(giftcard_sum, self.invoice.event.currency)] [money_filter(giftcard_sum, self.invoice.event.currency)]
) )
tdata.append( tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] + [PlainTextParagraph(pgettext('invoice', 'Remaining amount'), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(total - giftcard_sum, self.invoice.event.currency)] [money_filter(total - giftcard_sum, self.invoice.event.currency)]
) )
@@ -1008,14 +997,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm)) story.append(Spacer(1, 10 * mm))
if request_show_service_date: if request_show_service_date:
story.append(FontFallbackParagraph( story.append(PlainTextParagraph(
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)), pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.payment_provider_text: if self.invoice.payment_provider_text:
story.append(FontFallbackParagraph( story.append(FontFallbackParagraph(
self._normalize(self.invoice.payment_provider_text), self._clean_text(self.invoice.payment_provider_text, tags=['br', 'b']),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
@@ -1039,10 +1028,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('FONTNAME', (0, 0), (-1, -1), self.font_regular), ('FONTNAME', (0, 0), (-1, -1), self.font_regular),
] ]
thead = [ thead = [
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']), PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['Fineprint']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']), PlainTextParagraph(pgettext('invoice', 'Net value'), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']), PlainTextParagraph(pgettext('invoice', 'Gross value'), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']), PlainTextParagraph(pgettext('invoice', 'Tax'), self.stylesheet['FineprintRight']),
'' ''
] ]
tdata = [thead] tdata = [thead]
@@ -1053,7 +1042,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
continue continue
tax = taxvalue_map[idx] tax = taxvalue_map[idx]
tdata.append([ tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
money_filter(gross - tax, self.invoice.event.currency), money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency), money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency), money_filter(tax, self.invoice.event.currency),
@@ -1072,7 +1061,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata)) table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm)) story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([ story.append(KeepTogether([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']), PlainTextParagraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table table
])) ]))
@@ -1089,7 +1078,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net = gross - tax net = gross - tax
tdata.append([ tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
fmt(net), fmt(gross), fmt(tax), '' fmt(net), fmt(gross), fmt(tax), ''
]) ])
@@ -1098,13 +1087,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(KeepTogether([ story.append(KeepTogether([
Spacer(1, height=2 * mm), Spacer(1, height=2 * mm),
FontFallbackParagraph( PlainTextParagraph(
self._normalize(pgettext( pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, this corresponds to:' '{date}, this corresponds to:'
).format(rate=localize(self.invoice.foreign_currency_rate), ).format(rate=localize(self.invoice.foreign_currency_rate),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")),
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
), ),
Spacer(1, height=3 * mm), Spacer(1, height=3 * mm),
@@ -1113,14 +1102,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate) foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
story.append(Spacer(1, 5 * mm)) story.append(Spacer(1, 5 * mm))
story.append(FontFallbackParagraph(self._normalize( story.append(PlainTextParagraph(
pgettext( pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, the invoice total corresponds to {total}.' '{date}, the invoice total corresponds to {total}.'
).format(rate=localize(self.invoice.foreign_currency_rate), ).format(rate=localize(self.invoice.foreign_currency_rate),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
total=fmt(foreign_total))), total=fmt(foreign_total)),
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
@@ -1162,11 +1151,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
if not self.invoice.address_invoice_from: if not self.invoice.address_invoice_from:
return return
c = [ c = self.invoice.address_invoice_from.strip().split('\n')
self._clean_text(l) p = PlainTextParagraph(' · '.join(c), style=self.stylesheet['Sender'])
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm) p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas) super()._draw_invoice_from(canvas)
@@ -1225,8 +1211,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs) _draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
] ]
p = FontFallbackParagraph( p = PlainTextParagraph(
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")), date_format(self.invoice.date, "DATE_FORMAT"),
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2) style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
) )
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize) w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
@@ -1283,7 +1269,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
i = [] i = []
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage: if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
i.append(FontFallbackParagraph( i.append(PlainTextParagraph(
pgettext('invoice', 'Event date: {date_range}').format( pgettext('invoice', 'Event date: {date_range}').format(
date_range=self.invoice.event.get_date_range_display(), date_range=self.invoice.event.get_date_range_display(),
), ),
@@ -44,8 +44,7 @@ class Command(Parent):
# Start the vite server in the background # Start the vite server in the background
vite_server = subprocess.Popen( vite_server = subprocess.Popen(
["npm", "run", "dev:control"], ["npm", "run", "dev:control"],
cwd=Path(__file__).parent.parent.parent.parent.parent, cwd=Path(__file__).parent.parent.parent.parent.parent
stdin=subprocess.DEVNULL
) )
def cleanup(): def cleanup():
+14 -20
View File
@@ -26,7 +26,6 @@ from django.utils.translation import gettext_lazy as _
class BaseMediaType: class BaseMediaType:
medium_created_by_server = False medium_created_by_server = False
medium_created_from_unknown_supported = False
supports_orderposition = False supports_orderposition = False
supports_giftcard = False supports_giftcard = False
@@ -57,7 +56,7 @@ class BaseMediaType:
def is_active(self, organizer): def is_active(self, organizer):
return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False) return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False)
def handle_unknown(self, organizer, identifier, user, auth, force_create=False): def handle_unknown(self, organizer, identifier, user, auth):
pass pass
def handle_new(self, organizer, medium, user, auth): def handle_new(self, organizer, medium, user, auth):
@@ -89,32 +88,23 @@ class NfcUidMediaType(BaseMediaType):
verbose_name = _('NFC UID-based') verbose_name = _('NFC UID-based')
icon = 'pretixbase/img/media/nfc_uid.svg' icon = 'pretixbase/img/media/nfc_uid.svg'
medium_created_by_server = False medium_created_by_server = False
medium_created_from_unknown_supported = True
supports_giftcard = True supports_giftcard = True
supports_orderposition = True supports_orderposition = False
def handle_unknown(self, organizer, identifier, user, auth, force_create=False): def handle_unknown(self, organizer, identifier, user, auth):
from pretix.base.models import GiftCard, ReusableMedium from pretix.base.models import GiftCard, ReusableMedium
create_giftcard = organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool) if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
if create_giftcard or force_create:
if identifier.startswith("08"): if identifier.startswith("08"):
# Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random # Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random
# UIDs on every read, so they won't be useful. # UIDs on every read, so they won't be useful.
return return
with transaction.atomic(): with transaction.atomic():
if create_giftcard: gc = GiftCard.objects.create(
gc = GiftCard.objects.create( issuer=organizer,
issuer=organizer, expires=organizer.default_gift_card_expiry,
expires=organizer.default_gift_card_expiry, currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), )
)
gc.log_action(
'pretix.giftcards.created',
user=user, auth=auth,
)
else:
gc = None
m = ReusableMedium.objects.create( m = ReusableMedium.objects.create(
type=self.identifier, type=self.identifier,
identifier=identifier, identifier=identifier,
@@ -126,6 +116,10 @@ class NfcUidMediaType(BaseMediaType):
'pretix.reusable_medium.created.auto', 'pretix.reusable_medium.created.auto',
user=user, auth=auth, user=user, auth=auth,
) )
gc.log_action(
'pretix.giftcards.created',
user=user, auth=auth,
)
return m return m
@@ -135,7 +129,7 @@ class NfcMf0aesMediaType(BaseMediaType):
icon = 'pretixbase/img/media/nfc_secure.svg' icon = 'pretixbase/img/media/nfc_secure.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = True supports_orderposition = False
def handle_new(self, organizer, medium, user, auth): def handle_new(self, organizer, medium, user, auth):
from pretix.base.models import GiftCard from pretix.base.models import GiftCard
+4 -54
View File
@@ -65,44 +65,11 @@ def get_supported_language(requested_language, allowed_languages, default_langua
return language return language
class BaseLocaleMiddleware(MiddlewareMixin):
"""
This is a reduced LocaleMiddleware that uses only information contained in the WSGI request data
to figure out the language (cookie and browser settings). We need it to have a consistent language
for error pages that are generated from the middleware stack before we know e.g. which user is logged
in or which event is selected.
"""
def process_request(self, request: HttpRequest):
language = get_language_from_early_request(request)
translation.activate(language)
set_region(None)
request.LANGUAGE_CODE = language
timezone.deactivate()
def process_response(self, request: HttpRequest, response: HttpResponse):
language = translation.get_language()
patch_vary_headers(response, ('Accept-Language',))
if 'Content-Language' not in response:
response['Content-Language'] = language
return response
class LocaleMiddleware(MiddlewareMixin): class LocaleMiddleware(MiddlewareMixin):
""" """
This is the full LocaleMiddleware that uses all available information to figure out the correct This middleware sets the correct locale and timezone
language for the request using all available sources, in this order of priority: for a request.
- Backend: User settings
- Language cookie
- Frontend: Customer account settings
- Browser settings
- Frontend: Event/Organizer settings
- System default
It needs to run late in the middleware stack to have all information available for these steps.
For some cases, it is even ran a second time since the event is sometimes only figured out after the
middleware stack (can happen for plugin views).
""" """
def process_request(self, request: HttpRequest): def process_request(self, request: HttpRequest):
@@ -215,24 +182,6 @@ def get_default_language():
return settings.LANGUAGE_CODE return settings.LANGUAGE_CODE
def get_language_from_early_request(request: HttpRequest) -> str:
"""
Analyzes the request to find what language the user wants the system to
show using only WSGI-available information. Only languages listed in
settings.LANGUAGES are taken into account. If the user requests a sublanguage
where we have a main language, we send out the main language.
"""
global _supported
if _supported is None:
_supported = OrderedDict(settings.LANGUAGES)
return (
get_language_from_cookie(request)
or get_language_from_browser(request)
or get_default_language()
)
def get_language_from_request(request: HttpRequest) -> str: def get_language_from_request(request: HttpRequest) -> str:
""" """
Analyzes the request to find what language the user wants the system to Analyzes the request to find what language the user wants the system to
@@ -247,6 +196,7 @@ def get_language_from_request(request: HttpRequest) -> str:
if request.path.startswith(get_script_prefix() + 'control'): if request.path.startswith(get_script_prefix() + 'control'):
return ( return (
get_language_from_user_settings(request) get_language_from_user_settings(request)
or get_language_from_customer_settings(request)
or get_language_from_cookie(request) or get_language_from_cookie(request)
or get_language_from_browser(request) or get_language_from_browser(request)
or get_language_from_event(request) or get_language_from_event(request)
@@ -1,35 +0,0 @@
# Generated by Django 4.2.26 on 2025-11-24 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0299_itemprogramtime_location"),
]
operations = [
migrations.AddField(
model_name="reusablemedium",
name="claim_token",
field=models.CharField(max_length=200, null=True),
),
migrations.AddField(
model_name="reusablemedium",
name="label",
field=models.CharField(max_length=200, null=True),
),
# use temporary related_name "linked_mediums" for ManyToManyField, so we can migrate existing data
migrations.AddField(
model_name="reusablemedium",
name="linked_orderpositions",
field=models.ManyToManyField(
related_name="linked_mediums", to="pretixbase.orderposition"
),
),
migrations.RunSQL(
sql="INSERT INTO pretixbase_reusablemedium_linked_orderpositions (reusablemedium_id, orderposition_id) SELECT id, linked_orderposition_id FROM pretixbase_reusablemedium WHERE linked_orderposition_id IS NOT NULL;",
reverse_sql="DELETE FROM pretixbase_reusablemedium_linked_orderpositions;",
),
]
@@ -1,44 +0,0 @@
# Generated by Django 4.2.26 on 2025-11-24 11:32
from django.db import migrations, models
def reverse(apps, schema_editor):
ReusableMedium = apps.get_model('pretixbase', 'ReusableMedium')
qs = ReusableMedium.linked_orderpositions.through.objects
objs = []
# get last added orderposition from linked_orderpositions
for rm_id, op_id in qs.filter(id__in=qs.values("reusablemedium_id").annotate(max_id=models.Max('id')).values('max_id')).values_list("reusablemedium_id", "orderposition_id"):
obj = ReusableMedium(
id=rm_id,
linked_orderposition_id=op_id,
)
objs.append(obj)
ReusableMedium.objects.bulk_update(objs, ['linked_orderposition_id'])
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0300_add_reusablemedium_label"),
]
operations = [
# according to the docs, UPDATE FROM should run similarly on sqlite and postgres, but I could not get it to work
# so roll back the data migration with code before deleting data from through-table in 0297
migrations.RunPython(migrations.RunPython.noop, reverse),
migrations.RemoveField(
model_name="reusablemedium",
name="linked_orderposition",
),
# change related_name for new ManyToManyField to previously used linked_media
migrations.AlterField(
model_name="reusablemedium",
name="linked_orderpositions",
field=models.ManyToManyField(
related_name="linked_media", to="pretixbase.orderposition"
),
),
]
-6
View File
@@ -346,14 +346,11 @@ class Checkin(models.Model):
REASON_INCOMPLETE = 'incomplete' REASON_INCOMPLETE = 'incomplete'
REASON_ALREADY_REDEEMED = 'already_redeemed' REASON_ALREADY_REDEEMED = 'already_redeemed'
REASON_AMBIGUOUS = 'ambiguous' REASON_AMBIGUOUS = 'ambiguous'
REASON_MEDIUM_INVALID = 'medium_invalid'
REASON_MEDIUM_EXISTS = 'medium_exists'
REASON_ERROR = 'error' REASON_ERROR = 'error'
REASON_BLOCKED = 'blocked' REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved' REASON_UNAPPROVED = 'unapproved'
REASON_INVALID_TIME = 'invalid_time' REASON_INVALID_TIME = 'invalid_time'
REASON_ANNULLED = 'annulled' REASON_ANNULLED = 'annulled'
REASON_ALREADY_EXCHANGED = 'already_exchanged'
REASONS = ( REASONS = (
(REASON_CANCELED, _('Order canceled')), (REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')), (REASON_INVALID, _('Unknown ticket')),
@@ -369,9 +366,6 @@ class Checkin(models.Model):
(REASON_UNAPPROVED, _('Order not approved')), (REASON_UNAPPROVED, _('Order not approved')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')), (REASON_INVALID_TIME, _('Ticket not valid at this time')),
(REASON_ANNULLED, _('Check-in annulled')), (REASON_ANNULLED, _('Check-in annulled')),
(REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')),
(REASON_MEDIUM_INVALID, _('Reusable medium invalid')),
(REASON_MEDIUM_EXISTS, _('Reusable medium already exists')),
) )
successful = models.BooleanField( successful = models.BooleanField(
+6 -16
View File
@@ -452,16 +452,11 @@ class Item(LoggedModel):
MEDIA_POLICY_REUSE = 'reuse' MEDIA_POLICY_REUSE = 'reuse'
MEDIA_POLICY_NEW = 'new' MEDIA_POLICY_NEW = 'new'
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new' MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
MEDIA_POLICY_APPEND = 'append'
MEDIA_POLICY_APPEND_OR_NEW = 'append_or_new'
MEDIA_POLICIES = ( MEDIA_POLICIES = (
(None, _("Don't use reusable media, use regular one-off tickets")), (None, _("Don't use re-usable media, use regular one-off tickets")),
(MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')),
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')), (MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
(MEDIA_POLICY_REUSE, _('Require an existing medium to be reused, replacing any previous tickets')), (MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used, replacing any previous tickets')),
(MEDIA_POLICY_APPEND, _('Require an existing medium to be reused, adding to any previous tickets')),
(MEDIA_POLICY_APPEND_OR_NEW,
_('Require either an existing or a new medium to be used, adding to any previous tickets')),
) )
objects = ItemQuerySetManager() objects = ItemQuerySetManager()
@@ -774,7 +769,7 @@ class Item(LoggedModel):
null=True, blank=True, max_length=16, null=True, blank=True, max_length=16,
verbose_name=_('Reusable media policy'), verbose_name=_('Reusable media policy'),
help_text=_( help_text=_(
'If this product should be stored on a reusable physical medium, you can attach a physical media policy. ' 'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. '
'This is not required for regular tickets, which just use a one-time barcode, but only for products like ' 'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
'renewable season tickets or re-chargeable gift card wristbands. ' 'renewable season tickets or re-chargeable gift card wristbands. '
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.' 'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
@@ -783,7 +778,7 @@ class Item(LoggedModel):
media_type = models.CharField( media_type = models.CharField(
max_length=100, max_length=100,
null=True, blank=True, null=True, blank=True,
choices=[(None, _("Don't use reusable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()], choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
verbose_name=_('Reusable media type'), verbose_name=_('Reusable media type'),
help_text=_( help_text=_(
'Select the type of physical medium that should be used for this product. Note that not all media types ' 'Select the type of physical medium that should be used for this product. Note that not all media types '
@@ -1000,11 +995,6 @@ class Item(LoggedModel):
raise ValidationError(_('The selected media type does not support usage for tickets currently.')) raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
if not mt.supports_giftcard and issue_giftcard: if not mt.supports_giftcard and issue_giftcard:
raise ValidationError(_('The selected media type does not support usage for gift cards currently.')) raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
if media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
if not mt.medium_created_by_server and not mt.medium_created_from_unknown_supported:
raise ValidationError(_('The selected media type requires all media to be registered in the system '
'prior to their usage. Therefore, the selected media policy does not make '
'sense for this media type.'))
if issue_giftcard: if issue_giftcard:
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, ' raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
'gift cards for some reusable media types can be created or re-charged directly ' 'gift cards for some reusable media types can be created or re-charged directly '
@@ -2230,7 +2220,7 @@ class Quota(LoggedModel):
class ItemMetaProperty(LoggedModel): class ItemMetaProperty(LoggedModel):
""" """
An event can have ItemMetaProperty objects attached to define meta information fields An event can have ItemMetaProperty objects attached to define meta information fields
for its items. This information can be reused for example in ticket layouts. for its items. This information can be re-used for example in ticket layouts.
:param event: The event this property is defined for. :param event: The event this property is defined for.
:type event: Event :type event: Event
+5 -20
View File
@@ -72,16 +72,6 @@ class ReusableMedium(LoggedModel):
max_length=200, max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'), verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
) )
claim_token = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Claim token'),
null=True, blank=True
)
label = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Label'),
null=True, blank=True
)
active = models.BooleanField( active = models.BooleanField(
verbose_name=_('Active'), verbose_name=_('Active'),
@@ -99,14 +89,12 @@ class ReusableMedium(LoggedModel):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
verbose_name=_('Customer account'), verbose_name=_('Customer account'),
) )
linked_orderpositions = models.ManyToManyField( linked_orderposition = models.ForeignKey(
OrderPosition, OrderPosition,
null=True, blank=True,
related_name='linked_media', related_name='linked_media',
verbose_name=_('Linked tickets'), on_delete=models.SET_NULL,
help_text=_( verbose_name=_('Linked ticket'),
'If you link to more than one ticket, make sure there is no overlap in validity. '
'If multiple tickets are valid at once, this will lead to failed check-ins.'
)
) )
linked_giftcard = models.ForeignKey( linked_giftcard = models.ForeignKey(
GiftCard, GiftCard,
@@ -129,10 +117,7 @@ class ReusableMedium(LoggedModel):
@property @property
def is_expired(self): def is_expired(self):
return self.expires and self.expires < now() return self.expires and self.expires > now()
def touch(self):
self.save(update_fields=['updated'])
class Meta: class Meta:
unique_together = (("identifier", "type", "organizer"),) unique_together = (("identifier", "type", "organizer"),)
+1 -1
View File
@@ -1062,7 +1062,7 @@ class Renderer:
except: except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style) p = Paragraph(text, style=style) # not using AutoEscapeParagraph is safe as we escape above
return p, ad, lineheight return p, ad, lineheight
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
+2 -2
View File
@@ -287,11 +287,11 @@ def _check_position_constraints(
raise CartPositionError(error_messages['unavailable']) raise CartPositionError(error_messages['unavailable'])
# Invalid media policy for online sale # Invalid media policy for online sale
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[item.media_type] mt = MEDIA_TYPES[item.media_type]
if not mt.medium_created_by_server: if not mt.medium_created_by_server:
raise CartPositionError(error_messages['media_usage_not_implemented']) raise CartPositionError(error_messages['media_usage_not_implemented'])
elif item.media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND): elif item.media_policy == Item.MEDIA_POLICY_REUSE:
raise CartPositionError(error_messages['media_usage_not_implemented']) raise CartPositionError(error_messages['media_usage_not_implemented'])
# Item removed from sales channel # Item removed from sales channel
+2 -30
View File
@@ -867,15 +867,6 @@ class RequiredQuestionsError(Exception):
super().__init__(msg) super().__init__(msg)
class RequiredMediaExchangeError(Exception):
def __init__(self, msg, code, media_policy, media_type):
self.msg = msg
self.code = code
self.media_policy = media_policy
self.media_type = media_type
super().__init__(msg)
def _save_answers(op, answers, given_answers): def _save_answers(op, answers, given_answers):
def _create_answer(question, answer): def _create_answer(question, answer):
try: try:
@@ -948,7 +939,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False, raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False,
gate=None, reusable_medium=None): gate=None):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -964,7 +955,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
:param datetime: The datetime of the checkin, defaults to now. :param datetime: The datetime of the checkin, defaults to now.
:param simulate: If true, the check-in is not saved. :param simulate: If true, the check-in is not saved.
:param gate: The gate the check-in was performed at. :param gate: The gate the check-in was performed at.
:param reusable_medium: The medium that is available for an exchange
""" """
# !!!!!!!!! # !!!!!!!!!
@@ -1045,7 +1035,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
with transaction.atomic(): with transaction.atomic():
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic # Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
opqs = OrderPosition.all.select_related("order", "item") opqs = OrderPosition.all
if type != Checkin.TYPE_EXIT: if type != Checkin.TYPE_EXIT:
opqs = opqs.select_for_update(of=OF_SELF) opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk) op = opqs.get(pk=op.pk)
@@ -1111,24 +1101,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
require_answers require_answers
) )
required_media_policy = op.item.media_policy
required_media_type = op.item.media_type
require_a_medium = required_media_policy and required_media_type
linked_media = op.linked_media
if require_a_medium and not reusable_medium and not force:
if not linked_media.exists():
raise RequiredMediaExchangeError(
_('Ticket needs to be exchanged to a suitable medium.'),
'exchange',
required_media_policy,
required_media_type
)
elif op.organizer.settings.reusable_media_usage_enforced:
raise CheckInError(
_('This ticket has already been exchanged for a reusable medium that now needs to be used instead.'),
'already_exchanged',
)
device = None device = None
if isinstance(auth, Device): if isinstance(auth, Device):
device = auth device = auth
+2 -176
View File
@@ -23,13 +23,10 @@ import secrets
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.media import MEDIA_TYPES from pretix.base.models import GiftCardAcceptance
from pretix.base.models import Checkin, GiftCardAcceptance, Item from pretix.base.models.media import MediumKeySet
from pretix.base.models.media import MediumKeySet, ReusableMedium
from pretix.base.services.checkin import CheckInError
def create_nfc_mf0aes_keyset(organizer): def create_nfc_mf0aes_keyset(organizer):
@@ -73,174 +70,3 @@ def get_keysets_for_organizer(organizer):
if new_set: if new_set:
sets.append(new_set) sets.append(new_set)
return sets return sets
def perform_media_exchange(organizer, media_type, identifier, link_orderposition, user, auth):
"""
Create or retrieve a medium, then link the order position to it. Expected to be called in a transaction.
:param organizer: Organizer to operate in
:param media_type: Type of medium to operate with
:param identifier: Identifier of the medium
:param link_orderposition: Position to link to the medium
:return: ReusableMedium
"""
medium = None
media_policy = link_orderposition.item.media_policy
if media_type not in MEDIA_TYPES: # should be caught by serializer already
raise CheckInError(
_('Invalid medium type.'),
Checkin.REASON_ERROR,
reason=_('Invalid medium type.'),
)
if not MEDIA_TYPES[media_type].is_active(organizer):
raise CheckInError(
_('Medium type is not enabled for organizer.'),
Checkin.REASON_ERROR,
reason=_('Medium type is not enabled for organizer.'),
)
if link_orderposition.item.media_type != media_type:
raise CheckInError(
_('Incorrect medium type for product.'),
Checkin.REASON_PRODUCT,
reason=_('Incorrect medium type for product.'),
)
if link_orderposition.linked_media.exists():
raise CheckInError(
_('Ticket is already exchanged for reusable medium.'),
Checkin.REASON_ALREADY_EXCHANGED,
reason=_('Ticket is already exchanged for reusable medium.'),
)
if media_policy in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_NEW):
link_action = "append"
else:
link_action = "replace"
if media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND):
try:
medium = ReusableMedium.objects.get(
type=media_type,
identifier=identifier,
organizer=organizer,
)
except ReusableMedium.DoesNotExist:
raise CheckInError(
_('Reusable medium not found.'),
Checkin.REASON_MEDIUM_INVALID,
reason=_('Reusable medium not found.'),
)
else:
if medium.is_expired or not medium.active:
raise CheckInError(
_('Reusable medium is inactive or expired.'),
Checkin.REASON_MEDIUM_INVALID,
reason=_('Reusable medium is inactive or expired.'),
)
elif media_policy in (Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW):
try:
medium = ReusableMedium.objects.get(
type=media_type,
identifier=identifier,
organizer=organizer,
)
except ReusableMedium.DoesNotExist:
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
raise CheckInError(
_('Reusable medium not found and could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
if not medium:
raise CheckInError(
_('Reusable medium not found and could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
if medium.is_expired or not medium.active:
raise CheckInError(
_('Reusable medium is inactive or expired.'),
Checkin.REASON_MEDIUM_INVALID,
reason=_('Reusable medium is inactive or expired.'),
)
elif media_policy == Item.MEDIA_POLICY_NEW:
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
raise CheckInError(
_('Reusable medium not found and could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
try:
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
except IntegrityError:
raise CheckInError(
_('Reusable medium already exists.'),
Checkin.REASON_MEDIUM_EXISTS,
)
else:
if not medium:
raise CheckInError(
_('Reusable medium could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
else:
raise CheckInError(
_('Product does not support medium exchange.'),
Checkin.REASON_PRODUCT,
reason=_('Product does not support medium exchange.'),
)
if link_action == 'append':
medium.linked_orderpositions.add(link_orderposition)
medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=user,
auth=auth,
data={
'linked_orderposition': link_orderposition,
}
)
elif link_action == 'replace':
already_found = False
for op_pk in medium.linked_orderpositions.values_list('pk', flat=True):
if op_pk == link_orderposition.pk:
already_found = True
continue
else:
medium.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
data={
'linked_orderposition': op_pk,
}
)
if not already_found:
medium.linked_orderpositions.set([link_orderposition])
medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=user,
auth=auth,
data={
'linked_orderposition': link_orderposition,
}
)
link_orderposition.order.log_action(
'pretix.reusable_medium.exchanged',
data={
'position': link_orderposition.pk,
'positionid': link_orderposition.positionid,
'medium': medium.pk,
'medium_identifier': medium.identifier,
'medium_type': medium.media_type.identifier,
}
)
medium.touch()
return medium
+2 -2
View File
@@ -3506,7 +3506,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
from pretix.base.models import ReusableMedium from pretix.base.models import ReusableMedium
for p in order.positions.all(): for p in order.positions.all():
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW): if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[p.item.media_type] mt = MEDIA_TYPES[p.item.media_type]
if mt.medium_created_by_server and not p.linked_media.exists(): if mt.medium_created_by_server and not p.linked_media.exists():
rm = ReusableMedium.objects.create( rm = ReusableMedium.objects.create(
@@ -3515,8 +3515,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
identifier=mt.generate_identifier(sender.organizer), identifier=mt.generate_identifier(sender.organizer),
active=True, active=True,
customer=order.customer, customer=order.customer,
linked_orderposition=p,
) )
rm.linked_orderpositions.add(p)
rm.log_action( rm.log_action(
'pretix.reusable_medium.created', 'pretix.reusable_medium.created',
data={ data={
+3 -16
View File
@@ -211,25 +211,12 @@ DEFAULTS = {
'form_class': forms.BooleanField, 'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Activate reusable media"), label=_("Activate re-usable media"),
help_text=_("The reusable media feature allows you to connect tickets and gift cards with physical media " help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media "
"such as wristbands or chip cards that may be reused for different tickets or gift cards " "such as wristbands or chip cards that may be re-used for different tickets or gift cards "
"later.") "later.")
) )
}, },
'reusable_media_usage_enforced': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Enforce the usage of issued reusable media for check-in"),
help_text=_("If enabled, a ticket barcode will not be accepted anymore, if a reusable medium has been "
"created and linked to a ticket. Keeping this option turned off will treat the reusable "
"medium and ticket as equals."),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-reusable_media_active'}),
)
},
'reusable_media_type_barcode': { 'reusable_media_type_barcode': {
'default': 'False', 'default': 'False',
'type': bool, 'type': bool,
+1 -1
View File
@@ -20,6 +20,6 @@
<div class="container"> <div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</body> </body>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</html> </html>
@@ -2,13 +2,14 @@
{% load i18n %} {% load i18n %}
{% load rich_text %} {% load rich_text %}
{% load static %} {% load static %}
{% load wrap_in %}
{% block title %}{% trans "Redirect" %}{% endblock %} {% block title %}{% trans "Redirect" %}{% endblock %}
{% block content %} {% block content %}
<i class="fa fa-link fa-fw big-icon"></i> <i class="fa fa-link fa-fw big-icon"></i>
<div class="error-details"> <div class="error-details">
<h1>{% trans "Redirect" %}</h1> <h1>{% trans "Redirect" %}</h1>
<h3> <h3>
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %} {% blocktrans trimmed with host=hostname|wrap_in:'strong' %}
The link you clicked on wants to redirect you to a destination on the website {{ host }}. The link you clicked on wants to redirect you to a destination on the website {{ host }}.
{% endblocktrans %} {% endblocktrans %}
{% blocktrans trimmed %} {% blocktrans trimmed %}
-28
View File
@@ -461,31 +461,3 @@ class SalesChannelCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
**super().create_option(name, value, label, selected, index, subindex, attrs), **super().create_option(name, value, label, selected, index, subindex, attrs),
"plugin_missing": plugin and plugin not in self.event.get_plugins(), "plugin_missing": plugin and plugin not in self.event.get_plugins(),
} }
class ModelChoiceIteratorWithNone(forms.models.ModelChoiceIterator):
# see django.forms.models.ModelChoiceIterator for original implementation
def __iter__(self):
if self.field.empty_label is not None:
yield ("", self.field.empty_label)
if self.field.none_label is not None:
yield ("_none", self.field.none_label)
queryset = self.queryset
# Can't use iterator() when queryset uses prefetch_related()
if not queryset._prefetch_related_lookups:
queryset = queryset.iterator()
for obj in queryset:
yield self.choice(obj)
class ModelChoiceFieldWithNone(forms.ModelChoiceField):
iterator = ModelChoiceIteratorWithNone
def __init__(self, *args, **kwargs):
self.none_label = kwargs.pop("none_label", None)
super().__init__(*args, **kwargs)
def to_python(self, value):
if value == "_none":
return value
return super().to_python(value)
+1 -1
View File
@@ -1871,7 +1871,7 @@ class ReusableMediaFilterForm(FilterForm):
Q(identifier__icontains=query) Q(identifier__icontains=query)
| Q(customer__identifier__icontains=query) | Q(customer__identifier__icontains=query)
| Q(customer__external_identifier__istartswith=query) | Q(customer__external_identifier__istartswith=query)
| Q(linked_orderpositions__order__code__icontains=query) | Q(linked_orderposition__order__code__icontains=query)
| Q(linked_giftcard__secret__icontains=query) | Q(linked_giftcard__secret__icontains=query)
) )
+9 -19
View File
@@ -86,7 +86,7 @@ from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import ( from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate, SafeEventMultipleChoiceField, multimail_validate,
) )
from pretix.control.forms.widgets import Select2, Select2Multiple from pretix.control.forms.widgets import Select2
from pretix.multidomain.models import KnownDomain from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -249,15 +249,6 @@ class SafeOrderPositionChoiceField(forms.ModelChoiceField):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})' return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class SafeOrderPositionMultipleChoiceField(forms.ModelMultipleChoiceField):
def __init__(self, queryset, **kwargs):
queryset = queryset.model.all.none()
super().__init__(queryset, **kwargs)
def label_from_instance(self, op):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class EventMetaPropertyForm(I18nModelForm): class EventMetaPropertyForm(I18nModelForm):
class Meta: class Meta:
model = EventMetaProperty model = EventMetaProperty
@@ -636,7 +627,6 @@ class OrganizerSettingsForm(SettingsForm):
'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no', 'cookie_consent_dialog_button_no',
'reusable_media_active', 'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode', 'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length', 'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
@@ -973,12 +963,12 @@ class ReusableMediumUpdateForm(forms.ModelForm):
class Meta: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderpositions', 'notes'] fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes']
field_classes = { field_classes = {
'expires': SplitDateTimeField, 'expires': SplitDateTimeField,
'customer': SafeModelChoiceField, 'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField, 'linked_giftcard': SafeModelChoiceField,
'linked_orderpositions': SafeOrderPositionMultipleChoiceField, 'linked_orderposition': SafeOrderPositionChoiceField,
} }
widgets = { widgets = {
'expires': SplitDateTimePickerWidget, 'expires': SplitDateTimePickerWidget,
@@ -988,8 +978,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
organizer = self.instance.organizer organizer = self.instance.organizer
self.fields['linked_orderpositions'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all() self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
self.fields['linked_orderpositions'].widget = Select2Multiple( self.fields['linked_orderposition'].widget = Select2(
attrs={ attrs={
'data-model-select2': 'generic', 'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={ 'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
@@ -997,8 +987,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
}), }),
} }
) )
self.fields['linked_orderpositions'].widget.choices = self.fields['linked_orderpositions'].choices self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
self.fields['linked_orderpositions'].required = False self.fields['linked_orderposition'].required = False
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all() self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
self.fields['linked_giftcard'].widget = Select2( self.fields['linked_giftcard'].widget = Select2(
@@ -1052,12 +1042,12 @@ class ReusableMediumCreateForm(ReusableMediumUpdateForm):
class Meta: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderpositions', 'linked_giftcard', 'customer', 'notes'] fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes']
field_classes = { field_classes = {
'expires': SplitDateTimeField, 'expires': SplitDateTimeField,
'customer': SafeModelChoiceField, 'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField, 'linked_giftcard': SafeModelChoiceField,
'linked_orderpositions': SafeOrderPositionMultipleChoiceField, 'linked_orderposition': SafeOrderPositionChoiceField,
} }
widgets = { widgets = {
'expires': SplitDateTimePickerWidget, 'expires': SplitDateTimePickerWidget,
+11 -24
View File
@@ -29,30 +29,17 @@ class Select2Mixin:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def options(self, name, value, attrs=None): def options(self, name, value, attrs=None):
if not value or not value[0]: if value and value[0]:
return for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
has_none = "_none" in value yield self.create_option(
if has_none: None,
value = [v for v in value if v != "_none"] self.choices.field.prepare_value(selected),
yield self.create_option( self.choices.field.label_from_instance(selected),
None, True,
"_none", i,
self.choices.field.none_label, subindex=None,
True, attrs=attrs
0, )
subindex=None,
attrs=attrs
)
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
yield self.create_option(
None,
self.choices.field.prepare_value(selected),
self.choices.field.label_from_instance(selected),
True,
i + (1 if has_none else 0),
subindex=None,
attrs=attrs
)
return return
def optgroups(self, name, value, attrs=None): def optgroups(self, name, value, attrs=None):
-3
View File
@@ -743,10 +743,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.reusable_medium.created': _('The reusable medium has been created.'), 'pretix.reusable_medium.created': _('The reusable medium has been created.'),
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'), 'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'), 'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
'pretix.reusable_medium.linked_orderposition.added': _('A new ticket has been added to the medium.'),
'pretix.reusable_medium.linked_orderposition.removed': _('A ticket has been removed from the medium.'),
'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'), 'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'),
'pretix.reusable_medium.exchanged': _('The ticket #{positionid} was exchanged for reusable medium {medium_identifier}.'),
'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'), 'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'),
'pretix.email.error': _('Sending of an email has failed.'), 'pretix.email.error': _('Sending of an email has failed.'),
'pretix.event.comment': _('The event\'s internal comment has been updated.'), 'pretix.event.comment': _('The event\'s internal comment has been updated.'),
-20
View File
@@ -213,16 +213,6 @@ quota as argument in the ``quota`` keyword argument.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
""" """
subevent_detail_html = EventPluginSignal()
"""
Arguments: 'subevent'
This signal allows you to append HTML to a SubEvent's detail view. You receive the
subevent as argument in the ``subevent`` keyword argument.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
organizer_edit_tabs = DeprecatedSignal() organizer_edit_tabs = DeprecatedSignal()
""" """
Arguments: 'organizer', 'request' Arguments: 'organizer', 'request'
@@ -271,16 +261,6 @@ As with all event plugin signals, the ``sender`` keyword argument will contain t
Additionally, the argument ``order`` and ``request`` are available. Additionally, the argument ``order`` and ``request`` are available.
""" """
order_approve_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
This signal is sent out to display additional information on the order approve page
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
"""
order_position_buttons = EventPluginSignal() order_position_buttons = EventPluginSignal()
""" """
Arguments: ``order``, ``position``, ``request`` Arguments: ``order``, ``position``, ``request``
+23 -22
View File
@@ -38,6 +38,7 @@ from pretix import __version__
from pretix.base.models import Order, OrderPayment, Transaction from pretix.base.models import Order, OrderPayment, Transaction
from pretix.base.plugins import get_all_plugins from pretix.base.plugins import get_all_plugins
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import PlainTextParagraph
from pretix.plugins.reports.exporters import ReportlabExportMixin from pretix.plugins.reports.exporters import ReportlabExportMixin
from pretix.settings import DATA_DIR from pretix.settings import DATA_DIR
@@ -79,23 +80,23 @@ class SysReport(ReportlabExportMixin):
style_small.fontSize = 6 style_small.fontSize = 6
story = [ story = [
Paragraph("System report", headlinestyle), PlainTextParagraph("System report", headlinestyle),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
Paragraph("Usage", subheadlinestyle), PlainTextParagraph("Usage", subheadlinestyle),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
self._usage_table(), self._usage_table(),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
Paragraph("Installed versions", subheadlinestyle), PlainTextParagraph("Installed versions", subheadlinestyle),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
self._tech_table(), self._tech_table(),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
Paragraph("Plugins", subheadlinestyle), PlainTextParagraph("Plugins", subheadlinestyle),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
Paragraph(self._get_plugin_versions(), style_small), PlainTextParagraph(self._get_plugin_versions(), style_small),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
Paragraph("Custom templates", subheadlinestyle), PlainTextParagraph("Custom templates", subheadlinestyle),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
Paragraph(self._get_custom_templates(), style_small), PlainTextParagraph(self._get_custom_templates(), style_small),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
] ]
@@ -121,13 +122,13 @@ class SysReport(ReportlabExportMixin):
("RIGHTPADDING", (-1, 0), (-1, -1), 0), ("RIGHTPADDING", (-1, 0), (-1, -1), 0),
] ]
tdata = [ tdata = [
[Paragraph("Site URL:", style), Paragraph(settings.SITE_URL, style)], [PlainTextParagraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
[Paragraph("pretix version:", style), Paragraph(__version__, style)], [PlainTextParagraph("pretix version:", style), Paragraph(__version__, style)],
[Paragraph("Python version:", style), Paragraph(sys.version, style)], [PlainTextParagraph("Python version:", style), Paragraph(sys.version, style)],
[Paragraph("Platform:", style), Paragraph(platform.platform(), style)], [PlainTextParagraph("Platform:", style), Paragraph(platform.platform(), style)],
[ [
Paragraph("Database engine:", style), PlainTextParagraph("Database engine:", style),
Paragraph(settings.DATABASES["default"]["ENGINE"], style), PlainTextParagraph(settings.DATABASES["default"]["ENGINE"], style),
], ],
] ]
table = Table(tdata, colWidths=colwidths, repeatRows=0) table = Table(tdata, colWidths=colwidths, repeatRows=0)
@@ -206,7 +207,7 @@ class SysReport(ReportlabExportMixin):
year_last = now().year year_last = now().year
tdata = [ tdata = [
[ [
Paragraph(l, style_small_head) PlainTextParagraph(l, style_small_head)
for l in ( for l in (
"Time frame", "Time frame",
"Currency", "Currency",
@@ -257,19 +258,19 @@ class SysReport(ReportlabExportMixin):
tdata.append( tdata.append(
( (
Paragraph( PlainTextParagraph(
date_format(first_day, "M Y") date_format(first_day, "M Y")
+ " " + " "
+ date_format(after_day - timedelta(days=1), "M Y"), + date_format(after_day - timedelta(days=1), "M Y"),
style_small, style_small,
), ),
Paragraph(c, style_small), PlainTextParagraph(c, style_small),
Paragraph(str(orders_count), style_small) if i == 0 else "", PlainTextParagraph(str(orders_count), style_small) if i == 0 else "",
Paragraph(money_filter(revenue_data.get("s_net") or 0, c), style_small), PlainTextParagraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
Paragraph(str(testmode_count), style_small) if i == 0 else "", PlainTextParagraph(str(testmode_count), style_small) if i == 0 else "",
Paragraph(str(unconfirmed_count), style_small) if i == 0 else "", PlainTextParagraph(str(unconfirmed_count), style_small) if i == 0 else "",
Paragraph(str(revenue_data.get("c") or 0), style_small), PlainTextParagraph(str(revenue_data.get("c") or 0), style_small),
Paragraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small), PlainTextParagraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
) )
) )
@@ -54,8 +54,6 @@
<span class="fa fa-check-circle"></span> <span class="fa fa-check-circle"></span>
{% elif result.status == "incomplete" %} {% elif result.status == "incomplete" %}
<span class="fa fa-question-circle"></span> <span class="fa fa-question-circle"></span>
{% elif result.status == "exchange" %}
<span class="fa fa-recycle"></span>
{% elif result.status == "error" %} {% elif result.status == "error" %}
{% if result.reason == "already_redeemed" %} {% if result.reason == "already_redeemed" %}
<span class="fa fa-warning"></span> <span class="fa fa-warning"></span>
@@ -81,14 +79,6 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% elif result.status == "exchange" %}
<h3 class="nomargin-top">{% trans "Media exchange required" %}</h3>
<p>
{% blocktrans trimmed with media_policy=media_policies|getitem:result.media_policy media_type=media_types|getitem:result.media_type %}
This ticket needs to be exchanged into a <strong>{{ media_type }}</strong> reusable medium.
<strong>{{ media_policy }}</strong>.
{% endblocktrans %}
</p>
{% elif result.status == "error" %} {% elif result.status == "error" %}
<h3 class="nomargin-top">{{ reason_labels|getitem:result.reason }}</h3> <h3 class="nomargin-top">{{ reason_labels|getitem:result.reason }}</h3>
{% if result.reason_explanation %} {% if result.reason_explanation %}
@@ -9,24 +9,23 @@
<h3 class="panel-title">{% trans "Go offline" %}</h3> <h3 class="panel-title">{% trans "Go offline" %}</h3>
</div> </div>
<div class="row panel-body"> <div class="row panel-body">
<div class="col-sm-12 col-lg-6"> <div class="col-sm-12 col-md-9 nomargin-bottom">
<p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
You can take your event offline. Nobody except your team will be able to see or access it any more. You can take your event offline. Nobody except your team will be able to see or access it any more.
{% endblocktrans %} {% endblocktrans %}
</p> </div>
</div> <div class="col-sm-12 col-md-3">
<form class="col-sm-12 col-lg-6 text-right" <form action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}"
action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}" method="post">
method="post"> {% csrf_token %}
{% csrf_token %} <input type="hidden" name="live" value="false">
<input type="hidden" name="live" value="false">
<button type="submit" class="btn btn-primary btn-lg"> <button type="submit" class="btn btn-primary btn-lg btn-block">
<span class="fa fa-power-off"></span> <span class="fa fa-power-off"></span>
{% trans "Go offline" %} {% trans "Go offline" %}
</button> </button>
</form> </form>
</div>
</div> </div>
</div> </div>
@@ -35,24 +34,22 @@
<h3 class="panel-title">{% trans "Cancel event" %}</h3> <h3 class="panel-title">{% trans "Cancel event" %}</h3>
</div> </div>
<div class="row panel-body"> <div class="row panel-body">
<div class="col-sm-12 col-lg-6"> <div class="col-sm-12 col-md-9 nomargin-bottom">
<p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
If you need to call off your event you want to cancel and refund all tickets, you can do so through If you need to call off your event you want to cancel and refund all tickets, you can do so through
this option. this option.
{% endblocktrans %} {% endblocktrans %}
</p>
</div> </div>
<div class="col-sm-12 col-lg-6 text-right"> <div class="col-sm-12 col-md-3 text-center">
<a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}" {% if "event:cancel" in request.eventpermset %}
class="btn btn-danger btn-lg pull-right {% if "event:cancel" not in request.eventpermset %}disabled{% endif %}"> <a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}"
<span class="fa fa-ban"></span> class="btn btn-danger btn-block btn-lg">
{% if "event:cancel" in request.eventpermset %} <span class="fa fa-ban"></span>
{% trans "Cancel event" %} {% trans "Cancel event" %}
{% else %} </a>
{% trans "No permission" %} {% else %}
{% endif %} {% trans "No permission" %}
</a> {% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -62,16 +59,15 @@
<h3 class="panel-title">{% trans "Delete personal data" %}</h3> <h3 class="panel-title">{% trans "Delete personal data" %}</h3>
</div> </div>
<div class="row panel-body"> <div class="row panel-body">
<div class="col-sm-12 col-lg-6"> <div class="col-sm-12 col-md-9 nomargin-bottom">
<p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
You can remove personal data such as names and email addresses from your event and only retain the You can remove personal data such as names and email addresses from your event and only retain the
financial information such as the number and type of tickets sold. financial information such as the number and type of tickets sold.
{% endblocktrans %} {% endblocktrans %}
</p>
</div> </div>
<div class="col-sm-12 col-lg-6 text-right"> <div class="col-sm-12 col-md-3">
<a href="{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg"> <a href="
{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg btn-block">
<span class="fa fa-eraser"></span> <span class="fa fa-eraser"></span>
{% trans "Delete personal data" %} {% trans "Delete personal data" %}
</a> </a>
@@ -84,17 +80,15 @@
<h3 class="panel-title">{% trans "Delete event" %}</h3> <h3 class="panel-title">{% trans "Delete event" %}</h3>
</div> </div>
<div class="row panel-body"> <div class="row panel-body">
<div class="col-sm-12 col-lg-6"> <div class="col-sm-12 col-md-9 nomargin-bottom">
<p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
You can delete your event completely only as long as it does not contain any undeletable data, such as You can delete your event completely only as long as it does not contain any undeletable data, such as
orders not performed in test mode. orders not performed in test mode.
{% endblocktrans %} {% endblocktrans %}
</p>
</div> </div>
<div class="col-sm-12 col-lg-6 text-right"> <div class="col-sm-12 col-md-3">
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}" <a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg {% if not request.event.allow_delete %}disabled{% endif %}"> class="btn btn-danger btn-block btn-lg {% if not request.event.allow_delete %}disabled{% endif %}">
<span class="fa fa-trash"></span> <span class="fa fa-trash"></span>
{% trans "Delete event" %} {% trans "Delete event" %}
</a> </a>
@@ -1,5 +1,4 @@
{% extends "pretixcontrol/event/base.html" %} {% extends "pretixcontrol/event/base.html" %}
{% load eventsignal %}
{% load i18n %} {% load i18n %}
{% block title %} {% block title %}
{% trans "Approve order" %} {% trans "Approve order" %}
@@ -8,9 +7,6 @@
<h1> <h1>
{% trans "Approve order" %} {% trans "Approve order" %}
</h1> </h1>
{% eventsignal request.event "pretix.control.signals.order_approve_info" order=order request=request %}
<p>{% blocktrans trimmed %} <p>{% blocktrans trimmed %}
Do you really want to approve this order? Do you really want to approve this order?
{% endblocktrans %}</p> {% endblocktrans %}</p>
@@ -222,7 +222,6 @@
<fieldset> <fieldset>
<legend>{% trans "Reusable media" %}</legend> <legend>{% trans "Reusable media" %}</legend>
{% bootstrap_field sform.reusable_media_active layout="control" %} {% bootstrap_field sform.reusable_media_active layout="control" %}
{% bootstrap_field sform.reusable_media_usage_enforced layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_active.id_for_label }}"> <div data-display-dependency="#{{ sform.reusable_media_active.id_for_label }}">
<div class="panel panel-default"> <div class="panel panel-default">
@@ -58,8 +58,8 @@
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a> <a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
</th> </th>
<th>{% trans "Media type" context "reusable_media" %} <th>{% trans "Media type" context "reusable_media" %}
<a href="?{% url_replace request 'ordering' '-type' %}"><i class="fa fa-caret-down"></i></a> <a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'type' %}"><i class="fa fa-caret-up"></i></a></th> <a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Connections" context "reusable_media" %}</th> <th>{% trans "Connections" context "reusable_media" %}</th>
<th></th> <th></th>
</tr> </tr>
@@ -90,13 +90,13 @@
{% endif %} {% endif %}
</span> </span>
{% endif %} {% endif %}
{% for op in m.linked_orderpositions.all %} {% if m.linked_orderposition %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span> <span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=op.order.event.slug organizer=request.organizer.slug code=op.order.code %}"> <a href="{% url "control:event.order" event=m.linked_orderposition.order.event.slug organizer=request.organizer.slug code=m.linked_orderposition.order.code %}">
{{ op.order.code }}</a>-{{ op.positionid }} {{ m.linked_orderposition.order.code }}</a>-{{ m.linked_orderposition.positionid }}
</span> </span>
{% endfor %} {% endif %}
{% if m.linked_giftcard %} {% if m.linked_giftcard %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span> <span class="fa fa-credit-card fa-fw"></span>
@@ -26,19 +26,7 @@
<dt>{% trans "Media type" context "reusable_media" %}</dt> <dt>{% trans "Media type" context "reusable_media" %}</dt>
<dd>{{ medium.get_type_display }}</dd> <dd>{{ medium.get_type_display }}</dd>
<dt>{% trans "Identifier" context "reusable_media" %}</dt> <dt>{% trans "Identifier" context "reusable_media" %}</dt>
<dd> <dd><code>{{ medium.identifier }}</code></dd>
<code id="medium_identifier">{{ medium.identifier }}</code>
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#medium_identifier">
<i class="fa fa-clipboard" aria-hidden="true"></i>
<span class="sr-only">{% trans "Copy to clipboard" %}</span>
</button>
{% if medium.type == "barcode" %}
<button type="button" class="btn btn-default btn-xs js-only" data-toggle="qrcode" data-qrcode="{{ medium.identifier }}">
<i class="fa fa-qrcode" aria-hidden="true"></i>
<span class="sr-only">{% trans "Create QR code" %}</span>
</button>
{% endif %}
</dd>
<dt>{% trans "Status" %}</dt> <dt>{% trans "Status" %}</dt>
<dd> <dd>
{% if not medium.active %} {% if not medium.active %}
@@ -53,34 +41,34 @@
<dd> <dd>
{% if medium.customer %} {% if medium.customer %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-user fa-fw"></span> <span class="fa fa-user fa-fw"></span>
{% if "organizer.customers:read" in request.orgapermset %} {% if "organizer.customers:read" in request.orgapermset %}
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}"> <a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}">
{{ medium.customer }}
</a>
{% else %}
{{ medium.customer }} {{ medium.customer }}
{% endif %} </a>
</span> {% else %}
{{ medium.customer }}
{% endif %}
</span>
{% endif %} {% endif %}
{% for op in medium.linked_orderpositions.all %} {% if medium.linked_orderposition %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span> <span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=op.order.event.slug organizer=request.organizer.slug code=op.order.code %}"> <a href="{% url "control:event.order" event=medium.linked_orderposition.order.event.slug organizer=request.organizer.slug code=medium.linked_orderposition.order.code %}">
{{ op.order.code }}</a>-{{ op.positionid }} {{ medium.linked_orderposition.order.code }}</a>-{{ medium.linked_orderposition.positionid }}
</span> </span>
{% endfor %} {% endif %}
{% if medium.linked_giftcard %} {% if medium.linked_giftcard %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span> <span class="fa fa-credit-card fa-fw"></span>
{% if "organizer.giftcards:read" in request.orgapermset %} {% if "organizer.giftcards:read" in request.orgapermset %}
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}"> <a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}">
{{ medium.linked_giftcard.secret }} {{ medium.linked_giftcard.secret }}
</a> </a>
{% else %} {% else %}
{{ medium.linked_giftcard.secret|slice:":3" }}… {{ medium.linked_giftcard.secret|slice:":3" }}…
{% endif %} {% endif %}
</span> </span>
{% endif %} {% endif %}
</dd> </dd>
{% if medium.notes %} {% if medium.notes %}
@@ -2,7 +2,6 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load getitem %} {% load getitem %}
{% load icon %}
{% block inner %} {% block inner %}
{% if team %} {% if team %}
<h1>{% trans "Team:" %} {{ team.name }}</h1> <h1>{% trans "Team:" %} {{ team.name }}</h1>
@@ -26,18 +25,6 @@
<legend>{% trans "Organizer permissions" %}</legend> <legend>{% trans "Organizer permissions" %}</legend>
{% bootstrap_field form.all_organizer_permissions layout="control" %} {% bootstrap_field form.all_organizer_permissions layout="control" %}
<div class="team-permission-groups col-md-9 col-md-offset-3" data-display-dependency="#id_all_organizer_permissions" data-inverse> <div class="team-permission-groups col-md-9 col-md-offset-3" data-display-dependency="#id_all_organizer_permissions" data-inverse>
<p class="text-muted">
{% icon "info-circle" %}
{% blocktrans trimmed %}
Even if a team has no access to a certain category of data, they might still be able to see
parts of this data when it is linked to data they can see.
{% endblocktrans %}
{% blocktrans trimmed %}
For example, someone with access to customer accounts will be able to see some information
about gift cards linked to a customer account, even if they generally can't see gift cards
directly.
{% endblocktrans %}
</p>
{% for f in form.organizer_field_names %} {% for f in form.organizer_field_names %}
{% bootstrap_field form|getitem:f layout="control" %} {% bootstrap_field form|getitem:f layout="control" %}
{% endfor %} {% endfor %}
@@ -50,17 +37,6 @@
{% bootstrap_field form.limit_events layout="control" %} {% bootstrap_field form.limit_events layout="control" %}
{% bootstrap_field form.all_event_permissions layout="control" %} {% bootstrap_field form.all_event_permissions layout="control" %}
<div class="team-permission-groups col-md-9 col-md-offset-3" data-display-dependency="#id_all_event_permissions" data-inverse> <div class="team-permission-groups col-md-9 col-md-offset-3" data-display-dependency="#id_all_event_permissions" data-inverse>
<p class="text-muted">
{% icon "info-circle" %}
{% blocktrans trimmed %}
Even if a team has no access to a certain category of data, they might still be able to see
parts of this data when it is linked to data they can see.
{% endblocktrans %}
{% blocktrans trimmed %}
For example, someone with access to orders will be able to see some information about
vouchers used to create an order, even if they generally can't see vouchers directly.
{% endblocktrans %}
</p>
{% for f in form.event_field_names %} {% for f in form.event_field_names %}
{% bootstrap_field form|getitem:f layout="control" %} {% bootstrap_field form|getitem:f layout="control" %}
{% endfor %} {% endfor %}
@@ -19,9 +19,7 @@
{% endif %} {% endif %}
</h1> </h1>
<script type="application/json" id="editor-data"> {{ layout|json_script:"editor-data" }}
{{ layout|safe }}
</script>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
<div class="panel panel-default panel-pdf-editor"> <div class="panel panel-default panel-pdf-editor">
@@ -4,306 +4,290 @@
{% load formset_tags %} {% load formset_tags %}
{% load eventsignal %} {% load eventsignal %}
{% load static %} {% load static %}
{% load money %} {% block title %}{% trans "Date" context "subevent" %}{% endblock %}
{% load icon %}
{% block title %}{% blocktrans trimmed with name=subevent.name context "subevent" %}Date: {{ name }}
{% endblocktrans %}{% endblock %}
{% block content %} {% block content %}
<h1> {% if not subevent.pk %}
{% blocktrans trimmed with name=subevent.name context "subevent" %}Date: {{ name }}{% endblocktrans %} <h1>{% trans "Create date" context "subevent" %}</h1>
{% if 'event.subevents:write' in request.eventpermset %} {% else %}
<a href="{% url "control:event.subevent.edit" event=request.event.slug organizer=request.event.organizer.slug subevent=subevent.pk %}" <h1>{% trans "Date" context "subevent" %}</h1>
class="btn btn-default"> {% endif %}
<span class="fa fa-edit"></span> <form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% trans "Edit" %} {% csrf_token %}
</a> {% bootstrap_form_errors form %}
{% endif %} {% for f in itemvar_forms %}
</h1> {% bootstrap_form_errors f %}
<div class="row"> {% endfor %}
<div class="{% if "event.orders:read" in request.eventpermset %}col-md-5{% else %}col-md-10{% endif %} col-xs-12"> <div class="row">
<fieldset> <div class="col-xs-12 {% if subevent.pk %}col-lg-10{% endif %}">
<legend>{% trans "General information" %}</legend>
<dl class="dl-horizontal">
<dt>{% trans "Name" %}</dt>
<dd>{{ subevent.name }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>#{{ subevent.pk }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if not subevent.active %}
<span class="label label-danger">{% trans "Disabled" %}</span>
{% elif subevent.presale_has_ended %}
<span class="label label-warning">{% trans "Presale over" %}</span>
{% elif not subevent.presale_is_running %}
<span class="label label-warning">{% trans "Presale not started" %}</span>
{% else %}
<span class="label label-success">{% trans "On sale" %}</span>
{% endif %}
</dd>
<dt>{% trans "Event start time" %}</dt>
<dd>{{ subevent.date_from|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Event end time" %}</dt>
<dd>{{ subevent.date_to|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% if subevent.date_admission %}
<dt>{% trans "Admission time" %}</dt>
<dd>{{ subevent.date_admission|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if subevent.presale_start %}
<dt>{% trans "Start of presale" %}</dt>
<dd>{{ subevent.presale_start|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if subevent.presale_end %}
<dt>{% trans "End of presale" %}</dt>
<dd>{{ subevent.presale_end|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if subevent.location %}
<dt>{% trans "Location" %}</dt>
<dd>{{ subevent.location|linebreaksbr }}</dd>
{% endif %}
<dt>{% trans "Show in lists" %}</dt>
<dd>{{ subevent.is_public|yesno }}</dd>
{% for k, v in subevent.meta_data.items %}
<dt>{{ k }}</dt>
<dd>{{ v }}</dd>
{% endfor %}
{% if subevent.comment %}
<dt>{% trans "Internal comment" %}</dt>
<dd>{{ subevent.comment|linebreaksbr }}</dd>
{% endif %}
</dl>
</fieldset>
<fieldset>
<legend>{% trans "Quotas" %}</legend>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Quota name" %}</th>
<th>{% trans "Products" %}</th>
<th>{% trans "Total capacity" %}</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in quotas %}
<tr>
<td>
<strong><a
href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong>
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip"
title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td>
<td class="text-right flip">
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</fieldset>
{% if checkinlists %}
<fieldset> <fieldset>
<legend>{% trans "Check-in lists" %}</legend> <legend>{% trans "General information" %}</legend>
<div class="table-responsive"> {% bootstrap_field form.name layout="control" %}
<table class="table table-hover table-quotas"> {% bootstrap_field form.active layout="control" %}
<thead> {% bootstrap_field form.date_from layout="control" %}
<tr> {% bootstrap_field form.date_to layout="control" %}
<th>{% trans "Name" %}</th> {% include "pretixcontrol/event/fragment_geodata.html" %}
{% if "event.orders:read" in request.eventpermset %} {% bootstrap_field form.date_admission layout="control" %}
<th>{% trans "Checked in" %}</th> {% bootstrap_field form.frontpage_text layout="control" %}
{% endif %} {% bootstrap_field form.is_public layout="control" %}
<th>{% trans "Products" %}</th> {% bootstrap_field form.comment layout="control" %}
<th class="action-col-2"></th> {% if meta_forms %}
</tr> <div class="form-group metadata-group">
</thead> <label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<tbody> <div class="col-md-9">
{% for cl in checkinlists %} {% for form in meta_forms %}
<tr> <div class="row">
<td> <div class="col-md-4">
<strong><a <label for="{{ form.value.id_for_label }}">
href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}">{{ cl.name }}</a></strong> {{ form.property.name }}
</td> </label>
{% if "event.orders:read" in request.eventpermset %} </div>
<td> <div class="col-md-8">
<div class="quotabox availability"> {% bootstrap_form form layout="inline" error_types="all" %}
<div class="progress"> </div>
<div class="progress-bar progress-bar-success progress-bar-{{ cl.percent }}"> </div>
</div> {% endfor %}
</div>
</div>
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Timeline" %}</legend>
{% bootstrap_field form.presale_start layout="control" %}
{% bootstrap_field form.presale_end layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Quotas" %}</legend>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field form.name layout='inline' form_group_class="" %}
</div> </div>
<div class="numbers"> <div class="col-md-2 text-right flip">
{{ cl.checkin_count|default_if_none:"0" }} / <button type="button" class="btn btn-danger" data-formset-delete-button>
{{ cl.position_count|default_if_none:"0" }} <i class="fa fa-trash"></i></button>
</div> </div>
</div> </div>
</td> </h4>
{% endif %} </div>
<td> <div class="panel-body form-horizontal">
{% if cl.all_products %} {% bootstrap_form_errors form %}
<em>{% trans "All" %}</em> {% bootstrap_field form.size layout="control" %}
{% else %} {% bootstrap_field form.itemvars layout="control" %}
<ul> {% bootstrap_field form.release_after_exit layout="control" %}
{% for item in cl.limit_products.all %} {% bootstrap_field form.ignore_for_event_availability layout="control" %}
<li> </div>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a> </div>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td class="text-right flip">
{% if "event.orders:read" in request.eventpermset %}
<a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}"
class="btn btn-default btn-sm"><i class="fa fa-eye"></i></a>
{% endif %}
{% if "event.settings.general:write" in request.eventpermset %}
<a href="{% url "control:event.orders.checkinlists.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ cl.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}"
data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %}
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </div>
</table> <script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field formset.empty_form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</h4>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.size layout="control" %}
{% bootstrap_field formset.empty_form.itemvars layout="control" %}
{% bootstrap_field formset.empty_form.release_after_exit layout="control" %}
{% bootstrap_field formset.empty_form.ignore_for_event_availability layout="control" %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new quota" %}</button>
</p>
</fieldset> </fieldset>
{% endif %}
{% eventsignal request.event "pretix.control.signals.subevent_detail_html" subevent=subevent %}
</div>
{% if "event.orders:read" in request.eventpermset %}
<div class="col-md-5 col-xs-12">
<fieldset> <fieldset>
<legend> <legend>{% trans "Product settings" %}</legend>
{% trans "Orders" %} <p class="text-muted">
<span class="badge"> {% trans "These settings are optional, if you leave them empty, the default values from the product settings will be used." %}
{{ order_count }} </p>
</span> {% for f in itemvar_forms %}
</legend> <div data-itemvar="{{ f.item.id }}{% if f.variation %}-{{ f.variation.id }}{% endif %}">
{% if order_count %} {% bootstrap_form_errors f %}
<div class="table-responsive"> <div class="form-group subevent-itemvar-group">
<table class="table table-condensed table-hover table-orders"> <label class="col-md-3 control-label" for="id_{{ f.prefix }}-price">
<thead> {% if f.variation %}{{ f.item }} {{ f.variation }}{% else %}{{ f.item }}{% endif %}
<tr> </label>
<th>{% trans "Order code" %}</th> <div class="col-md-4">
<th>{% trans "Details" %}</th> <label for="{{ f.price.id_for_label }}" class="text-muted">{% trans "Price" %}</label><br>
</tr> {% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="inline" %}
</thead> </div>
<tbody> <div class="col-md-4">
{% for o in orders %} <br>
<tr> {% bootstrap_field f.disabled layout="inline" form_group_class="" %}
<td> </div>
<strong> </div>
<a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=o.code %}"> <div class="form-group subevent-itemvar-group">
{{ o.code }} <div class="col-md-4 col-md-offset-3">
</a> <label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label>
</strong> {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br>
<br> {% bootstrap_field f.available_from form_group_class="foo" layout="inline" %}
{% if o.testmode %} </div>
<span class="label label-warning">{% trans "TEST MODE" %}</span> <div class="col-md-4">
{% endif %} <label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label>
{% if o.status == "p" and o.pcnt == 0 %} {% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{# Everything related to this subevent is canceled #} {% bootstrap_field f.available_until form_group_class="" layout="inline" %}
<span class="label label-danger"> </div>
<span class="fa fa-times"></span> </div>
{% trans "partially canceled" %}
</span>
{% else %}
{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}
{% endif %}
</td>
<td>
{% if "." in o.sales_channel.icon %}
<img src="{% static o.sales_channel.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ o.sales_channel.label }}">
{% else %}
<span class="fa fa-fw fa-{{ o.sales_channel.icon }} text-muted"
data-toggle="tooltip" title="{{ o.sales_channel.label }}"></span>
{% endif %}
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if o.email %}
<br>{% icon "envelope-o fa-fw text-muted" %}
{{ o.email|default_if_none:"" }}
{% endif %}
{% if o.invoice_address.name %}
<br>{% icon "user fa-fw text-muted" %} {{ o.invoice_address.name }}
{% endif %}
<br>{% icon "ticket text-muted fa-fw" %} {{ o.pcnt }}
{% if o.comment %}
<br>
<span class="text-muted">
{{ o.comment|linebreaksbr }}
</span>
{% endif %}
{% if o.custom_followup_due %}
<br>
<span class="label label-danger">{% blocktrans trimmed with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}
TODO {{ date }}{% endblocktrans %}</span>
{% elif o.custom_followup_at %}
<br>
<span class="label label-default">{% blocktrans trimmed with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}
TODO {{ date }}{% endblocktrans %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% if order_count > 10 %} {% endfor %}
<p class="text-center"> </fieldset>
<a href="{% url "control:event.orders" organizer=request.organizer.slug event=request.event.slug %}?subevent={{ subevent.pk }}" <fieldset>
class="btn btn-default"> <legend>{% trans "Check-in lists" %}</legend>
{% trans "View all" %} <p class="help-block">
</a> {% blocktrans trimmed %}
</p> You can choose to either add one or more check-in lists for every date in your series individually,
{% endif %} or use just one check-in list for all your dates and limit admission through check-in rules. Which
{% else %} approach is better depends on multiple factors, such as the number of dates in your series. For a
<div class="empty-collection"> series with one or less event date per day, individual lists are usually more helpful. If you
<p> use dates to represent many time slots on the same day, or even overlapping time slots, working with
{% blocktrans trimmed %} just one large check-in list will be easier.
No orders found. {% endblocktrans %}
{% endblocktrans %} </p>
</p> <div class="formset" data-formset data-formset-prefix="{{ cl_formset.prefix }}">
{{ cl_formset.management_form }}
{% bootstrap_formset_errors cl_formset %}
<div data-formset-body>
{% for form in cl_formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</h4>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.include_pending layout="control" %}
{% bootstrap_field form.all_products layout="control" %}
{% bootstrap_field form.limit_products layout="control" %}
{% bootstrap_field form.allow_entry_after_exit layout="control" %}
{% if form.gates %}
{% bootstrap_field form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endfor %}
</div> </div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ cl_formset.empty_form.id }}
{% bootstrap_field cl_formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field cl_formset.empty_form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</h4>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field cl_formset.empty_form.include_pending layout="control" %}
{% bootstrap_field cl_formset.empty_form.all_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.limit_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.allow_entry_after_exit layout="control" %}
{% if cl_formset.empty_form.gates %}
{% bootstrap_field cl_formset.empty_form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new check-in list" %}
</button>
</p>
</fieldset>
{% for f in plugin_forms %}
{% if f.title %}
<fieldset>
<legend>{{ f.title }}</legend>
{% if f.template %}
{% include f.template with form=f %}
{% else %}
{% bootstrap_form f layout="control" %}
{% endif %}
</fieldset>
{% endif %} {% endif %}
{% endfor %}
<fieldset>
<legend>{% trans "Additional settings" %}</legend>
{% for f in plugin_forms %}
{% if not f.title %}
{% if f.template %}
{% include f.template with form=f %}
{% else %}
{% bootstrap_form f layout="control" %}
{% endif %}
{% endif %}
{% endfor %}
</fieldset> </fieldset>
</div> </div>
{% endif %} {% if subevent.pk %}
<div class="col-md-2 col-xs-12"> <div class="col-xs-12 col-lg-2">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title"> <h3 class="panel-title">
{% trans "Date history" context "subevent" %} {% trans "Date history" context "subevent" %}
</h3> </h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=subevent %}
</div>
</div> </div>
{% include "pretixcontrol/includes/logs.html" with obj=subevent %} {% endif %}
</div>
</div> </div>
</div> <div class="form-group submit-group submit-group-sticky">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %} {% endblock %}
@@ -1,296 +0,0 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% load eventsignal %}
{% load static %}
{% block title %}{% trans "Date" context "subevent" %}{% endblock %}
{% block content %}
{% if not subevent.pk %}
<h1>{% trans "Create date" context "subevent" %}</h1>
{% else %}
<h1>{% trans "Date" context "subevent" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% for f in itemvar_forms %}
{% bootstrap_form_errors f %}
{% endfor %}
<div class="row">
<div class="col-xs-12 {% if subevent.pk %}col-lg-10{% endif %}">
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.active layout="control" %}
{% bootstrap_field form.date_from layout="control" %}
{% bootstrap_field form.date_to layout="control" %}
{% include "pretixcontrol/event/fragment_geodata.html" %}
{% bootstrap_field form.date_admission layout="control" %}
{% bootstrap_field form.frontpage_text layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.comment layout="control" %}
{% if meta_forms %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<div class="col-md-9">
{% for form in meta_forms %}
<div class="row">
<div class="col-md-4">
<label for="{{ form.value.id_for_label }}">
{{ form.property.name }}
</label>
</div>
<div class="col-md-8">
{% bootstrap_form form layout="inline" error_types="all" %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Timeline" %}</legend>
{% bootstrap_field form.presale_start layout="control" %}
{% bootstrap_field form.presale_end layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Quotas" %}</legend>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</h4>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.size layout="control" %}
{% bootstrap_field form.itemvars layout="control" %}
{% bootstrap_field form.release_after_exit layout="control" %}
{% bootstrap_field form.ignore_for_event_availability layout="control" %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field formset.empty_form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</h4>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.size layout="control" %}
{% bootstrap_field formset.empty_form.itemvars layout="control" %}
{% bootstrap_field formset.empty_form.release_after_exit layout="control" %}
{% bootstrap_field formset.empty_form.ignore_for_event_availability layout="control" %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new quota" %}</button>
</p>
</fieldset>
<fieldset>
<legend>{% trans "Product settings" %}</legend>
<p class="text-muted">
{% trans "These settings are optional, if you leave them empty, the default values from the product settings will be used." %}
</p>
{% for f in itemvar_forms %}
<div data-itemvar="{{ f.item.id }}{% if f.variation %}-{{ f.variation.id }}{% endif %}">
{% bootstrap_form_errors f %}
<div class="form-group subevent-itemvar-group">
<label class="col-md-3 control-label" for="id_{{ f.prefix }}-price">
{% if f.variation %}{{ f.item }} {{ f.variation }}{% else %}{{ f.item }}{% endif %}
</label>
<div class="col-md-4">
<label for="{{ f.price.id_for_label }}" class="text-muted">{% trans "Price" %}</label><br>
{% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
<br>
{% bootstrap_field f.disabled layout="inline" form_group_class="" %}
</div>
</div>
<div class="form-group subevent-itemvar-group">
<div class="col-md-4 col-md-offset-3">
<label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br>
{% bootstrap_field f.available_from form_group_class="foo" layout="inline" %}
</div>
<div class="col-md-4">
<label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{% bootstrap_field f.available_until form_group_class="" layout="inline" %}
</div>
</div>
</div>
{% endfor %}
</fieldset>
<fieldset>
<legend>{% trans "Check-in lists" %}</legend>
<p class="help-block">
{% blocktrans trimmed %}
You can choose to either add one or more check-in lists for every date in your series individually,
or use just one check-in list for all your dates and limit admission through check-in rules. Which
approach is better depends on multiple factors, such as the number of dates in your series. For a
series with one or less event date per day, individual lists are usually more helpful. If you
use dates to represent many time slots on the same day, or even overlapping time slots, working with
just one large check-in list will be easier.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ cl_formset.prefix }}">
{{ cl_formset.management_form }}
{% bootstrap_formset_errors cl_formset %}
<div data-formset-body>
{% for form in cl_formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</h4>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.include_pending layout="control" %}
{% bootstrap_field form.all_products layout="control" %}
{% bootstrap_field form.limit_products layout="control" %}
{% bootstrap_field form.allow_entry_after_exit layout="control" %}
{% if form.gates %}
{% bootstrap_field form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ cl_formset.empty_form.id }}
{% bootstrap_field cl_formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<h4 class="panel-title">
<div class="row">
<div class="col-md-10">
{% bootstrap_field cl_formset.empty_form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</h4>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field cl_formset.empty_form.include_pending layout="control" %}
{% bootstrap_field cl_formset.empty_form.all_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.limit_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.allow_entry_after_exit layout="control" %}
{% if cl_formset.empty_form.gates %}
{% bootstrap_field cl_formset.empty_form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new check-in list" %}
</button>
</p>
</fieldset>
{% for f in plugin_forms %}
{% if f.title %}
<fieldset>
<legend>{{ f.title }}</legend>
{% if f.template %}
{% include f.template with form=f %}
{% else %}
{% bootstrap_form f layout="control" %}
{% endif %}
</fieldset>
{% endif %}
{% endfor %}
<fieldset>
<legend>{% trans "Additional settings" %}</legend>
{% for f in plugin_forms %}
{% if not f.title %}
{% if f.template %}
{% include f.template with form=f %}
{% else %}
{% bootstrap_form f layout="control" %}
{% endif %}
{% endif %}
{% endfor %}
</fieldset>
</div>
{% if subevent.pk %}
<div class="col-xs-12 col-lg-2">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Date history" context "subevent" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=subevent %}
</div>
</div>
{% endif %}
</div>
<div class="form-group submit-group submit-group-sticky">
<a href="{{ next_url }}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -133,7 +133,7 @@
</td> </td>
{% endif %} {% endif %}
<td> <td>
<strong><a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=s.id %}"> <strong><a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=s.id %}?returnto={{ request.GET.urlencode|urlencode }}">
{{ s.name }}</a></strong><br> {{ s.name }}</a></strong><br>
<small class="text-muted"> <small class="text-muted">
#{{ s.pk }} #{{ s.pk }}
@@ -182,7 +182,7 @@
{% endif %} {% endif %}
{% if "event.subevents:write" in request.eventpermset %} {% if "event.subevents:write" in request.eventpermset %}
<a href="{% url "control:event.subevent.edit" organizer=request.event.organizer.slug event=request.event.slug subevent=s.id %}?next={{ request.get_full_path|urlencode }}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> <a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=s.id %}?returnto={{ request.GET.urlencode|urlencode }}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<div class="btn-group {% if forloop.revcounter0 < 2 %}dropup{% endif %}"> <div class="btn-group {% if forloop.revcounter0 < 2 %}dropup{% endif %}">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" <button type="button" class="btn btn-default btn-sm dropdown-toggle"
data-toggle="dropdown"> data-toggle="dropdown">
@@ -201,7 +201,7 @@
</li> </li>
</ul> </ul>
</div> </div>
<a href="{% url "control:event.subevent.delete" organizer=request.event.organizer.slug event=request.event.slug subevent=s.id %}?next={{ request.get_full_path|urlencode }}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> <a href="{% url "control:event.subevent.delete" organizer=request.event.organizer.slug event=request.event.slug subevent=s.id %}?returnto={{ request.GET.urlencode|urlencode }}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
+1 -2
View File
@@ -308,8 +308,7 @@ urlpatterns = [
re_path(r'^pdf/editor/(?P<filename>[^/]+).pdf$', pdf.PdfView.as_view(), name='pdf.background'), re_path(r'^pdf/editor/(?P<filename>[^/]+).pdf$', pdf.PdfView.as_view(), name='pdf.background'),
re_path(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'), re_path(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'),
re_path(r'^subevents/select2$', typeahead.subevent_select2, name='event.subevents.select2'), re_path(r'^subevents/select2$', typeahead.subevent_select2, name='event.subevents.select2'),
re_path(r'^subevents/(?P<subevent>\d+)/$', subevents.SubEventDetail.as_view(), name='event.subevent'), re_path(r'^subevents/(?P<subevent>\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'),
re_path(r'^subevents/(?P<subevent>\d+)/edit$', subevents.SubEventUpdate.as_view(), name='event.subevent.edit'),
re_path(r'^subevents/(?P<subevent>\d+)/delete$', subevents.SubEventDelete.as_view(), re_path(r'^subevents/(?P<subevent>\d+)/delete$', subevents.SubEventDelete.as_view(),
name='event.subevent.delete'), name='event.subevent.delete'),
re_path(r'^subevents/add$', subevents.SubEventCreate.as_view(), name='event.subevents.add'), re_path(r'^subevents/add$', subevents.SubEventCreate.as_view(), name='event.subevents.add'),
+2 -5
View File
@@ -50,7 +50,7 @@ from i18nfield.strings import LazyI18nString
from pretix.api.views.checkin import _redeem_process from pretix.api.views.checkin import _redeem_process
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, Item, LogEntry, Order, OrderPosition from pretix.base.models import Checkin, LogEntry, Order, OrderPosition
from pretix.base.models.checkin import CheckinList from pretix.base.models.checkin import CheckinList
from pretix.base.models.orders import PrintLog from pretix.base.models.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf from pretix.base.permissions import AnyPermissionOf
@@ -401,14 +401,13 @@ class CheckinListUpdate(EventPermissionRequiredMixin, UpdateView):
{ {
'id': i.pk, 'id': i.pk,
'name': str(i), 'name': str(i),
'active': i.active,
'variations': [ 'variations': [
{ {
'id': v.pk, 'id': v.pk,
'name': str(v.value) 'name': str(v.value)
} for v in i.variations.all() } for v in i.variations.all()
] ]
} for i in self.request.event.items.prefetch_related('variations') } for i in self.request.event.items.filter(active=True).prefetch_related('variations')
], ],
**super().get_context_data(), **super().get_context_data(),
} }
@@ -533,8 +532,6 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView):
checkinlist=self.list, checkinlist=self.list,
result=self.result, result=self.result,
reason_labels=dict(Checkin.REASONS), reason_labels=dict(Checkin.REASONS),
media_policies=dict(Item.MEDIA_POLICIES),
media_types=dict(MEDIA_TYPES),
) )
def form_valid(self, form): def form_valid(self, form):
+2 -2
View File
@@ -44,7 +44,7 @@ from pretix.control.permissions import (
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from ...helpers.compat import CompatDeleteView from ...helpers.compat import CompatDeleteView
from . import CreateView, UpdateView from . import CreateView, PaginationMixin, UpdateView
class DiscountDelete(EventPermissionRequiredMixin, CompatDeleteView): class DiscountDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -183,7 +183,7 @@ class DiscountCreate(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form) return super().form_invalid(form)
class DiscountList(ListView): class DiscountList(PaginationMixin, ListView):
model = Discount model = Discount
context_object_name = 'discounts' context_object_name = 'discounts'
template_name = 'pretixcontrol/items/discounts.html' template_name = 'pretixcontrol/items/discounts.html'
+1 -1
View File
@@ -335,7 +335,7 @@ class CategoryCreate(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form) return super().form_invalid(form)
class CategoryList(ListView): class CategoryList(PaginationMixin, ListView):
model = ItemCategory model = ItemCategory
context_object_name = 'categories' context_object_name = 'categories'
template_name = 'pretixcontrol/items/categories.html' template_name = 'pretixcontrol/items/categories.html'
-1
View File
@@ -396,7 +396,6 @@ class OrderDeleteBulkActionView(BaseOrderBulkActionView):
def execute_single(self, instance, form: forms.Form): def execute_single(self, instance, form: forms.Form):
instance.gracefully_delete(user=self.request.user) instance.gracefully_delete(user=self.request.user)
return True
class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin, ListView): class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):
+7 -40
View File
@@ -3384,10 +3384,8 @@ class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequire
def get_queryset(self): def get_queryset(self):
qs = self.request.organizer.reusable_media.select_related( qs = self.request.organizer.reusable_media.select_related(
'customer', 'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event',
'linked_giftcard', 'linked_giftcard'
).prefetch_related(
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order", "order__event"))
) )
if self.filter_form.is_valid(): if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs) qs = self.filter_form.filter_qs(qs)
@@ -3435,14 +3433,10 @@ class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
r = super().form_valid(form) r = super().form_valid(form)
form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data={
data = {
k: getattr(form.instance, k) k: getattr(form.instance, k)
for k in form.changed_data for k in form.changed_data
} })
if "linked_orderpositions" in data:
data["linked_orderpositions"] = data["linked_orderpositions"].values_list("pk", flat=True)
form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data=data)
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
return r return r
@@ -3467,40 +3461,13 @@ class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
prev_linked_ops_pks = list(getattr(self.object, "linked_orderpositions").values_list("pk", flat=True))
result = super().form_valid(form)
if form.has_changed(): if form.has_changed():
data = { self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data={
k: getattr(self.object, k) k: getattr(self.object, k)
for k in form.changed_data for k in form.changed_data
} })
if "linked_orderpositions" in data:
# handle changes to linked_orderpositions separately
linked_ops_pks = data["linked_orderpositions"].values_list("pk", flat=True)
del data["linked_orderpositions"]
for op_pk in prev_linked_ops_pks:
if op_pk not in linked_ops_pks:
self.object.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
user=self.request.user,
data={
'linked_orderposition': op_pk,
}
)
for op_pk in linked_ops_pks:
if op_pk not in prev_linked_ops_pks:
self.object.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=self.request.user,
data={
'linked_orderposition': op_pk,
}
)
if data:
# log change-action only for changes other than linked_orderpositions
self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data=data)
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
return result return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
return reverse('control:organizer.reusable_medium', kwargs={ return reverse('control:organizer.reusable_medium', kwargs={
+1 -1
View File
@@ -284,7 +284,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
ctx['pdf'] = self.get_current_background() ctx['pdf'] = self.get_current_background()
ctx['variables'] = self.get_variables() ctx['variables'] = self.get_variables()
ctx['images'] = self.get_images() ctx['images'] = self.get_images()
ctx['layout'] = json.dumps(self.get_current_layout()) ctx['layout'] = self.get_current_layout()
ctx['title'] = self.title ctx['title'] = self.title
ctx['locales'] = [p for p in settings.LANGUAGES if p[0] in self.request.event.settings.locales] ctx['locales'] = [p for p in settings.LANGUAGES if p[0] in self.request.event.settings.locales]
ctx['maxfilesize'] = self.maxfilesize ctx['maxfilesize'] = self.maxfilesize
+8 -81
View File
@@ -41,9 +41,7 @@ from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files import File from django.core.files import File
from django.db import transaction from django.db import transaction
from django.db.models import ( from django.db.models import Count, F, Prefetch, ProtectedError
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Subquery,
)
from django.db.models.functions import Coalesce, TruncDate, TruncTime from django.db.models.functions import Coalesce, TruncDate, TruncTime
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from django.http import Http404, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseRedirect
@@ -51,21 +49,17 @@ from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.formats import get_format from django.utils.formats import get_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.views import View from django.views import View
from django.views.generic import ( from django.views.generic import CreateView, FormView, ListView, UpdateView
CreateView, DetailView, FormView, ListView, UpdateView,
)
from pretix.base.models import CartPosition, LogEntry, OrderPosition from pretix.base.models import CartPosition, LogEntry
from pretix.base.models.checkin import CheckinList from pretix.base.models.checkin import CheckinList
from pretix.base.models.event import SubEvent, SubEventMetaValue from pretix.base.models.event import SubEvent, SubEventMetaValue
from pretix.base.models.items import ( from pretix.base.models.items import (
Item, ItemVariation, Quota, SubEventItem, SubEventItemVariation, ItemVariation, Quota, SubEventItem, SubEventItemVariation,
) )
from pretix.base.models.orders import CancellationRequest
from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.services import tickets from pretix.base.services import tickets
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
@@ -511,68 +505,9 @@ class SubEventEditorMixin(MetaDataEditorMixin):
) and self.cl_formset.is_valid() and all(f.is_valid() for f in self.plugin_forms) ) and self.cl_formset.is_valid() and all(f.is_valid() for f in self.plugin_forms)
class SubEventDetail(EventPermissionRequiredMixin, DetailView):
model = SubEvent
template_name = 'pretixcontrol/subevents/detail.html'
permission = None
context_object_name = 'subevent'
def get_object(self, queryset=None) -> SubEvent:
try:
return self.request.event.subevents.get(
id=self.kwargs['subevent']
)
except SubEvent.DoesNotExist:
raise Http404(pgettext_lazy("subevent", "The requested date does not exist."))
def get_context_data(self, **kwargs):
oqs = self.request.event.orders.filter(
Exists(
OrderPosition.objects.filter(
subevent=self.object,
order_id=OuterRef("id"),
)
)
).annotate(
pcnt=Subquery(
OrderPosition.objects.filter(
subevent=self.object,
).values("subevent").annotate(c=Count("*")).values("c")
),
has_cancellation_request=Exists(CancellationRequest.objects.filter(order=OuterRef("pk"))),
).select_related("invoice_address").prefetch_related("sales_channel")
ctx = {
"quotas": self.object.quotas.prefetch_related(
Prefetch(
"items",
queryset=Item.objects.annotate(
has_variations=Exists(ItemVariation.objects.filter(item=OuterRef("pk")))
),
to_attr="cached_items"
),
"variations",
"variations__item",
).order_by("name", "pk"),
"checkinlists": self.object.checkinlist_set.prefetch_related("limit_products"),
"orders": oqs[:11],
"order_count": oqs.count(),
}
qa = QuotaAvailability()
qa.queue(*ctx["quotas"])
qa.compute()
for quota in ctx["quotas"]:
quota.cached_avail = qa.results[quota]
return super().get_context_data(
**kwargs,
**ctx,
)
class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateView): class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateView):
model = SubEvent model = SubEvent
template_name = 'pretixcontrol/subevents/edit.html' template_name = 'pretixcontrol/subevents/detail.html'
permission = 'event.subevents:write' permission = 'event.subevents:write'
context_object_name = 'subevent' context_object_name = 'subevent'
form_class = SubEventForm form_class = SubEventForm
@@ -638,28 +573,20 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
def get_success_url(self) -> str: def get_success_url(self) -> str:
if "next" in self.request.GET and url_has_allowed_host_and_scheme(self.request.GET.get("next"), allowed_hosts=None): return reverse('control:event.subevents', kwargs={
return self.request.GET.get("next")
return reverse('control:event.subevent', kwargs={
'organizer': self.request.event.organizer.slug, 'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug, 'event': self.request.event.slug,
'subevent': self.object.pk, }) + ('?' + self.request.GET.get('returnto') if 'returnto' in self.request.GET else '')
})
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event kwargs['event'] = self.request.event
return kwargs return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(
next_url=self.get_success_url()
)
class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateView): class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateView):
model = SubEvent model = SubEvent
template_name = 'pretixcontrol/subevents/edit.html' template_name = 'pretixcontrol/subevents/detail.html'
permission = 'event.subevents:write' permission = 'event.subevents:write'
context_object_name = 'subevent' context_object_name = 'subevent'
form_class = SubEventForm form_class = SubEventForm
+7 -26
View File
@@ -145,21 +145,11 @@ def event_list(request):
if 'can_copy' in request.GET: if 'can_copy' in request.GET:
qs = EventWizardCopyForm.copy_from_queryset(request.user, request.session) qs = EventWizardCopyForm.copy_from_queryset(request.user, request.session)
else: else:
permission = request.GET.get('permission') qs = request.user.get_events_with_any_permission(request)
if permission:
qs = request.user.get_events_with_permission(permission, request)
else:
qs = request.user.get_events_with_any_permission(request)
name_slug_q = Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query)
organizer = request.GET.get('organizer')
if organizer:
qs = qs.filter(organizer__slug=organizer)
else:
name_slug_q |= Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
qs = qs.filter( qs = qs.filter(
name_slug_q Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) |
Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
).annotate( ).annotate(
min_from=Min('subevents__date_from'), min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'), max_from=Max('subevents__date_from'),
@@ -172,19 +162,10 @@ def event_list(request):
total = qs.count() total = qs.count()
pagesize = 20 pagesize = 20
offset = (page - 1) * pagesize offset = (page - 1) * pagesize
results = []
if page == 1 and 'include_none' in request.GET and not query:
results.append({
'id': "_none",
'text': _("No event"),
'name': _("No event"),
'type': "event",
})
results += [
serialize_event(e) for e in qs.select_related('organizer')[offset:offset + pagesize]
]
doc = { doc = {
'results': results, 'results': [
serialize_event(e) for e in qs.select_related('organizer')[offset:offset + pagesize]
],
'pagination': { 'pagination': {
"more": total >= (offset + pagesize) "more": total >= (offset + pagesize)
} }
@@ -200,7 +181,7 @@ def giftcard_select2(request, **kwargs):
except ValueError: except ValueError:
page = 1 page = 1
if request.user.has_organizer_permission(request.organizer, 'organizer.giftcards:read', request): if request.user.has_organizer_permission(request.organizer, 'organizer.giftcards:write', request):
qs = request.organizer.issued_gift_cards.filter( qs = request.organizer.issued_gift_cards.filter(
Q(secret__icontains=query) Q(secret__icontains=query)
).order_by('secret') ).order_by('secret')
+5
View File
@@ -29,3 +29,8 @@ class PretixHelpersConfig(AppConfig):
def ready(self): def ready(self):
from .monkeypatching import monkeypatch_all_at_ready from .monkeypatching import monkeypatch_all_at_ready
monkeypatch_all_at_ready() monkeypatch_all_at_ready()
# Ensure reportlab does not make any calls to the internet or the local disk
from reportlab import rl_config
rl_config.trustedHosts = []
rl_config.trustedSchemes = ['data']
+21
View File
@@ -27,6 +27,7 @@ from datetime import datetime
from http import cookies from http import cookies
from django.conf import settings from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from PIL import Image from PIL import Image
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.connection import HTTPConnection, HTTPSConnection from urllib3.connection import HTTPConnection, HTTPSConnection
@@ -40,6 +41,8 @@ from urllib3.util.connection import (
) )
from urllib3.util.timeout import _DEFAULT_TIMEOUT from urllib3.util.timeout import _DEFAULT_TIMEOUT
from pretix.helpers.reportlab import ThumbnailingImageReader
_cgnat_net = ipaddress.ip_network('100.64.0.0/10') _cgnat_net = ipaddress.ip_network('100.64.0.0/10')
@@ -230,9 +233,27 @@ def monkeypatch_cookie_morsel():
cookies.Morsel._reserved.setdefault("partitioned", "Partitioned") cookies.Morsel._reserved.setdefault("partitioned", "Partitioned")
def monkeypatch_reportlab_imagereader():
from reportlab.lib import utils
old_init = utils.ImageReader.__init__
def new_init(self, fileName, ident=None): # noqa
if not isinstance(fileName, Image.Image) and not hasattr(fileName, 'read') and not hasattr(fileName, 'str'):
if not isinstance(self, ThumbnailingImageReader):
# ThumbnailingImageReader is only used by us explicitly and not by using <img> in html, so it is safe
raise SuspiciousFileOperation("reportlab should not be reading images from disk")
return types.MethodType(old_init, self)(
fileName, ident
)
utils.ImageReader.__init__ = new_init
def monkeypatch_all_at_ready(): def monkeypatch_all_at_ready():
monkeypatch_vobject_performance() monkeypatch_vobject_performance()
monkeypatch_pillow_safer() monkeypatch_pillow_safer()
monkeypatch_requests_timeout() monkeypatch_requests_timeout()
monkeypatch_urllib3_ssrf_protection() monkeypatch_urllib3_ssrf_protection()
monkeypatch_cookie_morsel() monkeypatch_cookie_morsel()
monkeypatch_reportlab_imagereader()
+39
View File
@@ -20,14 +20,19 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
import re
import unicodedata
from arabic_reshaper import ArabicReshaper from arabic_reshaper import ArabicReshaper
from bidi import get_display
from django.conf import settings from django.conf import settings
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.utils.html import escape
from PIL import Image from PIL import Image
from reportlab.lib.styles import ParagraphStyle from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph from reportlab.platypus import Paragraph
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -70,6 +75,20 @@ reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
})) }))
def normalize_text(text: str) -> str:
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFKC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "\n".join(get_display(reshaper.reshape(l)) for l in re.split("\n", text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
class FontFallbackParagraph(Paragraph): class FontFallbackParagraph(Paragraph):
def __init__(self, text, style=None, *args, **kwargs): def __init__(self, text, style=None, *args, **kwargs):
if style is None: if style is None:
@@ -87,6 +106,8 @@ class FontFallbackParagraph(Paragraph):
if not text: if not text:
return True return True
font = pdfmetrics.getFont(font_name) font = pdfmetrics.getFont(font_name)
if not isinstance(font, TTFont):
return True
return all( return all(
ord(c) in font.face.charToGlyph or not c.isprintable() ord(c) in font.face.charToGlyph or not c.isprintable()
for c in text for c in text
@@ -102,6 +123,24 @@ class FontFallbackParagraph(Paragraph):
return family return family
class PlainTextParagraph(FontFallbackParagraph):
def __init__(self, text, style=None, linebreaks=True, *args, **kwargs):
if not isinstance(text, str):
if hasattr(text, '__html__'):
raise ValueError("It is contradictory to pass escaped content to PlainTextParagraph")
text = str(text)
# Normalize unicode and apply reshaping
text = normalize_text(text)
# Escape any HTML in the text
text = escape(text)
if linebreaks:
text = text.strip().replace("\n", "<br />\n")
super().__init__(text, style, *args, **kwargs)
def register_ttf_font_if_new(name, path): def register_ttf_font_if_new(name, path):
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
@@ -0,0 +1,33 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix 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 logging
from django import template
from django.utils.html import format_html
register = template.Library()
logger = logging.getLogger(__name__)
@register.filter
def wrap_in(content, tag_name):
return format_html(f'<{tag_name}>{{}}</{tag_name}>', content)
+6 -6
View File
@@ -5,16 +5,16 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n" "POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-09 20:00+0000\n" "PO-Revision-Date: 2026-05-27 15:20+0000\n"
"Last-Translator: Mira <weller@rami.io>\n" "Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/de/"
"de/>\n" ">\n"
"Language: de\n" "Language: de\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.6.1\n" "X-Generator: Weblate 2026.5\n"
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n" "X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670 #: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
@@ -11353,7 +11353,7 @@ msgid ""
msgstr "" msgstr ""
"Wenn diese Option deaktiviert ist, werden Tickets nur für Produkte " "Wenn diese Option deaktiviert ist, werden Tickets nur für Produkte "
"aktiviert, bei denen die Option \"Berechtigt zum Eintritt\" gesetzt ist. Sie " "aktiviert, bei denen die Option \"Berechtigt zum Eintritt\" gesetzt ist. Sie "
"können die Ticketgenerierung auch in den Einstellungen jedes Produktes " "können die Ticketgenerierung auch in den Einstellungen von jedes Produktes "
"einzeln abschalten." "einzeln abschalten."
#: pretix/base/settings.py:1813 #: pretix/base/settings.py:1813
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n" "POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-09 20:00+0000\n" "PO-Revision-Date: 2026-05-27 15:20+0000\n"
"Last-Translator: Mira <weller@rami.io>\n" "Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/" "Language-Team: German (informal) <https://translate.pretix.eu/projects/"
"pretix/pretix/de_Informal/>\n" "pretix/pretix/de_Informal/>\n"
"Language: de_Informal\n" "Language: de_Informal\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.6.1\n" "X-Generator: Weblate 2026.5\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670 #: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166 #: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -11339,8 +11339,8 @@ msgid ""
"issuing in every product separately." "issuing in every product separately."
msgstr "" msgstr ""
"Wenn diese Option deaktiviert ist, werden Tickets nur für Produkte " "Wenn diese Option deaktiviert ist, werden Tickets nur für Produkte "
"aktiviert, bei denen die Option \"Berechtigt zum Eintritt\" gesetzt ist. Du " "aktiviert, bei denen die Option \"Berechtigt zum Eintritt\" gesetzt ist. Sie "
"kannst die Ticketgenerierung auch in den Einstellungen jedes Produktes " "können die Ticketgenerierung auch in den Einstellungen von jedes Produktes "
"einzeln abschalten." "einzeln abschalten."
#: pretix/base/settings.py:1813 #: pretix/base/settings.py:1813
+84 -50
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n" "POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-05-29 17:00+0000\n" "PO-Revision-Date: 2026-05-01 21:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n" "Last-Translator: Paul Berschick <paul@plainschwarz.com>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/"
"es/>\n" "es/>\n"
"Language: es\n" "Language: es\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.5\n" "X-Generator: Weblate 5.17\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670 #: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166 #: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -620,17 +620,16 @@ msgstr ""
"como variaciones o paquetes." "como variaciones o paquetes."
#: pretix/api/webhooks.py:413 #: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed" msgid "Quota changed"
msgstr "Se ha modificado la cuota" msgstr "Gestión de cuotas"
#: pretix/api/webhooks.py:414 #: pretix/api/webhooks.py:414
msgid "" msgid ""
"This includes related events like creation, deletion, opening or closing of " "This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability." "quotas. No webhook is sent for changes to the resulting availability."
msgstr "" msgstr ""
"Esto incluye acciones relacionadas, como la creación, la eliminación, la "
"apertura o el cierre de cuotas. No se envía ningún webhook cuando se "
"producen cambios en la disponibilidad resultante."
#: pretix/api/webhooks.py:419 #: pretix/api/webhooks.py:419
msgid "Shop taken live" msgid "Shop taken live"
@@ -3419,13 +3418,11 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as " "The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"." "\"%(chars)s\"."
msgstr "" msgstr ""
"El campo «%(label)s» no puede contener caracteres especiales como «%(chars)s"
"»."
#: pretix/base/forms/questions.py:305 #: pretix/base/forms/questions.py:305
#, python-format #, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)." msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr "El campo «%(label)s» no puede contener una URL (%(url)s)." msgstr ""
#: pretix/base/forms/questions.py:338 #: pretix/base/forms/questions.py:338
msgctxt "phonenumber" msgctxt "phonenumber"
@@ -8364,14 +8361,19 @@ msgid "Program times"
msgstr "Horarios del programa" msgstr "Horarios del programa"
#: pretix/base/pdf.py:503 #: pretix/base/pdf.py:503
#, fuzzy
#| msgid ""
#| "2017-05-31 10:00 12:00\n"
#| "2017-05-31 14:00 16:00\n"
#| "2017-05-31 14:00 2017-06-01 14:00"
msgid "" msgid ""
"2017-05-31 10:00 12:00, Room 1\n" "2017-05-31 10:00 12:00, Room 1\n"
"2017-05-31 14:00 16:00, Room 2\n" "2017-05-31 14:00 16:00, Room 2\n"
"2017-05-31 14:00 2017-06-01 14:00, Building A" "2017-05-31 14:00 2017-06-01 14:00, Building A"
msgstr "" msgstr ""
"31 de mayo de 2017, de 10:00 a 12:00, Sala 1\n" "2017-05-31 10:00 12:00\n"
"31 de mayo de 2017, de 14:00 a 16:00, Sala 2\n" "2017-05-31 14:00 16:00\n"
"31 de mayo de 2017, de 14:00 a 1 de junio de 2017, 14:00, Edificio A" "2017-05-31 14:00 2017-06-01 14:00"
#: pretix/base/pdf.py:507 #: pretix/base/pdf.py:507
msgid "Reusable Medium ID" msgid "Reusable Medium ID"
@@ -8901,7 +8903,13 @@ msgid "This voucher code is not known in our database."
msgstr "Este vale de compra no se conoce en nuestra base de datos." msgstr "Este vale de compra no se conoce en nuestra base de datos."
#: pretix/base/services/cart.py:165 #: pretix/base/services/cart.py:165
#, python-format #, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
msgid "" msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product." "%(number)s matching product."
@@ -8909,14 +8917,22 @@ msgid_plural ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching products." "%(number)s matching products."
msgstr[0] "" msgstr[0] ""
"El código de descuento «%(voucher)s» solo se puede utilizar si seleccionas " "El vale de compra \"%(voucher)s\" solo se puede utilizar si selecciona al "
"al menos%(number)s productos que cumplan los requisitos." "menos %(number)s productos coincidentes."
msgstr[1] "" msgstr[1] ""
"El código de descuento «%(voucher)s» solo se puede utilizar si seleccionas " "Los vales de compra \"%(voucher)s\" solo se pueden utilizar si selecciona al "
"al menos %(number)s productos que cumplan los requisitos." "menos %(number)s productos coincidentes."
#: pretix/base/services/cart.py:170 #: pretix/base/services/cart.py:170
#, python-format #, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
msgid "" msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product. We have therefore removed some positions from " "%(number)s matching product. We have therefore removed some positions from "
@@ -8926,15 +8942,13 @@ msgid_plural ""
"%(number)s matching products. We have therefore removed some positions from " "%(number)s matching products. We have therefore removed some positions from "
"your cart that can no longer be purchased like this." "your cart that can no longer be purchased like this."
msgstr[0] "" msgstr[0] ""
"El código promocional «%(voucher)s» solo se puede utilizar si seleccionas al " "El vale de compra \"%(voucher)s\" solo se puede utilizar si selecciona al "
"menos %(number)s producto que cumpla los requisitos. Por lo tanto, hemos " "menos %(number)s productos coincidentes. Por lo tanto, hemos eliminado "
"eliminado de tu carrito algunos artículos que ya no se pueden comprar de " "algunas posiciones de su carrito que ya no se pueden comprar así."
"esta forma."
msgstr[1] "" msgstr[1] ""
"El código promocional «%(voucher)s» solo se puede utilizar si seleccionas al " "Los vale de compra \"%(voucher)s\" solo se pueden utilizar si selecciona al "
"menos %(number)s productos que cumplan los requisitos. Por lo tanto, hemos " "menos %(number)s productos coincidentes. Por lo tanto, hemos eliminado "
"eliminado de tu carrito algunos artículos que ya no se pueden comprar de " "algunas posiciones de su carrito que ya no se pueden comprar así."
"esta forma."
#: pretix/base/services/cart.py:176 #: pretix/base/services/cart.py:176
msgid "" msgid ""
@@ -14240,8 +14254,6 @@ msgid ""
"You entered an URL, which is not allowed. Please remove %(match)s from your " "You entered an URL, which is not allowed. Please remove %(match)s from your "
"input." "input."
msgstr "" msgstr ""
"Ha introducido una URL que no está permitida. Elimina %(match)s de su "
"entrada."
#: pretix/base/views/errors.py:48 #: pretix/base/views/errors.py:48
msgid "" msgid ""
@@ -16182,8 +16194,14 @@ msgid "inactive"
msgstr "inactivo" msgstr "inactivo"
#: pretix/control/forms/item.py:1414 #: pretix/control/forms/item.py:1414
#, fuzzy
#| msgid ""
#| "Sample Conference Center\n"
#| "Heidelberg, Germany"
msgid "Sample Conference Center, Heidelberg, Germany" msgid "Sample Conference Center, Heidelberg, Germany"
msgstr "Ejemplo de Centro de Conferencia : Heidelberg, Alemania" msgstr ""
"Ejemplo de Centro de Conferencia \n"
"Heidelberg, Alemania"
#: pretix/control/forms/mailsetup.py:42 #: pretix/control/forms/mailsetup.py:42
msgid "Hostname" msgid "Hostname"
@@ -23641,8 +23659,11 @@ msgid "Quota history"
msgstr "Historial de cuotas" msgstr "Historial de cuotas"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6 #: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6
#, fuzzy
#| msgctxt "subevent"
#| msgid "Change multiple dates"
msgid "Change multiple quotas" msgid "Change multiple quotas"
msgstr "Modificar varias cuotas" msgstr "Cambiar varias fechas"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8 #: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8
#: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8 #: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8
@@ -23692,15 +23713,18 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6
#, fuzzy
#| msgid "Delete quota"
msgid "Delete quotas" msgid "Delete quotas"
msgstr "Eliminar cuotas" msgstr "Borrar cuota"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10
#, python-format #, fuzzy, python-format
#| msgid "Are you sure you want to delete the following dates?"
msgid "Are you sure you want to delete the following quota?" msgid "Are you sure you want to delete the following quota?"
msgid_plural "Are you sure you want to delete the following %(num)s quotas?" msgid_plural "Are you sure you want to delete the following %(num)s quotas?"
msgstr[0] "¿Está seguro de que desea eliminar la siguiente cuota?" msgstr[0] "¿Está seguro de que desea borrar las fechas siguientes?"
msgstr[1] "¿Está seguro de que desea eliminar las siguientes %(num)s cuotas?" msgstr[1] "¿Está seguro de que desea borrar las fechas siguientes?"
#: pretix/control/templates/pretixcontrol/items/quotas.html:9 #: pretix/control/templates/pretixcontrol/items/quotas.html:9
msgid "" msgid ""
@@ -24305,15 +24329,12 @@ msgid ""
"generated once the customer pays the invoice or selects a payment method " "generated once the customer pays the invoice or selects a payment method "
"that requires an invoice." "that requires an invoice."
msgstr "" msgstr ""
"Este pedido se modificó después de que se generara la última factura. Aún no "
"se ha generado una nueva factura, ya que las facturas están configuradas "
"para generarse al realizar el pago o si así lo exige la forma de pago. Se "
"generará una nueva factura una vez que el cliente abone la factura o "
"seleccione una forma de pago que requiera una factura."
#: pretix/control/templates/pretixcontrol/order/index.html:152 #: pretix/control/templates/pretixcontrol/order/index.html:152
#, fuzzy
#| msgid "Request invoice"
msgid "Reissue invoice" msgid "Reissue invoice"
msgstr "Reemitir factura" msgstr "Solicitar factura"
#: pretix/control/templates/pretixcontrol/order/index.html:161 #: pretix/control/templates/pretixcontrol/order/index.html:161
#: pretix/control/templates/pretixcontrol/order/index.html:413 #: pretix/control/templates/pretixcontrol/order/index.html:413
@@ -24744,16 +24765,23 @@ msgid "How should the refund be sent?"
msgstr "¿Cómo se debe de realizar este reembolso?" msgstr "¿Cómo se debe de realizar este reembolso?"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:25 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:25
#, fuzzy
#| msgid ""
#| "Any payments that you selected for automatical refunds will be "
#| "immediately communicate the refund request to the respective payment "
#| "provider. Manual refunds will be created as pending refunds, you can then "
#| "later mark them as done once you actually transferred the money back to "
#| "the customer."
msgid "" msgid ""
"Any payments you selected for automatic refunds will have the refund request " "Any payments you selected for automatic refunds will have the refund request "
"sent immediately to the respective payment provider. Manual refunds will be " "sent immediately to the respective payment provider. Manual refunds will be "
"created as pending refunds, which you can later mark as done once you have " "created as pending refunds, which you can later mark as done once you have "
"actually transferred the money back to the customer." "actually transferred the money back to the customer."
msgstr "" msgstr ""
"Los pagos que hayas seleccionado para reembolsos automáticos se enviarán " "Cualquier pago que haya seleccionado de manera automática para reembolso "
"inmediatamente al proveedor de pagos correspondiente. Los reembolsos " "será comunicado inmediatamente a la entidad de pago correspondiente. Los "
"manuales se crearán como reembolsos pendientes, que podrás marcar como " "devoluciones manuales se crearán como reembolsos pendientes, podrá marcarlos "
"completados más adelante, una vez que hayas devuelto el dinero al cliente." "como hechos una vez que se haya transferido el dinero al cliente."
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:32 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:32
msgid "Refund to original payment method" msgid "Refund to original payment method"
@@ -29309,8 +29337,11 @@ msgid "The new question has been created."
msgstr "La nueva pregunta ha sido creada." msgstr "La nueva pregunta ha sido creada."
#: pretix/control/views/item.py:918 #: pretix/control/views/item.py:918
#, fuzzy
#| msgctxt "subevent"
#| msgid "The selected dates have been deleted or disabled."
msgid "The selected quotas have been deleted or disabled." msgid "The selected quotas have been deleted or disabled."
msgstr "Las cuotas seleccionadas se han eliminado o desactivado." msgstr "Las fechas seleccionadas se han borrado o desactivado."
#: pretix/control/views/item.py:1074 #: pretix/control/views/item.py:1074
msgid "The new quota has been created." msgid "The new quota has been created."
@@ -30042,9 +30073,11 @@ msgstr ""
"Este plugin no está permitido actualmente para su cuenta de organizador." "Este plugin no está permitido actualmente para su cuenta de organizador."
#: pretix/control/views/organizer.py:832 #: pretix/control/views/organizer.py:832
#, python-brace-format #, fuzzy, python-brace-format
#| msgid "This plugin can be enabled or disabled for events individually."
msgid "This plugin cannot be activated for event {}." msgid "This plugin cannot be activated for event {}."
msgstr "Este complemento no se puede activar para el evento {}." msgstr ""
"Este plugin se puede activar o desactivar para eventos de forma individual."
#: pretix/control/views/organizer.py:901 #: pretix/control/views/organizer.py:901
msgid "The team has been created. You can now add members to the team." msgid "The team has been created. You can now add members to the team."
@@ -31089,9 +31122,10 @@ msgid "{width} x {height} mm label"
msgstr "etiqueta {width} x {height} mm" msgstr "etiqueta {width} x {height} mm"
#: pretix/plugins/badges/templates.py:265 #: pretix/plugins/badges/templates.py:265
#, python-brace-format #, fuzzy, python-brace-format
#| msgid "{width} x {height} mm label"
msgid "{width} x {height} inch label" msgid "{width} x {height} inch label"
msgstr "Etiqueta de {width} x {height} pulgadas" msgstr "etiqueta {width} x {height} mm"
#: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16 #: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16
#: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27 #: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27
+82 -44
View File
@@ -4,16 +4,16 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n" "POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-08 17:00+0000\n" "PO-Revision-Date: 2026-05-08 04:00+0000\n"
"Last-Translator: Sébastien BRUNEAU <s.bruneau@beauvaisis.fr>\n" "Last-Translator: corentin-spec <corentin@spectentaculaire.fr>\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/fr/"
"fr/>\n" ">\n"
"Language: fr\n" "Language: fr\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n" "Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 2026.6.1\n" "X-Generator: Weblate 5.17.1\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670 #: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166 #: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -618,17 +618,16 @@ msgstr ""
"aux objets imbriqués tels que les variantes ou les lots." "aux objets imbriqués tels que les variantes ou les lots."
#: pretix/api/webhooks.py:413 #: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed" msgid "Quota changed"
msgstr "Quota modifié" msgstr "Traitement des quotas"
#: pretix/api/webhooks.py:414 #: pretix/api/webhooks.py:414
msgid "" msgid ""
"This includes related events like creation, deletion, opening or closing of " "This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability." "quotas. No webhook is sent for changes to the resulting availability."
msgstr "" msgstr ""
"Cela inclut les événements associés, tels que la création, la suppression, "
"l'ouverture ou la suppression de quotas. Aucun webhook n'est envoyé en cas "
"de modification de la disponibilité qui en résulte."
#: pretix/api/webhooks.py:419 #: pretix/api/webhooks.py:419
msgid "Shop taken live" msgid "Shop taken live"
@@ -3423,13 +3422,11 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as " "The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"." "\"%(chars)s\"."
msgstr "" msgstr ""
"Le champ « %(label)s » ne doit pas contenir de caractères spéciaux tels que "
"«%(chars)s »."
#: pretix/base/forms/questions.py:305 #: pretix/base/forms/questions.py:305
#, python-format #, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)." msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr "Le champ « %(label)s » ne doit pas contenir d'URL (%(url)s)." msgstr ""
#: pretix/base/forms/questions.py:338 #: pretix/base/forms/questions.py:338
msgctxt "phonenumber" msgctxt "phonenumber"
@@ -8412,14 +8409,19 @@ msgid "Program times"
msgstr "Horaires du programme" msgstr "Horaires du programme"
#: pretix/base/pdf.py:503 #: pretix/base/pdf.py:503
#, fuzzy
#| msgid ""
#| "2017-05-31 10:00 12:00\n"
#| "2017-05-31 14:00 16:00\n"
#| "2017-05-31 14:00 2017-06-01 14:00"
msgid "" msgid ""
"2017-05-31 10:00 12:00, Room 1\n" "2017-05-31 10:00 12:00, Room 1\n"
"2017-05-31 14:00 16:00, Room 2\n" "2017-05-31 14:00 16:00, Room 2\n"
"2017-05-31 14:00 2017-06-01 14:00, Building A" "2017-05-31 14:00 2017-06-01 14:00, Building A"
msgstr "" msgstr ""
"31 mai 2017, de 10 h à 12 h, salle 1\n" "2017-05-31 10:00 12:00\n"
"31 mai 2017, de 14 h à 16 h, salle 2\n" "2017-05-31 14:00 16:00\n"
"Du 31 mai 2017 à 1 h du matin au 1er juin 2017 à 14 h, bâtiment A" "2017-05-31 14:00 2017-06-01 14:00"
#: pretix/base/pdf.py:507 #: pretix/base/pdf.py:507
msgid "Reusable Medium ID" msgid "Reusable Medium ID"
@@ -8955,7 +8957,13 @@ msgid "This voucher code is not known in our database."
msgstr "Ce code promotionnel n'est pas connu dans notre base de données." msgstr "Ce code promotionnel n'est pas connu dans notre base de données."
#: pretix/base/services/cart.py:165 #: pretix/base/services/cart.py:165
#, python-format #, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
msgid "" msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product." "%(number)s matching product."
@@ -8963,14 +8971,22 @@ msgid_plural ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching products." "%(number)s matching products."
msgstr[0] "" msgstr[0] ""
"Le code promo « %(voucher)s » ne peut être utilisé que si vous sélectionnez " "Le code promo \"%(voucher)s\" ne peut être utilisé que si vous sélectionnez "
"au moins %(number)s produit correspondant." "au moins %(number)s produit correspondant."
msgstr[1] "" msgstr[1] ""
"Le code promo « %(voucher)s » ne peut être utilisé que si vous sélectionnez " "Le code promo \"%(voucher)s\" ne peut être utilisé que si vous sélectionnez "
"au moins %(number)s produits correspondants." "au moins %(number)s produits correspondants."
#: pretix/base/services/cart.py:170 #: pretix/base/services/cart.py:170
#, python-format #, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
msgid "" msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product. We have therefore removed some positions from " "%(number)s matching product. We have therefore removed some positions from "
@@ -14363,8 +14379,6 @@ msgid ""
"You entered an URL, which is not allowed. Please remove %(match)s from your " "You entered an URL, which is not allowed. Please remove %(match)s from your "
"input." "input."
msgstr "" msgstr ""
"Vous avez saisi une URL, ce qui n'est pas autorisé. Veuillez supprimer %"
"(match)s de votre saisie."
#: pretix/base/views/errors.py:48 #: pretix/base/views/errors.py:48
msgid "" msgid ""
@@ -16314,8 +16328,14 @@ msgid "inactive"
msgstr "inactif" msgstr "inactif"
#: pretix/control/forms/item.py:1414 #: pretix/control/forms/item.py:1414
#, fuzzy
#| msgid ""
#| "Sample Conference Center\n"
#| "Heidelberg, Germany"
msgid "Sample Conference Center, Heidelberg, Germany" msgid "Sample Conference Center, Heidelberg, Germany"
msgstr "Centre de conférences d'exemple, Heidelberg, Allemagne" msgstr ""
"Exemple de centre de conférence\n"
"Centre des Congrès, France"
#: pretix/control/forms/mailsetup.py:42 #: pretix/control/forms/mailsetup.py:42
msgid "Hostname" msgid "Hostname"
@@ -23811,8 +23831,11 @@ msgid "Quota history"
msgstr "Historique des quotas" msgstr "Historique des quotas"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6 #: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6
#, fuzzy
#| msgctxt "subevent"
#| msgid "Change multiple dates"
msgid "Change multiple quotas" msgid "Change multiple quotas"
msgstr "Modifier plusieurs quotas" msgstr "Modifier plusieurs dates"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8 #: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8
#: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8 #: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8
@@ -23860,15 +23883,18 @@ msgstr "Les produits suivants pourraient ne plus être disponibles à la vente
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6
#, fuzzy
#| msgid "Delete quota"
msgid "Delete quotas" msgid "Delete quotas"
msgstr "Supprimer les quotas" msgstr "Supprimer le quota"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10
#, python-format #, fuzzy, python-format
#| msgid "Are you sure you want to delete the following dates?"
msgid "Are you sure you want to delete the following quota?" msgid "Are you sure you want to delete the following quota?"
msgid_plural "Are you sure you want to delete the following %(num)s quotas?" msgid_plural "Are you sure you want to delete the following %(num)s quotas?"
msgstr[0] "Êtes-vous sûr de vouloir supprimer le quota suivant?" msgstr[0] "Voulez-vous vraiment supprimer les dates suivantes ?"
msgstr[1] "Êtes-vous sûr de vouloir supprimer les %(num)s quotas suivants?" msgstr[1] "Voulez-vous vraiment supprimer les dates suivantes ?"
#: pretix/control/templates/pretixcontrol/items/quotas.html:9 #: pretix/control/templates/pretixcontrol/items/quotas.html:9
msgid "" msgid ""
@@ -24477,15 +24503,12 @@ msgid ""
"generated once the customer pays the invoice or selects a payment method " "generated once the customer pays the invoice or selects a payment method "
"that requires an invoice." "that requires an invoice."
msgstr "" msgstr ""
"Cette commande a été modifiée après l'émission de la dernière facture. "
"Aucune nouvelle facture n'a encore été générée, car les factures sont "
"configurées pour être émises lors du paiement ou si le mode de paiement "
"l'exige. Une nouvelle facture sera générée dès que le client aura réglé la "
"facture ou choisi un mode de paiement nécessitant une facture."
#: pretix/control/templates/pretixcontrol/order/index.html:152 #: pretix/control/templates/pretixcontrol/order/index.html:152
#, fuzzy
#| msgid "Request invoice"
msgid "Reissue invoice" msgid "Reissue invoice"
msgstr "Réémettre une facture" msgstr "Demande de facture"
#: pretix/control/templates/pretixcontrol/order/index.html:161 #: pretix/control/templates/pretixcontrol/order/index.html:161
#: pretix/control/templates/pretixcontrol/order/index.html:413 #: pretix/control/templates/pretixcontrol/order/index.html:413
@@ -24919,17 +24942,25 @@ msgid "How should the refund be sent?"
msgstr "Comment le remboursement doit-il être envoyé ?" msgstr "Comment le remboursement doit-il être envoyé ?"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:25 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:25
#, fuzzy
#| msgid ""
#| "Any payments that you selected for automatical refunds will be "
#| "immediately communicate the refund request to the respective payment "
#| "provider. Manual refunds will be created as pending refunds, you can then "
#| "later mark them as done once you actually transferred the money back to "
#| "the customer."
msgid "" msgid ""
"Any payments you selected for automatic refunds will have the refund request " "Any payments you selected for automatic refunds will have the refund request "
"sent immediately to the respective payment provider. Manual refunds will be " "sent immediately to the respective payment provider. Manual refunds will be "
"created as pending refunds, which you can later mark as done once you have " "created as pending refunds, which you can later mark as done once you have "
"actually transferred the money back to the customer." "actually transferred the money back to the customer."
msgstr "" msgstr ""
"Pour tous les paiements que vous avez sélectionnés pour un remboursement " "Tous les paiements que vous avez sélectionnés pour des remboursements "
"automatique, la demande de remboursement sera immédiatement transmise au " "automatiques seront immédiatement communiqués à la demande de remboursement "
"prestataire de paiement concerné. Les remboursements manuels seront " "au fournisseur de paiement respectif. Les remboursements manuels seront "
"enregistrés comme remboursements en attente; vous pourrez les marquer comme " "créés en tant que remboursements en attente, vous pourrez ensuite les "
"effectués une fois que vous aurez effectivement reversé l'argent au client." "marquer comme terminés une fois que vous aurez effectivement transféré "
"largent au client."
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:32 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:32
msgid "Refund to original payment method" msgid "Refund to original payment method"
@@ -29527,8 +29558,11 @@ msgid "The new question has been created."
msgstr "La nouvelle question a été créée." msgstr "La nouvelle question a été créée."
#: pretix/control/views/item.py:918 #: pretix/control/views/item.py:918
#, fuzzy
#| msgctxt "subevent"
#| msgid "The selected dates have been deleted or disabled."
msgid "The selected quotas have been deleted or disabled." msgid "The selected quotas have been deleted or disabled."
msgstr "Les quotas sélectionnés ont été supprimés ou désactivés." msgstr "Les dates sélectionnées ont été supprimées ou désactivées."
#: pretix/control/views/item.py:1074 #: pretix/control/views/item.py:1074
msgid "The new quota has been created." msgid "The new quota has been created."
@@ -30268,9 +30302,12 @@ msgstr ""
"Ce plugin n'est actuellement pas autorisé pour ce compte d'organisateur." "Ce plugin n'est actuellement pas autorisé pour ce compte d'organisateur."
#: pretix/control/views/organizer.py:832 #: pretix/control/views/organizer.py:832
#, python-brace-format #, fuzzy, python-brace-format
#| msgid "This plugin can be enabled or disabled for events individually."
msgid "This plugin cannot be activated for event {}." msgid "This plugin cannot be activated for event {}."
msgstr "Ce plugin ne peut pas être activé pour l'événement {}." msgstr ""
"Ce plugin peut être activé ou désactivé individuellement pour chaque "
"événement."
#: pretix/control/views/organizer.py:901 #: pretix/control/views/organizer.py:901
msgid "The team has been created. You can now add members to the team." msgid "The team has been created. You can now add members to the team."
@@ -31325,9 +31362,10 @@ msgid "{width} x {height} mm label"
msgstr "{width} x {height} mm étiquette" msgstr "{width} x {height} mm étiquette"
#: pretix/plugins/badges/templates.py:265 #: pretix/plugins/badges/templates.py:265
#, python-brace-format #, fuzzy, python-brace-format
#| msgid "{width} x {height} mm label"
msgid "{width} x {height} inch label" msgid "{width} x {height} inch label"
msgstr "{width} x {height} pouce étiquette" msgstr "{width} x {height} mm étiquette"
#: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16 #: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16
#: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27 #: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27
@@ -35123,7 +35161,7 @@ msgstr "Vous devez cocher toutes les cases en bas de la page."
#: pretix/presale/forms/checkout.py:67 #: pretix/presale/forms/checkout.py:67
msgid "Email address (repeated)" msgid "Email address (repeated)"
msgstr "Adresse de courriel (répétée)" msgstr "Adresse de courriel (répété)"
#: pretix/presale/forms/checkout.py:68 #: pretix/presale/forms/checkout.py:68
msgid "" msgid ""
+71 -34
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n" "POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-01 09:00+0000\n" "PO-Revision-Date: 2026-05-12 06:34+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n" "Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n" "ja/>\n"
"Language: ja\n" "Language: ja\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.5\n" "X-Generator: Weblate 5.17.1\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670 #: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166 #: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -608,20 +608,20 @@ msgstr ""
"更を含みます。" "更を含みます。"
#: pretix/api/webhooks.py:413 #: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed" msgid "Quota changed"
msgstr "クォータが変更されました" msgstr "クォータの処理"
#: pretix/api/webhooks.py:414 #: pretix/api/webhooks.py:414
msgid "" msgid ""
"This includes related events like creation, deletion, opening or closing of " "This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability." "quotas. No webhook is sent for changes to the resulting availability."
msgstr "" msgstr ""
"これには、クォータの作成、削除、開始または終了といった関連イベントが含まれま"
"す。結果として得られる可用性の変更については、Webhookが送信されません。"
#: pretix/api/webhooks.py:419 #: pretix/api/webhooks.py:419
msgid "Shop taken live" msgid "Shop taken live"
msgstr "ショップがオンラインになりました" msgstr "ショップが公開中になりました"
#: pretix/api/webhooks.py:423 #: pretix/api/webhooks.py:423
msgid "Shop taken offline" msgid "Shop taken offline"
@@ -3394,13 +3394,11 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as " "The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"." "\"%(chars)s\"."
msgstr "" msgstr ""
"フィールド「%(label)s」には、\"%(chars)s\" のような特殊文字を含めることはでき"
"ません。"
#: pretix/base/forms/questions.py:305 #: pretix/base/forms/questions.py:305
#, python-format #, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)." msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr "フィールド「%(label)s」には URL (%(url)s) を含めることができません。" msgstr ""
#: pretix/base/forms/questions.py:338 #: pretix/base/forms/questions.py:338
msgctxt "phonenumber" msgctxt "phonenumber"
@@ -8191,14 +8189,19 @@ msgid "Program times"
msgstr "プログラム時間" msgstr "プログラム時間"
#: pretix/base/pdf.py:503 #: pretix/base/pdf.py:503
#, fuzzy
#| msgid ""
#| "2017-05-31 10:00 12:00\n"
#| "2017-05-31 14:00 16:00\n"
#| "2017-05-31 14:00 2017-06-01 14:00"
msgid "" msgid ""
"2017-05-31 10:00 12:00, Room 1\n" "2017-05-31 10:00 12:00, Room 1\n"
"2017-05-31 14:00 16:00, Room 2\n" "2017-05-31 14:00 16:00, Room 2\n"
"2017-05-31 14:00 2017-06-01 14:00, Building A" "2017-05-31 14:00 2017-06-01 14:00, Building A"
msgstr "" msgstr ""
"2017-05-31 10:00 12:00、部屋1\n" "2017-05-31 10:00 12:00\n"
"2017-05-31 14:00 16:00、部屋2\n" "2017-05-31 14:00 16:00\n"
"2017-05-31 14:00 2017-06-01 14:00、ビルA" "2017-05-31 14:00 2017-06-01 14:00"
#: pretix/base/pdf.py:507 #: pretix/base/pdf.py:507
msgid "Reusable Medium ID" msgid "Reusable Medium ID"
@@ -8707,7 +8710,13 @@ msgid "This voucher code is not known in our database."
msgstr "このバウチャーコードは、当社のデータベースには登録されていません。" msgstr "このバウチャーコードは、当社のデータベースには登録されていません。"
#: pretix/base/services/cart.py:165 #: pretix/base/services/cart.py:165
#, python-format #, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
msgid "" msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product." "%(number)s matching product."
@@ -8719,7 +8728,15 @@ msgstr[0] ""
"した場合にのみ使用できます。" "した場合にのみ使用できます。"
#: pretix/base/services/cart.py:170 #: pretix/base/services/cart.py:170
#, python-format #, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
msgid "" msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product. We have therefore removed some positions from " "%(number)s matching product. We have therefore removed some positions from "
@@ -13820,8 +13837,6 @@ msgid ""
"You entered an URL, which is not allowed. Please remove %(match)s from your " "You entered an URL, which is not allowed. Please remove %(match)s from your "
"input." "input."
msgstr "" msgstr ""
"URL を入力しましたが、許可されていません。入力から %(match)s を削除してくださ"
"い。"
#: pretix/base/views/errors.py:48 #: pretix/base/views/errors.py:48
msgid "" msgid ""
@@ -15718,8 +15733,14 @@ msgid "inactive"
msgstr "無効" msgstr "無効"
#: pretix/control/forms/item.py:1414 #: pretix/control/forms/item.py:1414
#, fuzzy
#| msgid ""
#| "Sample Conference Center\n"
#| "Heidelberg, Germany"
msgid "Sample Conference Center, Heidelberg, Germany" msgid "Sample Conference Center, Heidelberg, Germany"
msgstr "サンプル・カンファレンスセンター, ドイツ, ハイデルベルク" msgstr ""
"サンプル・カンファレンスセンター\n"
"ドイツ、ハイデルベルク"
#: pretix/control/forms/mailsetup.py:42 #: pretix/control/forms/mailsetup.py:42
msgid "Hostname" msgid "Hostname"
@@ -22960,8 +22981,11 @@ msgid "Quota history"
msgstr "クォータ履歴" msgstr "クォータ履歴"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6 #: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6
#, fuzzy
#| msgctxt "subevent"
#| msgid "Change multiple dates"
msgid "Change multiple quotas" msgid "Change multiple quotas"
msgstr "複数のクォータを変更" msgstr "複数の日付を変更"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8 #: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8
#: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8 #: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8
@@ -23007,14 +23031,17 @@ msgstr "以下の製品は販売できなくなる可能性があります:"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6
#, fuzzy
#| msgid "Delete quota"
msgid "Delete quotas" msgid "Delete quotas"
msgstr "クォータを削除" msgstr "クォータを削除"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10 #: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10
#, python-format #, fuzzy, python-format
#| msgid "Are you sure you want to delete the following dates?"
msgid "Are you sure you want to delete the following quota?" msgid "Are you sure you want to delete the following quota?"
msgid_plural "Are you sure you want to delete the following %(num)s quotas?" msgid_plural "Are you sure you want to delete the following %(num)s quotas?"
msgstr[0] "以下の%(num)sのクォータを削除してもよろしいですか?" msgstr[0] "以下の日付を削除してもよろしいですか?"
#: pretix/control/templates/pretixcontrol/items/quotas.html:9 #: pretix/control/templates/pretixcontrol/items/quotas.html:9
msgid "" msgid ""
@@ -23607,14 +23634,12 @@ msgid ""
"generated once the customer pays the invoice or selects a payment method " "generated once the customer pays the invoice or selects a payment method "
"that requires an invoice." "that requires an invoice."
msgstr "" msgstr ""
"この注文は、最後の請求書が生成された後に変更されました。新しい請求書はまだ作"
"成されていません。請求書は支払い時に生成されるか、支払方法によって必要とされ"
"る場合に設定されているためです。お客様が請求書を支払うか、請求書が必要な支払"
"方法を選択すると、新しい請求書が生成されます。"
#: pretix/control/templates/pretixcontrol/order/index.html:152 #: pretix/control/templates/pretixcontrol/order/index.html:152
#, fuzzy
#| msgid "Request invoice"
msgid "Reissue invoice" msgid "Reissue invoice"
msgstr "請求書を再発行する" msgstr "請求書を要求"
#: pretix/control/templates/pretixcontrol/order/index.html:161 #: pretix/control/templates/pretixcontrol/order/index.html:161
#: pretix/control/templates/pretixcontrol/order/index.html:413 #: pretix/control/templates/pretixcontrol/order/index.html:413
@@ -24039,15 +24064,22 @@ msgid "How should the refund be sent?"
msgstr "どのように払い戻しますか?" msgstr "どのように払い戻しますか?"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:25 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:25
#, fuzzy
#| msgid ""
#| "Any payments that you selected for automatical refunds will be "
#| "immediately communicate the refund request to the respective payment "
#| "provider. Manual refunds will be created as pending refunds, you can then "
#| "later mark them as done once you actually transferred the money back to "
#| "the customer."
msgid "" msgid ""
"Any payments you selected for automatic refunds will have the refund request " "Any payments you selected for automatic refunds will have the refund request "
"sent immediately to the respective payment provider. Manual refunds will be " "sent immediately to the respective payment provider. Manual refunds will be "
"created as pending refunds, which you can later mark as done once you have " "created as pending refunds, which you can later mark as done once you have "
"actually transferred the money back to the customer." "actually transferred the money back to the customer."
msgstr "" msgstr ""
"自動返金をご選択いただいたすべての支払いについては、返金リクエストが直ちに該" "自動払い戻しに選択した支払いは、該当する決済プロバイダーに払い戻し要求が即座"
"当する決済プロバイダーへ送信されます。手動返金は保留中の返金として作成され、" "に通知されます。手動払い戻しは保留中の払い戻しとして作成され、実際に顧客に送"
"実際に顧客に返金した後で完了としてマークできます。" "金した後で完了済みとしてマークできます。"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:32 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:32
msgid "Refund to original payment method" msgid "Refund to original payment method"
@@ -28472,8 +28504,11 @@ msgid "The new question has been created."
msgstr "新しい質問が作成されました。" msgstr "新しい質問が作成されました。"
#: pretix/control/views/item.py:918 #: pretix/control/views/item.py:918
#, fuzzy
#| msgctxt "subevent"
#| msgid "The selected dates have been deleted or disabled."
msgid "The selected quotas have been deleted or disabled." msgid "The selected quotas have been deleted or disabled."
msgstr "選択したクォータは削除されたか無効す。" msgstr "選択した日付は削除されたか無効になっています。"
#: pretix/control/views/item.py:1074 #: pretix/control/views/item.py:1074
msgid "The new quota has been created." msgid "The new quota has been created."
@@ -29180,9 +29215,10 @@ msgid "This plugin is currently not allowed for this organizer account."
msgstr "このプラグインは現在、この主催者アカウントでは許可されていません。" msgstr "このプラグインは現在、この主催者アカウントでは許可されていません。"
#: pretix/control/views/organizer.py:832 #: pretix/control/views/organizer.py:832
#, python-brace-format #, fuzzy, python-brace-format
#| msgid "This plugin can be enabled or disabled for events individually."
msgid "This plugin cannot be activated for event {}." msgid "This plugin cannot be activated for event {}."
msgstr "このプラグインは、イベント{}に対してアクティベートできません。" msgstr "このプラグインは、イベントごとに個別に有効化または無効化できま。"
#: pretix/control/views/organizer.py:901 #: pretix/control/views/organizer.py:901
msgid "The team has been created. You can now add members to the team." msgid "The team has been created. You can now add members to the team."
@@ -30200,9 +30236,10 @@ msgid "{width} x {height} mm label"
msgstr "{width} x {height} mm ラベル" msgstr "{width} x {height} mm ラベル"
#: pretix/plugins/badges/templates.py:265 #: pretix/plugins/badges/templates.py:265
#, python-brace-format #, fuzzy, python-brace-format
#| msgid "{width} x {height} mm label"
msgid "{width} x {height} inch label" msgid "{width} x {height} inch label"
msgstr "{width} x {height} インチラベル" msgstr "{width} x {height} mm ラベル"
#: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16 #: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16
#: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27 #: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27
+12 -8
View File
@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n" "POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-01 09:00+0000\n" "PO-Revision-Date: 2026-02-01 21:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n" "Last-Translator: z3rrry <z3rrry@gmail.com>\n"
"Language-Team: Korean <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: Korean <https://translate.pretix.eu/projects/pretix/pretix/ko/"
"ko/>\n" ">\n"
"Language: ko\n" "Language: ko\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.5\n" "X-Generator: Weblate 5.15.2\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670 #: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166 #: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -48,7 +48,7 @@ msgstr "사전판매 시작하지 않음"
#: pretix/control/templates/pretixcontrol/subevents/index.html:176 #: pretix/control/templates/pretixcontrol/subevents/index.html:176
#: pretix/control/views/dashboards.py:549 #: pretix/control/views/dashboards.py:549
msgid "On sale" msgid "On sale"
msgstr "세일 중" msgstr ""
#: pretix/_base_settings.py:89 #: pretix/_base_settings.py:89
msgid "English" msgid "English"
@@ -427,8 +427,10 @@ msgstr ""
#: pretix/api/serializers/organizer.py:495 #: pretix/api/serializers/organizer.py:495
#: pretix/control/views/organizer.py:1035 #: pretix/control/views/organizer.py:1035
#, fuzzy
#| msgid "pretix account invitation"
msgid "Account invitation" msgid "Account invitation"
msgstr "계정 초대" msgstr "프레틱스 계정 초대"
#: pretix/api/serializers/organizer.py:516 #: pretix/api/serializers/organizer.py:516
#: pretix/control/views/organizer.py:1134 #: pretix/control/views/organizer.py:1134
@@ -18085,8 +18087,10 @@ msgid "A payment has been performed."
msgstr "수동 거래가 수행되었습니다." msgstr "수동 거래가 수행되었습니다."
#: pretix/control/logdisplay.py:807 #: pretix/control/logdisplay.py:807
#, fuzzy
#| msgid "A manual transaction has been performed."
msgid "A refund has been performed. " msgid "A refund has been performed. "
msgstr "환불이 처리되었습니다. " msgstr "수동 거래가 수행되었습니다."
#: pretix/control/logdisplay.py:808 #: pretix/control/logdisplay.py:808
#, python-brace-format #, python-brace-format
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n" "POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-01 09:00+0000\n" "PO-Revision-Date: 2026-05-21 15:08+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n" "Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Chinese (Traditional Han script) <https://translate.pretix.eu/" "Language-Team: Chinese (Traditional Han script) <https://translate.pretix.eu/"
"projects/pretix/pretix/zh_Hant/>\n" "projects/pretix/pretix/zh_Hant/>\n"
@@ -595,16 +595,16 @@ msgid ""
msgstr "這包括新增或刪除的產品,以及對變體或捆綁等巢狀物件的更改。" msgstr "這包括新增或刪除的產品,以及對變體或捆綁等巢狀物件的更改。"
#: pretix/api/webhooks.py:413 #: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed" msgid "Quota changed"
msgstr "配額改變了" msgstr "額度處理"
#: pretix/api/webhooks.py:414 #: pretix/api/webhooks.py:414
msgid "" msgid ""
"This includes related events like creation, deletion, opening or closing of " "This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability." "quotas. No webhook is sent for changes to the resulting availability."
msgstr "" msgstr ""
"這包括建立、刪除、開啟或關閉配額等相關事件。 沒有傳送webhook來更改結果的可用"
"性。"
#: pretix/api/webhooks.py:419 #: pretix/api/webhooks.py:419
msgid "Shop taken live" msgid "Shop taken live"
@@ -650,7 +650,7 @@ msgstr "優惠券已更改"
msgid "" msgid ""
"Only includes explicit changes to the voucher, not e.g. an increase of the " "Only includes explicit changes to the voucher, not e.g. an increase of the "
"number of redemptions." "number of redemptions."
msgstr "僅包括對代金券的明確更改,例如不包括兌換次數的增加。" msgstr ""
#: pretix/api/webhooks.py:460 #: pretix/api/webhooks.py:460
msgid "Voucher deleted" msgid "Voucher deleted"
@@ -669,16 +669,22 @@ msgid "Customer account anonymized"
msgstr "客戶帳戶已匿名化" msgstr "客戶帳戶已匿名化"
#: pretix/api/webhooks.py:476 #: pretix/api/webhooks.py:476
#, fuzzy
#| msgid "Gift card code"
msgid "Gift card added" msgid "Gift card added"
msgstr "添加了禮品卡" msgstr "禮品卡代碼"
#: pretix/api/webhooks.py:480 #: pretix/api/webhooks.py:480
#, fuzzy
#| msgid "Gift card code"
msgid "Gift card modified" msgid "Gift card modified"
msgstr "禮品卡修改了" msgstr "禮品卡代碼"
#: pretix/api/webhooks.py:484 #: pretix/api/webhooks.py:484
#, fuzzy
#| msgid "Gift card transactions"
msgid "Gift card used in transaction" msgid "Gift card used in transaction"
msgstr "交易中使用的禮品卡" msgstr "禮品卡交易"
#: pretix/base/addressvalidation.py:100 pretix/base/addressvalidation.py:103 #: pretix/base/addressvalidation.py:100 pretix/base/addressvalidation.py:103
#: pretix/base/addressvalidation.py:108 pretix/base/forms/questions.py:1074 #: pretix/base/addressvalidation.py:108 pretix/base/forms/questions.py:1074
+9 -9
View File
@@ -64,7 +64,7 @@ from pretix.base.timeframes import (
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
from pretix.helpers.filenames import safe_for_filename from pretix.helpers.filenames import safe_for_filename
from pretix.helpers.iter import chunked_iterable from pretix.helpers.iter import chunked_iterable
from pretix.helpers.reportlab import FontFallbackParagraph from pretix.helpers.reportlab import PlainTextParagraph
from pretix.helpers.templatetags.jsonfield import JSONExtract from pretix.helpers.templatetags.jsonfield import JSONExtract
from pretix.plugins.reports.exporters import ReportlabExportMixin from pretix.plugins.reports.exporters import ReportlabExportMixin
@@ -343,7 +343,7 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
] ]
story = [ story = [
FontFallbackParagraph( PlainTextParagraph(
cl.name, cl.name,
headlinestyle headlinestyle
), ),
@@ -351,7 +351,7 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
if cl.subevent: if cl.subevent:
story += [ story += [
Spacer(1, 3 * mm), Spacer(1, 3 * mm),
FontFallbackParagraph( PlainTextParagraph(
'{} ({} {})'.format( '{} ({} {})'.format(
cl.subevent.name, cl.subevent.name,
cl.subevent.get_date_range_display(), cl.subevent.get_date_range_display(),
@@ -381,10 +381,10 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
headrowstyle.fontName = 'OpenSansBd' headrowstyle.fontName = 'OpenSansBd'
for q in questions: for q in questions:
txt = str(q.question) txt = str(q.question)
p = FontFallbackParagraph(txt, headrowstyle) p = PlainTextParagraph(txt, headrowstyle)
while p.wrap(colwidths[len(tdata[0])], 5000)[1] > 30 * mm: while p.wrap(colwidths[len(tdata[0])], 5000)[1] > 30 * mm:
txt = txt[:len(txt) - 50] + "..." txt = txt[:len(txt) - 50] + "..."
p = FontFallbackParagraph(txt, headrowstyle) p = PlainTextParagraph(txt, headrowstyle)
tdata[0].append(p) tdata[0].append(p)
qs = self._get_queryset(cl, form_data) qs = self._get_queryset(cl, form_data)
@@ -431,8 +431,8 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
CBFlowable(bool(op.last_checked_in)) if not op.blocked else '', CBFlowable(bool(op.last_checked_in)) if not op.blocked else '',
'' if op.order.status != Order.STATUS_PAID else '', '' if op.order.status != Order.STATUS_PAID else '',
op.order.code, op.order.code,
FontFallbackParagraph(name, self.get_style()), PlainTextParagraph(name, self.get_style()),
FontFallbackParagraph(bleach.clean(str(item), tags={'br'}).strip().replace('<br>', '<br/>'), self.get_style()), PlainTextParagraph(bleach.clean(str(item), tags={'br'}).strip().replace('<br>', '<br/>'), self.get_style()),
] ]
acache = {} acache = {}
if op.addon_to: if op.addon_to:
@@ -443,10 +443,10 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
for q in questions: for q in questions:
txt = acache.get(q.pk, '') txt = acache.get(q.pk, '')
txt = bleach.clean(txt, tags={'br'}).strip().replace('<br>', '<br/>') txt = bleach.clean(txt, tags={'br'}).strip().replace('<br>', '<br/>')
p = FontFallbackParagraph(txt, self.get_style()) p = PlainTextParagraph(txt, self.get_style())
while p.wrap(colwidths[len(row)], 5000)[1] > 50 * mm: while p.wrap(colwidths[len(row)], 5000)[1] > 50 * mm:
txt = txt[:len(txt) - 50] + "..." txt = txt[:len(txt) - 50] + "..."
p = FontFallbackParagraph(txt, self.get_style()) p = PlainTextParagraph(txt, self.get_style())
row.append(p) row.append(p)
if op.order.status != Order.STATUS_PAID: if op.order.status != Order.STATUS_PAID:
tstyledata += [ tstyledata += [
+78 -78
View File
@@ -36,7 +36,7 @@ from reportlab.lib import colors, pagesizes
from reportlab.lib.enums import TA_CENTER, TA_RIGHT from reportlab.lib.enums import TA_CENTER, TA_RIGHT
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.platypus import ( from reportlab.platypus import (
KeepTogether, PageTemplate, Paragraph, Spacer, Table, TableStyle, KeepTogether, PageTemplate, Spacer, Table, TableStyle,
) )
from pretix.base.exporter import BaseExporter from pretix.base.exporter import BaseExporter
@@ -49,7 +49,7 @@ from pretix.base.timeframes import (
resolve_timeframe_to_datetime_start_inclusive_end_exclusive, resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
) )
from pretix.control.forms.filter import get_all_payment_providers from pretix.control.forms.filter import get_all_payment_providers
from pretix.helpers.reportlab import FontFallbackParagraph from pretix.helpers.reportlab import PlainTextParagraph
from pretix.plugins.reports.exporters import ReportlabExportMixin from pretix.plugins.reports.exporters import ReportlabExportMixin
@@ -311,13 +311,13 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata = [ tdata = [
[ [
FontFallbackParagraph(self._transaction_group_header_label(), tstyle_bold), PlainTextParagraph(self._transaction_group_header_label(), tstyle_bold),
FontFallbackParagraph(_("Price"), tstyle_bold_right), PlainTextParagraph(_("Price"), tstyle_bold_right),
FontFallbackParagraph(_("Tax rate"), tstyle_bold_right), PlainTextParagraph(_("Tax rate"), tstyle_bold_right),
FontFallbackParagraph("#", tstyle_bold_right), PlainTextParagraph("#", tstyle_bold_right),
FontFallbackParagraph(_("Net total"), tstyle_bold_right), PlainTextParagraph(_("Net total"), tstyle_bold_right),
FontFallbackParagraph(_("Tax total"), tstyle_bold_right), PlainTextParagraph(_("Tax total"), tstyle_bold_right),
FontFallbackParagraph(_("Gross total"), tstyle_bold_right), PlainTextParagraph(_("Gross total"), tstyle_bold_right),
] ]
] ]
@@ -347,12 +347,12 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
if e != last_group: if e != last_group:
if last_group_head_idx > 0 and e is not None: if last_group_head_idx > 0 and e is not None:
tdata[last_group_head_idx][4] = Paragraph(money_filter(sum_price_by_group - sum_tax_by_group, currency), tstyle_bold_right), tdata[last_group_head_idx][4] = PlainTextParagraph(money_filter(sum_price_by_group - sum_tax_by_group, currency), tstyle_bold_right),
tdata[last_group_head_idx][5] = Paragraph(money_filter(sum_tax_by_group, currency), tstyle_bold_right), tdata[last_group_head_idx][5] = PlainTextParagraph(money_filter(sum_tax_by_group, currency), tstyle_bold_right),
tdata[last_group_head_idx][6] = Paragraph(money_filter(sum_price_by_group, currency), tstyle_bold_right), tdata[last_group_head_idx][6] = PlainTextParagraph(money_filter(sum_price_by_group, currency), tstyle_bold_right),
tdata.append( tdata.append(
[ [
FontFallbackParagraph( PlainTextParagraph(
e, e,
tstyle_bold, tstyle_bold,
), ),
@@ -375,20 +375,20 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
text = self._transaction_row_label(r) text = self._transaction_row_label(r)
tdata.append( tdata.append(
[ [
FontFallbackParagraph(text, tstyle), PlainTextParagraph(text, tstyle),
Paragraph( PlainTextParagraph(
money_filter(r["price"], currency) money_filter(r["price"], currency)
if "price" in r and r["price"] is not None if "price" in r and r["price"] is not None
else "", else "",
tstyle_right, tstyle_right,
), ),
Paragraph(localize(r["tax_rate"].normalize()) + " %", tstyle_right), PlainTextParagraph(localize(r["tax_rate"].normalize()) + " %", tstyle_right),
Paragraph(str(r["sum_cont"]), tstyle_right), PlainTextParagraph(str(r["sum_cont"]), tstyle_right),
Paragraph( PlainTextParagraph(
money_filter(r["sum_price"] - r["sum_tax"], currency), tstyle_right money_filter(r["sum_price"] - r["sum_tax"], currency), tstyle_right
), ),
Paragraph(money_filter(r["sum_tax"], currency), tstyle_right), PlainTextParagraph(money_filter(r["sum_tax"], currency), tstyle_right),
Paragraph(money_filter(r["sum_price"], currency), tstyle_right), PlainTextParagraph(money_filter(r["sum_price"], currency), tstyle_right),
] ]
) )
sum_cnt_by_tax_rate[r["tax_rate"]] += r["sum_cont"] sum_cnt_by_tax_rate[r["tax_rate"]] += r["sum_cont"]
@@ -398,19 +398,19 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
sum_tax_by_group += r["sum_tax"] sum_tax_by_group += r["sum_tax"]
if last_group_head_idx > 0 and last_group is not None: if last_group_head_idx > 0 and last_group is not None:
tdata[last_group_head_idx][4] = Paragraph(money_filter(sum_price_by_group - sum_tax_by_group, currency), tstyle_bold_right), tdata[last_group_head_idx][4] = PlainTextParagraph(money_filter(sum_price_by_group - sum_tax_by_group, currency), tstyle_bold_right),
tdata[last_group_head_idx][5] = Paragraph(money_filter(sum_tax_by_group, currency), tstyle_bold_right), tdata[last_group_head_idx][5] = PlainTextParagraph(money_filter(sum_tax_by_group, currency), tstyle_bold_right),
tdata[last_group_head_idx][6] = Paragraph(money_filter(sum_price_by_group, currency), tstyle_bold_right), tdata[last_group_head_idx][6] = PlainTextParagraph(money_filter(sum_price_by_group, currency), tstyle_bold_right),
if len(sum_tax_by_tax_rate) > 1: if len(sum_tax_by_tax_rate) > 1:
for tax_rate in sorted(sum_tax_by_tax_rate.keys(), reverse=True): for tax_rate in sorted(sum_tax_by_tax_rate.keys(), reverse=True):
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Sum"), tstyle), PlainTextParagraph(_("Sum"), tstyle),
Paragraph("", tstyle_right), PlainTextParagraph("", tstyle_right),
Paragraph(localize(tax_rate.normalize()) + " %", tstyle_right), PlainTextParagraph(localize(tax_rate.normalize()) + " %", tstyle_right),
Paragraph("", tstyle_right), PlainTextParagraph("", tstyle_right),
Paragraph( PlainTextParagraph(
money_filter( money_filter(
sum_price_by_tax_rate[tax_rate] sum_price_by_tax_rate[tax_rate]
- sum_tax_by_tax_rate[tax_rate], - sum_tax_by_tax_rate[tax_rate],
@@ -418,10 +418,10 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
), ),
tstyle_right, tstyle_right,
), ),
Paragraph( PlainTextParagraph(
money_filter(sum_tax_by_tax_rate[tax_rate], currency), tstyle_right money_filter(sum_tax_by_tax_rate[tax_rate], currency), tstyle_right
), ),
Paragraph( PlainTextParagraph(
money_filter(sum_price_by_tax_rate[tax_rate], currency), money_filter(sum_price_by_tax_rate[tax_rate], currency),
tstyle_right, tstyle_right,
), ),
@@ -439,11 +439,11 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Sum"), tstyle_bold), PlainTextParagraph(_("Sum"), tstyle_bold),
Paragraph("", tstyle_right), PlainTextParagraph("", tstyle_right),
Paragraph("", tstyle_right), PlainTextParagraph("", tstyle_right),
Paragraph("", tstyle_bold_right), PlainTextParagraph("", tstyle_bold_right),
Paragraph( PlainTextParagraph(
money_filter( money_filter(
sum(sum_price_by_tax_rate.values()) sum(sum_price_by_tax_rate.values())
- sum(sum_tax_by_tax_rate.values()), - sum(sum_tax_by_tax_rate.values()),
@@ -451,11 +451,11 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
), ),
tstyle_bold_right, tstyle_bold_right,
), ),
Paragraph( PlainTextParagraph(
money_filter(sum(sum_tax_by_tax_rate.values()), currency), money_filter(sum(sum_tax_by_tax_rate.values()), currency),
tstyle_bold_right, tstyle_bold_right,
), ),
Paragraph( PlainTextParagraph(
money_filter(sum(sum_price_by_tax_rate.values()), currency), money_filter(sum(sum_price_by_tax_rate.values()), currency),
tstyle_bold_right, tstyle_bold_right,
), ),
@@ -493,10 +493,10 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata = [ tdata = [
[ [
FontFallbackParagraph(_("Payment method"), tstyle_bold), PlainTextParagraph(_("Payment method"), tstyle_bold),
FontFallbackParagraph(_("Payments"), tstyle_bold_right), PlainTextParagraph(_("Payments"), tstyle_bold_right),
FontFallbackParagraph(_("Refunds"), tstyle_bold_right), PlainTextParagraph(_("Refunds"), tstyle_bold_right),
FontFallbackParagraph(_("Total"), tstyle_bold_right), PlainTextParagraph(_("Total"), tstyle_bold_right),
] ]
] ]
@@ -537,20 +537,20 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
for p in providers: for p in providers:
tdata.append( tdata.append(
[ [
Paragraph(provider_names.get(p, p), tstyle), PlainTextParagraph(provider_names.get(p, p), tstyle),
FontFallbackParagraph( PlainTextParagraph(
money_filter(payments_by_provider[p], currency) money_filter(payments_by_provider[p], currency)
if p in payments_by_provider if p in payments_by_provider
else "", else "",
tstyle_right, tstyle_right,
), ),
Paragraph( PlainTextParagraph(
money_filter(refunds_by_provider[p], currency) money_filter(refunds_by_provider[p], currency)
if p in refunds_by_provider if p in refunds_by_provider
else "", else "",
tstyle_right, tstyle_right,
), ),
Paragraph( PlainTextParagraph(
money_filter( money_filter(
payments_by_provider.get(p, Decimal("0.00")) payments_by_provider.get(p, Decimal("0.00"))
- refunds_by_provider.get(p, Decimal("0.00")), - refunds_by_provider.get(p, Decimal("0.00")),
@@ -563,20 +563,20 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Sum"), tstyle_bold), PlainTextParagraph(_("Sum"), tstyle_bold),
Paragraph( PlainTextParagraph(
money_filter( money_filter(
sum(payments_by_provider.values(), Decimal("0.00")), currency sum(payments_by_provider.values(), Decimal("0.00")), currency
), ),
tstyle_bold_right, tstyle_bold_right,
), ),
Paragraph( PlainTextParagraph(
money_filter( money_filter(
sum(refunds_by_provider.values(), Decimal("0.00")), currency sum(refunds_by_provider.values(), Decimal("0.00")), currency
), ),
tstyle_bold_right, tstyle_bold_right,
), ),
Paragraph( PlainTextParagraph(
money_filter( money_filter(
sum(payments_by_provider.values(), Decimal("0.00")) sum(payments_by_provider.values(), Decimal("0.00"))
- sum(refunds_by_provider.values(), Decimal("0.00")), - sum(refunds_by_provider.values(), Decimal("0.00")),
@@ -641,7 +641,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
open_before = tx_before - p_before + r_before open_before = tx_before - p_before + r_before
tdata.append( tdata.append(
[ [
FontFallbackParagraph( PlainTextParagraph(
_("Pending payments at {datetime}").format( _("Pending payments at {datetime}").format(
datetime=date_format( datetime=date_format(
(df_start - datetime.timedelta.resolution).astimezone( (df_start - datetime.timedelta.resolution).astimezone(
@@ -653,7 +653,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tstyle, tstyle,
), ),
"", "",
Paragraph(money_filter(open_before, currency), tstyle_right), PlainTextParagraph(money_filter(open_before, currency), tstyle_right),
] ]
) )
else: else:
@@ -670,30 +670,30 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
] or Decimal("0.00") ] or Decimal("0.00")
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Orders"), tstyle), PlainTextParagraph(_("Orders"), tstyle),
Paragraph("+", tstyle_center), PlainTextParagraph("+", tstyle_center),
Paragraph(money_filter(tx_during, currency), tstyle_right), PlainTextParagraph(money_filter(tx_during, currency), tstyle_right),
] ]
) )
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Payments"), tstyle), PlainTextParagraph(_("Payments"), tstyle),
Paragraph("-", tstyle_center), PlainTextParagraph("-", tstyle_center),
Paragraph(money_filter(p_during, currency), tstyle_right), PlainTextParagraph(money_filter(p_during, currency), tstyle_right),
] ]
) )
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Refunds"), tstyle), PlainTextParagraph(_("Refunds"), tstyle),
Paragraph("+", tstyle_center), PlainTextParagraph("+", tstyle_center),
Paragraph(money_filter(r_during, currency), tstyle_right), PlainTextParagraph(money_filter(r_during, currency), tstyle_right),
] ]
) )
open_after = open_before + tx_during - p_during + r_during open_after = open_before + tx_during - p_during + r_during
tdata.append( tdata.append(
[ [
Paragraph( PlainTextParagraph(
_("Pending payments at {datetime}").format( _("Pending payments at {datetime}").format(
datetime=date_format( datetime=date_format(
((df_end or now()) - datetime.timedelta.resolution).astimezone( ((df_end or now()) - datetime.timedelta.resolution).astimezone(
@@ -704,8 +704,8 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
), ),
tstyle_bold, tstyle_bold,
), ),
Paragraph("=", tstyle_center), PlainTextParagraph("=", tstyle_center),
Paragraph(money_filter(open_after, currency), tstyle_bold_right), PlainTextParagraph(money_filter(open_after, currency), tstyle_bold_right),
] ]
) )
tstyledata += [ tstyledata += [
@@ -752,7 +752,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
) )
tdata.append( tdata.append(
[ [
Paragraph( PlainTextParagraph(
_("Total gift card value at {datetime}").format( _("Total gift card value at {datetime}").format(
datetime=date_format( datetime=date_format(
(df_start - datetime.timedelta.resolution).astimezone( (df_start - datetime.timedelta.resolution).astimezone(
@@ -763,7 +763,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
), ),
tstyle, tstyle,
), ),
Paragraph(money_filter(tx_before, currency), tstyle_right), PlainTextParagraph(money_filter(tx_before, currency), tstyle_right),
] ]
) )
else: else:
@@ -774,8 +774,8 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
] or Decimal("0.00") ] or Decimal("0.00")
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Gift card transactions (credit)"), tstyle), PlainTextParagraph(_("Gift card transactions (credit)"), tstyle),
Paragraph(money_filter(tx_during_pos, currency), tstyle_right), PlainTextParagraph(money_filter(tx_during_pos, currency), tstyle_right),
] ]
) )
@@ -784,15 +784,15 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
] or Decimal("0.00") ] or Decimal("0.00")
tdata.append( tdata.append(
[ [
FontFallbackParagraph(_("Gift card transactions (debit)"), tstyle), PlainTextParagraph(_("Gift card transactions (debit)"), tstyle),
Paragraph(money_filter(tx_during_neg, currency), tstyle_right), PlainTextParagraph(money_filter(tx_during_neg, currency), tstyle_right),
] ]
) )
open_after = tx_before + tx_during_pos + tx_during_neg open_after = tx_before + tx_during_pos + tx_during_neg
tdata.append( tdata.append(
[ [
Paragraph( PlainTextParagraph(
_("Total gift card value at {datetime}").format( _("Total gift card value at {datetime}").format(
datetime=date_format( datetime=date_format(
((df_end or now()) - datetime.timedelta.resolution).astimezone( ((df_end or now()) - datetime.timedelta.resolution).astimezone(
@@ -803,7 +803,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
), ),
tstyle_bold, tstyle_bold,
), ),
Paragraph(money_filter(open_after, currency), tstyle_bold_right), PlainTextParagraph(money_filter(open_after, currency), tstyle_bold_right),
] ]
) )
tstyledata += [ tstyledata += [
@@ -854,10 +854,10 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
style_small.leading = 10 style_small.leading = 10
story = [ story = [
FontFallbackParagraph(self.verbose_name, style_h1), PlainTextParagraph(self.verbose_name, style_h1),
Spacer(0, 3 * mm), Spacer(0, 3 * mm),
FontFallbackParagraph( PlainTextParagraph(
"<br />".join(escape(f) for f in self.describe_filters(form_data)), "\n".join(escape(f) for f in self.describe_filters(form_data)),
style_small, style_small,
), ),
] ]
@@ -870,7 +870,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
if s: if s:
story += [ story += [
Spacer(0, 3 * mm), Spacer(0, 3 * mm),
FontFallbackParagraph(_("Orders") + c_head, style_h2), PlainTextParagraph(_("Orders") + c_head, style_h2),
Spacer(0, 3 * mm), Spacer(0, 3 * mm),
*s *s
] ]
@@ -881,7 +881,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
if s: if s:
story += [ story += [
Spacer(0, 8 * mm), Spacer(0, 8 * mm),
FontFallbackParagraph(_("Payments") + c_head, style_h2), PlainTextParagraph(_("Payments") + c_head, style_h2),
Spacer(0, 3 * mm), Spacer(0, 3 * mm),
*s *s
] ]
@@ -894,7 +894,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
Spacer(0, 8 * mm), Spacer(0, 8 * mm),
KeepTogether( KeepTogether(
[ [
FontFallbackParagraph(_("Open items") + c_head, style_h2), PlainTextParagraph(_("Open items") + c_head, style_h2),
Spacer(0, 3 * mm), Spacer(0, 3 * mm),
*s *s
] ]
@@ -912,7 +912,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
Spacer(0, 8 * mm), Spacer(0, 8 * mm),
KeepTogether( KeepTogether(
[ [
FontFallbackParagraph(_("Gift cards") + c_head, style_h2), PlainTextParagraph(_("Gift cards") + c_head, style_h2),
Spacer(0, 3 * mm), Spacer(0, 3 * mm),
*s, *s,
] ]
+13 -13
View File
@@ -70,7 +70,7 @@ from pretix.base.timeframes import (
) )
from pretix.control.forms.filter import OverviewFilterForm from pretix.control.forms.filter import OverviewFilterForm
from pretix.helpers.reportlab import ( from pretix.helpers.reportlab import (
FontFallbackParagraph, register_ttf_font_if_new, PlainTextParagraph, register_ttf_font_if_new,
) )
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -282,7 +282,7 @@ class OverviewReport(Report):
headlinestyle.fontSize = 15 headlinestyle.fontSize = 15
headlinestyle.fontName = 'OpenSansBd' headlinestyle.fontName = 'OpenSansBd'
story = [ story = [
FontFallbackParagraph(_('Orders by product') + ' ' + (_('(excl. taxes)') if net else _('(incl. taxes)')), headlinestyle), PlainTextParagraph(_('Orders by product') + ' ' + (_('(excl. taxes)') if net else _('(incl. taxes)')), headlinestyle),
Spacer(1, 5 * mm) Spacer(1, 5 * mm)
] ]
return story return story
@@ -292,7 +292,7 @@ class OverviewReport(Report):
if form_data.get('date_axis') and form_data.get('date_range'): if form_data.get('date_axis') and form_data.get('date_range'):
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone) d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
story += [ story += [
FontFallbackParagraph(_('{axis} between {start} and {end}').format( PlainTextParagraph(_('{axis} between {start} and {end}').format(
axis=dict(OverviewFilterForm(event=self.event).fields['date_axis'].choices)[form_data.get('date_axis')], axis=dict(OverviewFilterForm(event=self.event).fields['date_axis'].choices)[form_data.get('date_axis')],
start=date_format(d_start, 'SHORT_DATE_FORMAT') if d_start else '', start=date_format(d_start, 'SHORT_DATE_FORMAT') if d_start else '',
end=date_format(d_end, 'SHORT_DATE_FORMAT') if d_end else '', end=date_format(d_end, 'SHORT_DATE_FORMAT') if d_end else '',
@@ -305,13 +305,13 @@ class OverviewReport(Report):
subevent = self.event.subevents.get(pk=self.form_data.get('subevent')) subevent = self.event.subevents.get(pk=self.form_data.get('subevent'))
except SubEvent.DoesNotExist: except SubEvent.DoesNotExist:
subevent = self.form_data.get('subevent') subevent = self.form_data.get('subevent')
story.append(FontFallbackParagraph(pgettext('subevent', 'Date: {}').format(subevent), self.get_style())) story.append(PlainTextParagraph(pgettext('subevent', 'Date: {}').format(subevent), self.get_style()))
story.append(Spacer(1, 5 * mm)) story.append(Spacer(1, 5 * mm))
if form_data.get('subevent_date_range'): if form_data.get('subevent_date_range'):
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['subevent_date_range'], self.timezone) d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['subevent_date_range'], self.timezone)
story += [ story += [
FontFallbackParagraph(_('{axis} between {start} and {end}').format( PlainTextParagraph(_('{axis} between {start} and {end}').format(
axis=_('Event date'), axis=_('Event date'),
start=date_format(d_start, 'SHORT_DATE_FORMAT') if d_start else '', start=date_format(d_start, 'SHORT_DATE_FORMAT') if d_start else '',
end=date_format(d_end - timedelta(hours=1), 'SHORT_DATE_FORMAT') if d_end else '', end=date_format(d_end - timedelta(hours=1), 'SHORT_DATE_FORMAT') if d_end else '',
@@ -384,13 +384,13 @@ class OverviewReport(Report):
tdata = [ tdata = [
[ [
_('Product'), _('Product'),
FontFallbackParagraph(_('Canceled'), tstyle_th), PlainTextParagraph(_('Canceled'), tstyle_th),
'', '',
FontFallbackParagraph(_('Expired'), tstyle_th), PlainTextParagraph(_('Expired'), tstyle_th),
'', '',
FontFallbackParagraph(_('Approval pending'), tstyle_th), PlainTextParagraph(_('Approval pending'), tstyle_th),
'', '',
FontFallbackParagraph(_('Purchased'), tstyle_th), PlainTextParagraph(_('Purchased'), tstyle_th),
'', '', '', '', '' '', '', '', '', ''
], ],
[ [
@@ -421,14 +421,14 @@ class OverviewReport(Report):
for tup in items_by_category: for tup in items_by_category:
if tup[0]: if tup[0]:
tdata.append([ tdata.append([
FontFallbackParagraph(str(tup[0]), tstyle_bold) PlainTextParagraph(str(tup[0]), tstyle_bold)
]) ])
for l, s in states: for l, s in states:
tdata[-1].append(str(tup[0].num[l][0])) tdata[-1].append(str(tup[0].num[l][0]))
tdata[-1].append(floatformat(tup[0].num[l][2 if net else 1], places)) tdata[-1].append(floatformat(tup[0].num[l][2 if net else 1], places))
for item in tup[1]: for item in tup[1]:
tdata.append([ tdata.append([
FontFallbackParagraph(str(item), tstyle) PlainTextParagraph(str(item), tstyle)
]) ])
for l, s in states: for l, s in states:
tdata[-1].append(str(item.num[l][0])) tdata[-1].append(str(item.num[l][0]))
@@ -436,7 +436,7 @@ class OverviewReport(Report):
if item.has_variations: if item.has_variations:
for var in item.all_variations: for var in item.all_variations:
tdata.append([ tdata.append([
FontFallbackParagraph(" " + str(var), tstyle) PlainTextParagraph(" " + str(var), tstyle)
]) ])
for l, s in states: for l, s in states:
tdata[-1].append(str(var.num[l][0])) tdata[-1].append(str(var.num[l][0]))
@@ -568,7 +568,7 @@ class OrderTaxListReportPDF(Report):
tstyledata.append(('SPAN', (5 + 2 * i, 0), (6 + 2 * i, 0))) tstyledata.append(('SPAN', (5 + 2 * i, 0), (6 + 2 * i, 0)))
story = [ story = [
FontFallbackParagraph(_('Orders by tax rate ({currency})').format(currency=self.event.currency), headlinestyle), PlainTextParagraph(_('Orders by tax rate ({currency})').format(currency=self.event.currency), headlinestyle),
Spacer(1, 5 * mm) Spacer(1, 5 * mm)
] ]
tdata = [ tdata = [
+2 -1
View File
@@ -51,6 +51,7 @@ from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils import translation from django.utils import translation
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import conditional_escape
from django.utils.translation import ( from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy, get_language, gettext_lazy as _, pgettext_lazy,
) )
@@ -1634,7 +1635,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
meta_info = { meta_info = {
'contact_form_data': self.cart_session.get('contact_form_data', {}), 'contact_form_data': self.cart_session.get('contact_form_data', {}),
'confirm_messages': [ 'confirm_messages': [
str(m) for m in self.confirm_messages.values() conditional_escape(str(m)) for m in self.confirm_messages.values()
] ]
} }
api_meta = {} api_meta = {}
+1 -1
View File
@@ -144,7 +144,7 @@ checkout_confirm_messages = EventPluginSignal()
This signal is sent out to retrieve short messages that need to be acknowledged by the user before the This signal is sent out to retrieve short messages that need to be acknowledged by the user before the
order can be completed. This is typically used for something like "accept the terms and conditions". order can be completed. This is typically used for something like "accept the terms and conditions".
Receivers are expected to return a dictionary where the keys are globally unique identifiers for the Receivers are expected to return a dictionary where the keys are globally unique identifiers for the
message and the values can be arbitrary HTML. message and the values can be a SafeString containing arbitrary HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -176,7 +176,7 @@
<div class="checkbox"> <div class="checkbox">
<label for="input_confirm_{{ key }}"> <label for="input_confirm_{{ key }}">
<input type="checkbox" class="checkbox" value="yes" name="confirm_{{ key }}" id="input_confirm_{{ key }}" required> <input type="checkbox" class="checkbox" value="yes" name="confirm_{{ key }}" id="input_confirm_{{ key }}" required>
{{ desc|safe }} {{ desc }}
</label> </label>
</div> </div>
{% endfor %} {% endfor %}
@@ -4,6 +4,7 @@
{% load eventsignal %} {% load eventsignal %}
{% load money %} {% load money %}
{% load eventurl %} {% load eventurl %}
{% load wrap_in %}
{% block title %}{% trans "Registration details" %}{% endblock %} {% block title %}{% trans "Registration details" %}{% endblock %}
{% block content %} {% block content %}
<h2 class="h1"> <h2 class="h1">
@@ -48,7 +49,7 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<p> <p>
{% blocktrans trimmed with email="<strong>"|add:order.email|add:"</strong>"|safe %} {% blocktrans trimmed with email=order.email|wrap_in:"strong" %}
This order is managed for you by {{ email }}. Please contact them for any questions regarding This order is managed for you by {{ email }}. Please contact them for any questions regarding
payment, cancellation or changes to this order. payment, cancellation or changes to this order.
{% endblocktrans %} {% endblocktrans %}
-3
View File
@@ -128,9 +128,6 @@ def _use_vite(request):
origin = request.META.get('HTTP_ORIGIN', '') origin = request.META.get('HTTP_ORIGIN', '')
gs = GlobalSettingsObject() gs = GlobalSettingsObject()
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='') vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
if vite_origins and not origin:
referer = request.META.get('HTTP_REFERER', '')
origin = '/'.join(referer.split('/', 3)[:3])
if origin and vite_origins: if origin and vite_origins:
origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()] origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()]
return origin in origins_list return origin in origins_list
+4 -17
View File
@@ -58,11 +58,10 @@ from django.utils.translation import gettext_lazy as _ # NOQA
_config = configparser.RawConfigParser() _config = configparser.RawConfigParser()
if 'PRETIX_CONFIG_FILE' in os.environ: if 'PRETIX_CONFIG_FILE' in os.environ:
config_files = [os.environ['PRETIX_CONFIG_FILE']] _config.read_file(open(os.environ.get('PRETIX_CONFIG_FILE'), encoding='utf-8'))
else: else:
config_files = ['/etc/pretix/pretix.cfg', os.path.expanduser('~/.pretix.cfg'), 'pretix.cfg'] _config.read(['/etc/pretix/pretix.cfg', os.path.expanduser('~/.pretix.cfg'), 'pretix.cfg'],
encoding='utf-8')
_config.read(config_files, encoding='utf-8')
config = EnvOrParserConfig(_config) config = EnvOrParserConfig(_config)
CONFIG_FILE = config CONFIG_FILE = config
@@ -503,7 +502,6 @@ REST_FRAMEWORK = {
MIDDLEWARE = [ MIDDLEWARE = [
'pretix.helpers.logs.RequestIdMiddleware', 'pretix.helpers.logs.RequestIdMiddleware',
'pretix.base.middleware.BaseLocaleMiddleware',
'pretix.api.middleware.IdempotencyMiddleware', 'pretix.api.middleware.IdempotencyMiddleware',
'pretix.multidomain.middlewares.MultiDomainMiddleware', 'pretix.multidomain.middlewares.MultiDomainMiddleware',
'pretix.base.middleware.CustomCommonMiddleware', 'pretix.base.middleware.CustomCommonMiddleware',
@@ -706,7 +704,7 @@ if config.has_option('sentry', 'dsn') and not any(c in sys.argv for c in ('shell
from sentry_sdk.integrations.logging import ( from sentry_sdk.integrations.logging import (
LoggingIntegration, ignore_logger, LoggingIntegration, ignore_logger,
) )
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST
from .sentry import PretixSentryIntegration, setup_custom_filters from .sentry import PretixSentryIntegration, setup_custom_filters
@@ -897,14 +895,3 @@ VITE_DEV_SERVER = f"http://localhost:{VITE_DEV_SERVER_PORT}"
VITE_DEV_MODE = DEBUG VITE_DEV_MODE = DEBUG
VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild` VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild`
PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0') PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
if DEBUG:
# Reload if settings file changes
config_files_to_watch = [Path(x).absolute() for x in config_files]
from django.dispatch import receiver
from django.utils.autoreload import BaseReloader, autoreload_started
@receiver(autoreload_started, dispatch_uid="pretix_watch_config_file")
def watch_config_file(sender: BaseReloader, *args, **kwargs):
sender.extra_files.update(config_files_to_watch)
+1
View File
@@ -1,4 +1,5 @@
'use strict'; 'use strict';
{ {
const globals = this; const globals = this;
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { rules as rawRules, allItems, activeItems, allProducts, limitProducts } from './django-interop' import { rules as rawRules, items, allProducts, limitProducts } from './django-interop'
import { convertToDNF } from './jsonlogic-boolalg' import { convertToDNF } from './jsonlogic-boolalg'
import RulesEditor from './checkin-rules-editor.vue' import RulesEditor from './checkin-rules-editor.vue'
@@ -53,7 +53,7 @@ const missingItems = computed(() => {
} }
let missing = [] let missing = []
for (const item of activeItems.value) { for (const item of items.value) {
if (productsSeen[item.id]) continue if (productsSeen[item.id]) continue
if (!allProducts.value && !limitProducts.value.includes(item.id)) continue if (!allProducts.value && !limitProducts.value.includes(item.id)) continue
if (item.variations.length > 0) { if (item.variations.length > 0) {
@@ -87,7 +87,7 @@ const missingItems = computed(() => {
//- Tab panes //- Tab panes
.tab-content .tab-content
#rules-edit.tab-pane.active(v-if="allItems", role="tabpanel") #rules-edit.tab-pane.active(v-if="items", role="tabpanel")
RulesEditor RulesEditor
#rules-viz.tab-pane(role="tabpanel") #rules-viz.tab-pane(role="tabpanel")
RulesVisualization RulesVisualization
@@ -26,13 +26,11 @@ watch(rules, (newVal) => {
rulesInput.value = JSON.stringify(newVal) rulesInput.value = JSON.stringify(newVal)
}, { deep: true }) }, { deep: true })
export const activeItems = ref<any[]>([]) export const items = ref<any[]>([])
export const allItems = ref<any[]>([])
const itemsEl = document.querySelector('#items') const itemsEl = document.querySelector('#items')
if (itemsEl?.textContent) { if (itemsEl?.textContent) {
allItems.value = JSON.parse(itemsEl.textContent || '[]') items.value = JSON.parse(itemsEl.textContent || '[]')
activeItems.value = allItems.value.filter(item => item.active)
function checkForInvalidIds (validProducts: Record<string, string>, validVariations: Record<string, string>, rule: any) { function checkForInvalidIds (validProducts: Record<string, string>, validVariations: Record<string, string>, rule: any) {
if (rule['and']) { if (rule['and']) {
@@ -59,8 +57,8 @@ if (itemsEl?.textContent) {
} }
checkForInvalidIds( checkForInvalidIds(
Object.fromEntries(allItems.value.map(p => [p.id, p.name])), Object.fromEntries(items.value.map(p => [p.id, p.name])),
Object.fromEntries(allItems.value.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]) ?? [])), Object.fromEntries(items.value.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]) ?? [])),
rules.value rules.value
) )
} }
@@ -639,13 +639,11 @@ var form_handlers = function (el) {
).append(" ").append($("<div>").text(res.organizer).html()) ).append(" ").append($("<div>").text(res.organizer).html())
); );
} }
if (res.date_range) { $ret.append(
$ret.append( $("<span>").addClass("event-daterange").append(
$("<span>").addClass("event-daterange").append( $("<span>").addClass("fa fa-calendar fa-fw")
$("<span>").addClass("fa fa-calendar fa-fw") ).append(" ").append(res.date_range)
).append(" ").append(res.date_range) );
);
}
return $ret; return $ret;
}, },
}).on("select2:select", function () { }).on("select2:select", function () {
@@ -864,9 +864,6 @@ tbody th {
.checkin-sim-result-status-incomplete { .checkin-sim-result-status-incomplete {
background: $brand-primary; background: $brand-primary;
} }
.checkin-sim-result-status-exchange {
background: $brand-primary;
}
.checkin-sim-result-status-error { .checkin-sim-result-status-error {
background: $brand-danger; background: $brand-danger;
} }
-21
View File
@@ -1098,27 +1098,6 @@ def test_question_upload(token_client, organizer, clist, event, order, question)
assert order.positions.first().answers.get(question=question[0]).file assert order.positions.first().answers.get(question=question[0]).file
@pytest.mark.django_db
def test_question_upload_optional(token_client, organizer, clist, event, order, question):
with scopes_disabled():
p = order.positions.first()
question[0].type = 'F'
question[0].required = False
question[0].save()
resp = _redeem(token_client, organizer, clist, p.pk, {})
assert resp.status_code == 400
assert resp.data['status'] == 'incomplete'
with scopes_disabled():
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
resp = _redeem(token_client, organizer, clist, p.pk, {'answers': {question[0].pk: ""}})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
assert not order.positions.first().answers.filter(question=question[0]).exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_store_failed(token_client, organizer, clist, event, order): def test_store_failed(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
+9 -560
View File
@@ -34,7 +34,7 @@ from tests.const import SAMPLE_PNG
from pretix.api.serializers.item import QuestionSerializer from pretix.api.serializers.item import QuestionSerializer
from pretix.base.models import ( from pretix.base.models import (
Checkin, InvoiceAddress, Item, Order, OrderPosition, ReusableMedium, Checkin, InvoiceAddress, Order, OrderPosition, ReusableMedium,
) )
# Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering # Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering
@@ -286,12 +286,12 @@ def test_by_secret_special_chars(token_client, organizer, clist, event, order):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium(token_client, organizer, clist, event, order): def test_by_medium(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
rm = ReusableMedium.objects.create( ReusableMedium.objects.create(
type="barcode", type="barcode",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
linked_orderposition=order.positions.first(),
) )
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 201 assert resp.status_code == 201
assert resp.data['status'] == 'ok' assert resp.data['status'] == 'ok'
@@ -301,71 +301,6 @@ def test_by_medium(token_client, organizer, clist, event, order):
assert ci.raw_source_type == "barcode" assert ci.raw_source_type == "barcode"
@pytest.mark.django_db
def test_by_medium_multiple_orderpositions(token_client, organizer, clist_all, event, order):
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="barcode",
identifier="abcdef",
organizer=organizer,
)
op_item_first = order.positions.first()
rm.linked_orderpositions.add(op_item_first)
op_item_other = order.positions.all()[1]
rm.linked_orderpositions.add(op_item_other)
# multiple tickets are valid => no check-in
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'ambiguous'
with scopes_disabled():
op_item_other.valid_from = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone)
op_item_other.valid_until = datetime.datetime(2020, 1, 1, 15, 0, 0, tzinfo=event.timezone)
op_item_other.save()
with freeze_time("2020-01-01 13:45:00"):
# multiple tickets are valid => no check-in
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'ambiguous'
with freeze_time("2020-01-01 10:45:00"):
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with freeze_time("2020-01-01 15:45:00"):
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'already_redeemed'
with scopes_disabled():
op_item_first.valid_from = datetime.datetime(2020, 1, 1, 10, 0, 0, tzinfo=event.timezone)
op_item_first.valid_until = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone)
op_item_first.save()
with freeze_time("2020-01-01 15:45:00"):
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'invalid_time'
with scopes_disabled():
op_item_first.canceled = True
op_item_first.save()
op_item_other.canceled = True
op_item_other.save()
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'canceled'
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_not_connected(token_client, organizer, clist, event, order): def test_by_medium_not_connected(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
@@ -383,12 +318,12 @@ def test_by_medium_not_connected(token_client, organizer, clist, event, order):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_wrong_event(token_client, organizer, clist, event, order2): def test_by_medium_wrong_event(token_client, organizer, clist, event, order2):
with scopes_disabled(): with scopes_disabled():
rm = ReusableMedium.objects.create( ReusableMedium.objects.create(
type="barcode", type="barcode",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
linked_orderposition=order2.positions.first(),
) )
rm.linked_orderpositions.add(order2.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.data['status'] == 'error' assert resp.data['status'] == 'error'
@@ -402,12 +337,12 @@ def test_by_medium_wrong_event(token_client, organizer, clist, event, order2):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_wrong_type(token_client, organizer, clist, event, order): def test_by_medium_wrong_type(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
rm = ReusableMedium.objects.create( ReusableMedium.objects.create(
type="nfc_uid", type="nfc_uid",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
linked_orderposition=order.positions.first(),
) )
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.data['status'] == 'error' assert resp.data['status'] == 'error'
@@ -420,13 +355,13 @@ def test_by_medium_wrong_type(token_client, organizer, clist, event, order):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_inactive(token_client, organizer, clist, event, order): def test_by_medium_inactive(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
rm = ReusableMedium.objects.create( ReusableMedium.objects.create(
type="barcode", type="barcode",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
active=False, active=False,
linked_orderposition=order.positions.first(),
) )
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.data['status'] == 'error' assert resp.data['status'] == 'error'
@@ -1253,489 +1188,3 @@ def test_annul_failures(device_client, team, organizer, clist, clist_event2, eve
with scopes_disabled(): with scopes_disabled():
ci = p.all_checkins.get() ci = p.all_checkins.get()
assert ci.successful assert ci.successful
@pytest.mark.django_db
def test_exchange_incomplete_body(token_client, organizer, clist, event, order):
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid"
})
assert resp.status_code == 400
assert resp.data == {
'non_field_errors': ['If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.']
}
@pytest.mark.django_db
def test_exchange_medium_for_medium(token_client, organizer, clist, event, order):
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="barcode",
identifier="abcdef",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {
"source_type": "barcode",
"exchange_medium_type": "barcode",
"exchange_medium_identifier": "hijkl",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'error'
@pytest.mark.django_db
def test_exchange_unknown_media_type(token_client, organizer, clist, event, order):
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "unknown",
"exchange_medium_identifier": "hijkl",
})
assert resp.status_code == 400
assert resp.data == {"exchange_medium_type": ["\"unknown\" is not a valid choice."]}
@pytest.mark.django_db
def test_exchange_disabled_media_type(token_client, organizer, clist, event, order):
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "hijkl",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'error'
assert resp.data['reason_explanation'] == 'Medium type is not enabled for organizer.'
@pytest.mark.django_db
def test_exchange_mismatch_media_type(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "barcode"
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'product'
assert resp.data['reason_explanation'] == 'Incorrect medium type for product.'
@pytest.mark.django_db
def test_exchange_no_item_policy(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'product'
assert resp.data['reason_explanation'] == 'Product does not support medium exchange.'
@pytest.mark.django_db
def test_exchange_reuse_or_new_new(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
rm = ReusableMedium.objects.get(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w"
@pytest.mark.django_db
def test_exchange_reuse_or_new_reuse_replace(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.last())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
rm.refresh_from_db()
with scopes_disabled():
assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w"
@pytest.mark.django_db
def test_exchange_reuse_or_new_reuse_append(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.last())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
rm.refresh_from_db()
with scopes_disabled():
assert rm.linked_orderpositions.count() == 2
assert rm.linked_orderpositions.filter(secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w").exists()
@pytest.mark.django_db
def test_exchange_reuse_exists_append(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.last())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
rm.refresh_from_db()
with scopes_disabled():
assert rm.linked_orderpositions.count() == 2
assert rm.linked_orderpositions.filter(secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w").exists()
@pytest.mark.django_db
def test_exchange_reuse_expired(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_REUSE
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
expires=now() - datetime.timedelta(hours=2),
)
rm.linked_orderpositions.add(order.positions.last())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'medium_invalid'
@pytest.mark.django_db
def test_exchange_reuse_not_exists(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_REUSE
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'medium_invalid'
@pytest.mark.django_db
def test_exchange_new_exists(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.last())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
"exchange_link_action": "append",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'medium_exists'
@pytest.mark.django_db
def test_exchange_new_not_exists(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
"exchange_link_action": "replace",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
rm = ReusableMedium.objects.get(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w"
@pytest.mark.django_db
def test_exchange_required(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
})
assert resp.status_code == 400
assert resp.data['status'] == 'exchange'
assert resp.data['media_policy'] == 'new'
assert resp.data['media_type'] == 'nfc_uid'
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
# Force works
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"force": True,
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
@pytest.mark.django_db
def test_exchanged_original_barcode_ok(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
@pytest.mark.django_db
def test_exchanged_original_barcode_not_ok(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
organizer.settings.reusable_media_usage_enforced = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'already_exchanged'
# Force works
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"force": True,
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
@pytest.mark.django_db
def test_exchanged_scan_medium_ok(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
organizer.settings.reusable_media_usage_enforced = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "12345678", {
"source_type": "nfc_uid",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
@pytest.mark.django_db
def test_exchanged_double_exchange(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
organizer.settings.reusable_media_usage_enforced = False
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "87654321",
"exchange_link_action": "replace",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'already_exchanged'
@pytest.mark.django_db
@pytest.mark.parametrize(
"media_policy,media_type",
[
(Item.MEDIA_POLICY_NEW, "nfc_mf0aes"),
(Item.MEDIA_POLICY_REUSE_OR_NEW, "nfc_mf0aes"),
(Item.MEDIA_POLICY_APPEND_OR_NEW, "nfc_mf0aes"),
(Item.MEDIA_POLICY_NEW, "barcode"),
(Item.MEDIA_POLICY_REUSE_OR_NEW, "barcode"),
(Item.MEDIA_POLICY_APPEND_OR_NEW, "barcode"),
]
)
def test_exchange_unsupported_media_type_for_new(token_client, organizer, clist, event, order, item, media_policy, media_type):
organizer.settings.set(f'reusable_media_type_{media_type}', True)
# Shouldn't be configurable, but test that the logic is solid anyway
item.media_type = media_type
item.media_policy = media_policy
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": media_type,
"exchange_medium_identifier": "12345678",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'medium_invalid'
@pytest.mark.django_db
@pytest.mark.parametrize(
"media_policy",
[
Item.MEDIA_POLICY_NEW,
Item.MEDIA_POLICY_REUSE_OR_NEW,
Item.MEDIA_POLICY_APPEND_OR_NEW,
]
)
def test_exchange_rejected_media_identifier(token_client, organizer, clist, event, order, item, media_policy):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = media_policy
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "08RANDOM",
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'medium_invalid'
@pytest.mark.django_db
@pytest.mark.parametrize(
"media_policy",
[
Item.MEDIA_POLICY_NEW,
Item.MEDIA_POLICY_REUSE_OR_NEW,
Item.MEDIA_POLICY_APPEND_OR_NEW,
]
)
def test_exchange_create_gift_card(token_client, organizer, clist, event, order, item, media_policy):
organizer.settings.reusable_media_type_nfc_uid = True
organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard = True
organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard_currency = "EUR"
item.media_type = "nfc_uid"
item.media_policy = media_policy
item.save()
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "0412345",
})
assert resp.status_code == 201
with scopes_disabled():
rm = ReusableMedium.objects.get(identifier="0412345")
assert rm.linked_giftcard.currency == "EUR"
+2 -61
View File
@@ -3121,68 +3121,9 @@ def test_order_create_use_medium(token_client, organizer, event, item, quota, qu
with scopes_disabled(): with scopes_disabled():
o = Order.objects.get(code=resp.data['code']) o = Order.objects.get(code=resp.data['code'])
medium.refresh_from_db() medium.refresh_from_db()
assert o.positions.first() == medium.linked_orderpositions.first() assert o.positions.first() == medium.linked_orderposition
assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.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'])
medium.refresh_from_db()
assert medium.linked_orderpositions.count() == 1
assert o.positions.first() == medium.linked_orderpositions.first()
assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier
@pytest.mark.django_db
def test_order_create_add_to_medium(token_client, organizer, event, item, quota, question, medium):
item.media_type = medium.type
item.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['use_reusable_medium'] = medium.pk
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
medium.refresh_from_db()
assert medium.linked_orderpositions.count() == 1
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
medium.refresh_from_db()
assert medium.linked_orderpositions.count() == 2
item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW
item.save()
res['positions'][0]['use_reusable_medium'] = medium.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.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'])
medium.refresh_from_db()
assert medium.linked_orderpositions.count() == 1
assert o.positions.first() == medium.linked_orderpositions.first()
@pytest.mark.django_db @pytest.mark.django_db
def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2): def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2):
@@ -3227,7 +3168,7 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota,
i = resp.data['positions'][0]['pdf_data']['medium_identifier'] i = resp.data['positions'][0]['pdf_data']['medium_identifier']
assert i assert i
m = organizer.reusable_media.get(identifier=i) m = organizer.reusable_media.get(identifier=i)
assert m.linked_orderpositions.first() == o.positions.first() assert m.linked_orderposition == o.positions.first()
assert m.type == "barcode" assert m.type == "barcode"
+3 -172
View File
@@ -89,13 +89,10 @@ TEST_MEDIUM_RES = {
"organizer": "dummy", "organizer": "dummy",
"identifier": "ABCDEFGH", "identifier": "ABCDEFGH",
"type": "barcode", "type": "barcode",
"claim_token": None,
"label": None,
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderposition": None, "linked_orderposition": None,
"linked_orderpositions": [],
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
"info": {}, "info": {},
@@ -173,7 +170,7 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True) personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14")) op = o.positions.create(item=ticket, price=Decimal("14"))
medium.linked_orderpositions.add(op) medium.linked_orderposition = op
medium.linked_giftcard = giftcard medium.linked_giftcard = giftcard
medium.customer = customer medium.customer = customer
medium.save() medium.save()
@@ -276,7 +273,7 @@ def test_medium_detail_event_permission_missing(token_client, organizer, event,
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True) personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14")) op = o.positions.create(item=ticket, price=Decimal("14"))
medium.linked_orderpositions.add(op) medium.linked_orderposition = op
medium.linked_giftcard = giftcard medium.linked_giftcard = giftcard
medium.customer = customer medium.customer = customer
medium.save() medium.save()
@@ -355,110 +352,6 @@ def test_medium_create(token_client, organizer, giftcard):
assert m.updated > now() - timedelta(minutes=10) assert m.updated > now() - timedelta(minutes=10)
@pytest.mark.django_db
def test_medium_create_linked_orderposition(token_client, organizer, event, org2_event, medium):
with scopes_disabled():
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
total=14, locale='en'
)
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
op2 = o.positions.create(item=ticket, price=Decimal("14"))
org2_o = Order.objects.create(
code='FOO', event=org2_event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
sales_channel=org2_event.organizer.sales_channels.get(identifier="web"),
total=14, locale='en'
)
org2_ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True)
org2_op = org2_o.positions.create(item=org2_ticket, price=Decimal("14"))
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
# wrong orderposition for organizer
payload['linked_orderposition'] = org2_op.pk
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 400
# unkown orderposition
payload['linked_orderposition'] = "unknown"
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 400
# create with linked_orderposition
payload['linked_orderposition'] = op.pk
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
m = ReusableMedium.objects.get(pk=resp.data['id'])
assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk]
# double-check API-response for fallback-values
resp = token_client.get(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id'])
)
assert resp.status_code == 200
assert resp.data['linked_orderposition'] == op.pk
assert resp.data['linked_orderpositions'] == [op.pk]
# create with linked_orderposition and linked_orderpositions (not allowed)
payload['identifier'] = "FOOBAZ"
payload['linked_orderpositions'] = [op.pk, org2_op.pk]
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 400
# multiple linked_orderpositions, but from different organizers
del payload['linked_orderposition']
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 400
# multiple linked_orderpositions from same organizer
payload['linked_orderpositions'] = [op.pk, op2.pk]
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
m = ReusableMedium.objects.get(pk=resp.data['id'])
assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk]
# double-check API-response for fallback-values
resp = token_client.get(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id'])
)
assert resp.status_code == 200
assert resp.data['linked_orderposition'] is None
assert resp.data['linked_orderpositions'] == [op.pk, op2.pk]
@pytest.mark.django_db @pytest.mark.django_db
def test_medium_foreignkeyval(token_client, organizer, giftcard2): def test_medium_foreignkeyval(token_client, organizer, giftcard2):
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
@@ -505,68 +398,6 @@ def test_medium_patch(token_client, organizer, event, medium, giftcard, customer
assert medium.info == {'test': 2} assert medium.info == {'test': 2}
assert medium.identifier == "ABCDEFGH" assert medium.identifier == "ABCDEFGH"
# test patch with linked_orderpositions
with scopes_disabled():
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
total=14, locale='en'
)
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
op2 = o.positions.create(item=ticket, price=Decimal("14"))
resp = token_client.patch(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
{
'linked_orderposition': op.pk,
},
format='json'
)
assert resp.status_code == 200
medium.refresh_from_db()
with scopes_disabled():
assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk]
assert medium.all_logentries().count() == 2
resp = token_client.patch(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
{
'linked_orderpositions': [op.pk, op2.pk],
},
format='json'
)
assert resp.status_code == 200
medium.refresh_from_db()
with scopes_disabled():
assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk]
assert medium.all_logentries().count() == 3
resp = token_client.patch(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
{
'linked_orderpositions': [op2.pk],
},
format='json'
)
assert resp.status_code == 200
medium.refresh_from_db()
with scopes_disabled():
assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op2.pk]
assert medium.all_logentries().count() == 4
resp = token_client.patch(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
{
'linked_orderposition': op.pk,
'linked_orderpositions': [op.pk, op2.pk],
},
format='json'
)
assert resp.status_code == 400
@pytest.mark.django_db @pytest.mark.django_db
def test_medium_no_deletion(token_client, organizer, event, medium): def test_medium_no_deletion(token_client, organizer, event, medium):
@@ -707,7 +538,7 @@ def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2
ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True) personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14")) op = o.positions.create(item=ticket, price=Decimal("14"))
medium2.linked_orderpositions.add(op) medium2.linked_orderposition = op
medium2.linked_giftcard = giftcard2 medium2.linked_giftcard = giftcard2
medium2.save() medium2.save()
+1 -1
View File
@@ -1186,7 +1186,7 @@ def test_rules_reasoning_prefer_number_over_date(event, position, clist):
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_position_queries(django_assert_max_num_queries, position, clist): def test_position_queries(django_assert_max_num_queries, position, clist):
with django_assert_max_num_queries(12) as captured: with django_assert_max_num_queries(13) as captured:
perform_checkin(position, clist, {}) perform_checkin(position, clist, {})
if 'sqlite' not in settings.DATABASES['default']['ENGINE']: if 'sqlite' not in settings.DATABASES['default']['ENGINE']:
assert any('FOR UPDATE' in s['sql'] for s in captured) assert any('FOR UPDATE' in s['sql'] for s in captured)
+2 -2
View File
@@ -4123,8 +4123,8 @@ def test_giftcard_multiple(event):
for p in order.payments.all(): for p in order.payments.all():
p.payment_provider.execute_payment(None, p) p.payment_provider.execute_payment(None, p)
assert order.payments.get(amount=Decimal("12.00")).info_data["gift_card"] == gc1.pk assert order.payments.get(info__icontains=gc1.pk).amount == Decimal('12.00')
assert order.payments.get(amount=Decimal("11.00")).info_data["gift_card"] == gc2.pk assert order.payments.get(info__icontains=gc2.pk).amount == Decimal('11.00')
gc1 = GiftCard.objects.get(pk=gc1.pk) gc1 = GiftCard.objects.get(pk=gc1.pk)
assert gc1.value == 0 assert gc1.value == 0
gc2 = GiftCard.objects.get(pk=gc2.pk) gc2 = GiftCard.objects.get(pk=gc2.pk)
+2 -4
View File
@@ -137,7 +137,6 @@ event_urls = [
"subevents/select2", "subevents/select2",
"subevents/add", "subevents/add",
"subevents/2/delete", "subevents/2/delete",
"subevents/2/edit",
"subevents/2/", "subevents/2/",
"quotas/", "quotas/",
"quotas/2/delete", "quotas/2/delete",
@@ -361,9 +360,8 @@ event_permission_urls = [
("event.items:write", "discounts/reorder", 400, HTTP_POST), ("event.items:write", "discounts/reorder", 400, HTTP_POST),
("event.items:write", "discounts/add", 200, HTTP_GET), ("event.items:write", "discounts/add", 200, HTTP_GET),
(None, "subevents/", 200, HTTP_GET), (None, "subevents/", 200, HTTP_GET),
(None, "subevents/2/", 404, HTTP_GET), ("event.subevents:write", "subevents/2/", 404, HTTP_GET),
("event.subevents:write", "subevents/2/edit", 404, HTTP_GET), ("event.subevents:write", "subevents/2/", 404, HTTP_POST),
("event.subevents:write", "subevents/2/edit", 404, HTTP_POST),
("event.subevents:write", "subevents/2/delete", 404, HTTP_GET), ("event.subevents:write", "subevents/2/delete", 404, HTTP_GET),
("event.subevents:write", "subevents/add", 200, HTTP_GET), ("event.subevents:write", "subevents/add", 200, HTTP_GET),
("event.subevents:write", "subevents/bulk_add", 200, HTTP_GET), ("event.subevents:write", "subevents/bulk_add", 200, HTTP_GET),
+2 -2
View File
@@ -110,9 +110,9 @@ class SubEventsTest(SoupTest):
assert se.checkinlist_set.count() == 1 assert se.checkinlist_set.count() == 1
def test_modify(self): def test_modify(self):
doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/edit' % self.subevent1.pk) doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk)
assert doc.select("input[name=quotas-TOTAL_FORMS]") assert doc.select("input[name=quotas-TOTAL_FORMS]")
doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/edit' % self.subevent1.pk, { doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk, {
'name_0': 'SE2', 'name_0': 'SE2',
'active': 'on', 'active': 'on',
'date_from_0': '2017-07-01', 'date_from_0': '2017-07-01',
+3 -4
View File
@@ -731,13 +731,11 @@ def event_series(organizer):
"""Create an event series with multiple subevents, items, and quotas.""" """Create an event series with multiple subevents, items, and quotas."""
from pretix.base.models import ItemCategory from pretix.base.models import ItemCategory
base_date = _future_dt(days=30, hour=19)
event = Event.objects.create( event = Event.objects.create(
organizer=organizer, organizer=organizer,
name='Concert Series', name='Concert Series',
slug='concert-series', slug='concert-series',
date_from=base_date, date_from=_future_dt(days=30, hour=19),
has_subevents=True, has_subevents=True,
currency='EUR', currency='EUR',
live=True, live=True,
@@ -762,8 +760,9 @@ def event_series(organizer):
) )
subevents = [] subevents = []
base_date = _future_dt(days=30, hour=19)
for i in range(20): for i in range(15):
se = SubEvent.objects.create( se = SubEvent.objects.create(
event=event, event=event,
name=f'Concert Night {i + 1}', name=f'Concert Night {i + 1}',
+50
View File
@@ -0,0 +1,50 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix 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 pytest
from django.core.exceptions import SuspiciousFileOperation
from reportlab.platypus import Paragraph
def test_http_access_disabled(monkeypatch):
def guard(*args, **kwargs):
pytest.fail("No internet wanted!")
monkeypatch.setattr('socket.socket', guard)
with pytest.raises(SuspiciousFileOperation, match="should not be reading images from disk"):
Paragraph(
'<img src="https://static.pretix.cloud/static/pretixeu/img/opengraph.png"/>',
)
def test_file_access_disabled_scheme(monkeypatch):
with pytest.raises(SuspiciousFileOperation, match="should not be reading images from disk"):
Paragraph(
'<img src="file:///etc/passwd" />',
)
def test_file_access_disabled_direct(monkeypatch):
with pytest.raises(SuspiciousFileOperation, match="should not be reading images from disk"):
Paragraph(
'<img src="/etc/passwd" />',
)