Compare commits

..

1 Commits

Author SHA1 Message Date
Raphael Michel
8916fbe3c6 Security profiles: Ensure default profile is always listed first 2026-06-09 19:56:13 +02:00
41 changed files with 133 additions and 927 deletions

View File

@@ -57,7 +57,8 @@ COPY vite.config.ts /pretix/vite.config.ts
RUN pip3 install -U \
pip \
setuptools && \
setuptools \
wheel && \
cd /pretix && \
PRETIX_DOCKER_BUILD=TRUE pip3 install \
-e ".[memcached]" \

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
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.
:<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
order) when building texts (currently only the ``reason_explanation`` response field).
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
``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_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
@@ -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),
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 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**:
@@ -228,9 +224,6 @@ Checking a ticket in
* ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked.
* ``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.
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``

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
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
exchange are only supported on the new API.
URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead.
: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
@@ -742,9 +741,6 @@ Order position endpoints
* ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked.
* ``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``
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.

View File

@@ -131,7 +131,7 @@ allow_waitinglist boolean If ``false``,
product when it is sold out.
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).
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.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.

View File

@@ -1069,7 +1069,8 @@ 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_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)
* ``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)
* ``add_to_reusable_medium`` (optional, causes the new ticket to be added to 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)
* ``answers``

View File

@@ -29,11 +29,11 @@ classifiers = [
dependencies = [
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"babel",
"BeautifulSoup4==4.15.*",
"BeautifulSoup4==4.14.*",
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=48.0.1",
"cryptography>=48.0.0",
"css-inline==0.20.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
@@ -93,7 +93,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==4.5.*",
"requests==2.32.*",
"sentry-sdk==2.62.*",
"sentry-sdk==2.61.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -139,6 +139,8 @@ build-backend = "backend"
backend-path = ["_build"]
requires = [
"setuptools",
"setuptools-rust",
"wheel",
"importlib_metadata",
"tomli",
]

View File

@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import logging
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
@@ -52,10 +51,18 @@ class BaseSecurityProfile:
"""
raise NotImplementedError()
@property
def priority(self) -> int:
"""
Priority for ordering, higher will come first.
"""
return 100
class FullAccessSecurityProfile(BaseSecurityProfile):
identifier = 'full'
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
priority = 1000
def is_allowed(self, request):
return True
@@ -110,8 +117,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
('PATCH', 'api-v1:reusablemedium-detail')
)
@@ -192,13 +197,15 @@ def get_all_security_profiles():
if _ALL_PROFILES:
return _ALL_PROFILES
types = OrderedDict()
types = []
for recv, ret in register_device_security_profile.send(None):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.identifier] = r
types.append(r)
else:
types[ret.identifier] = ret
types.append(ret)
types.sort(key=lambda el: el.priority, reverse=True)
types = {r.identifier: r for r in types}
_ALL_PROFILES = types
return types

View File

@@ -88,19 +88,11 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
nonce = serializers.CharField(required=False, allow_null=True)
datetime = serializers.DateTimeField(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):
super().__init__(*args, **kwargs)
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):
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)

View File

@@ -871,7 +871,6 @@ class EventSettingsSerializer(SettingsSerializer):
'og_image',
'name_scheme',
'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
@@ -886,7 +885,6 @@ class EventSettingsSerializer(SettingsSerializer):
readonly_fields = [
# These are read-only since they are currently only settable on organizers, not events
'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
@@ -972,7 +970,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order',
'tax_rule_payment',
'tax_rule_cancellation',

View File

@@ -1043,13 +1043,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True)
add_to_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
'requested_valid_from', 'use_reusable_medium', 'discount')
'requested_valid_from', 'use_reusable_medium', 'add_to_reusable_medium', 'discount')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1061,6 +1063,8 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
with scopes_disabled():
if 'use_reusable_medium' in self.fields:
self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all()
if 'add_to_reusable_medium' in self.fields:
self.fields['add_to_reusable_medium'].queryset = ReusableMedium.objects.all()
def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -1076,6 +1080,9 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
)
return m
def validate_add_to_reusable_medium(self, m):
return self.validate_use_reusable_medium(m)
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
@@ -1150,6 +1157,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
)
if 'use_reusable_medium' in data and 'add_to_reusable_medium' in data:
raise ValidationError({
'use_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'],
'add_to_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'],
})
return data
@@ -1589,7 +1602,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos_data['attendee_name_parts'] = {
'_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 not in ('answers', '_quotas', 'use_reusable_medium', 'add_to_reusable_medium')})
if simulate:
pos.order = order._wrapped
else:
@@ -1663,6 +1676,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
add_to_reusable_medium = pos_data.pop('add_to_reusable_medium', None)
pos = pos_data['__instance']
pos._calculate_tax(invoice_address=ia)
@@ -1704,17 +1718,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ.options.add(*options)
if use_reusable_medium:
if pos.item.media_policy not in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW):
for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True):
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)
for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True):
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
data={
'linked_orderposition': op_pk,
}
)
use_reusable_medium.linked_orderpositions.set([pos])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
data={
@@ -1722,7 +1733,15 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
'linked_orderposition': pos.pk,
}
)
use_reusable_medium.touch()
elif add_to_reusable_medium:
add_to_reusable_medium.linked_orderpositions.add(pos)
add_to_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
data={
'by_order': order.code,
'linked_orderposition': pos.pk,
}
)
if not simulate:
for cp in delete_cps:

View File

@@ -605,7 +605,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no',
'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',

View File

@@ -69,10 +69,8 @@ from pretix.base.models import (
from pretix.base.models.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf
from pretix.base.services.checkin import (
CheckInError, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic,
perform_checkin,
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
)
from pretix.base.services.media import perform_media_exchange
from pretix.base.signals import checkin_annulled
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,
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,
exchange_medium_type=None, exchange_medium_identifier=None):
source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False):
if not checkinlists:
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
gate = gate or (auth.gate if isinstance(auth, Device) else None)
medium = None
context = {
'request': request,
@@ -526,7 +522,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates:
try:
medium = ReusableMedium.objects.active().filter(
media = ReusableMedium.objects.active().filter(
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
).get(
organizer_id=checkinlists[0].event.organizer_id,
@@ -634,7 +630,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400)
else:
linked_ops = medium.linked_orderpositions.all().select_related("order").prefetch_related("addons")
linked_ops = media.linked_orderpositions.all().select_related("order").prefetch_related("addons")
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
@@ -665,7 +661,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
op_candidates = []
for op in linked_ops:
if op.order.event_id in list_by_event:
reusable_medium_used = medium
reusable_medium_used = media
op_candidates.append(op)
if list_by_event[op.order.event_id].addon_match:
op_candidates += list(op.addons.all())
@@ -808,14 +804,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
locale = op.order.event.settings.locale
with language(locale):
try:
if exchange_medium_identifier and medium:
# 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(
perform_checkin(
op=op,
clist=list_by_event[op.order.event_id],
given_answers=given_answers,
@@ -833,25 +822,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
from_revoked_secret=from_revoked_secret,
simulate=simulate,
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:
return Response({
'status': 'incomplete',
@@ -863,17 +834,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
],
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, 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:
if not simulate:
op.order.log_action('pretix.event.checkin.denied', data={
@@ -1061,8 +1021,6 @@ class CheckinRPCRedeemView(views.APIView):
canceled_supported=True,
request=self.request, # this is not clean, but we need it in the serializers for URL generation
legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
)

View File

@@ -196,7 +196,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
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):
date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset())

View File

@@ -44,8 +44,7 @@ class Command(Parent):
# Start the vite server in the background
vite_server = subprocess.Popen(
["npm", "run", "dev:control"],
cwd=Path(__file__).parent.parent.parent.parent.parent,
stdin=subprocess.DEVNULL
cwd=Path(__file__).parent.parent.parent.parent.parent
)
def cleanup():

View File

@@ -26,7 +26,6 @@ from django.utils.translation import gettext_lazy as _
class BaseMediaType:
medium_created_by_server = False
medium_created_from_unknown_supported = False
supports_orderposition = False
supports_giftcard = False
@@ -57,7 +56,7 @@ class BaseMediaType:
def is_active(self, organizer):
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
def handle_new(self, organizer, medium, user, auth):
@@ -89,32 +88,23 @@ class NfcUidMediaType(BaseMediaType):
verbose_name = _('NFC UID-based')
icon = 'pretixbase/img/media/nfc_uid.svg'
medium_created_by_server = False
medium_created_from_unknown_supported = 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
create_giftcard = organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool)
if create_giftcard or force_create:
if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
if identifier.startswith("08"):
# 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.
return
with transaction.atomic():
if create_giftcard:
gc = GiftCard.objects.create(
issuer=organizer,
expires=organizer.default_gift_card_expiry,
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
gc = GiftCard.objects.create(
issuer=organizer,
expires=organizer.default_gift_card_expiry,
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
)
m = ReusableMedium.objects.create(
type=self.identifier,
identifier=identifier,
@@ -126,6 +116,10 @@ class NfcUidMediaType(BaseMediaType):
'pretix.reusable_medium.created.auto',
user=user, auth=auth,
)
gc.log_action(
'pretix.giftcards.created',
user=user, auth=auth,
)
return m
@@ -135,7 +129,7 @@ class NfcMf0aesMediaType(BaseMediaType):
icon = 'pretixbase/img/media/nfc_secure.svg'
medium_created_by_server = False
supports_giftcard = True
supports_orderposition = True
supports_orderposition = False
def handle_new(self, organizer, medium, user, auth):
from pretix.base.models import GiftCard

View File

@@ -346,14 +346,11 @@ class Checkin(models.Model):
REASON_INCOMPLETE = 'incomplete'
REASON_ALREADY_REDEEMED = 'already_redeemed'
REASON_AMBIGUOUS = 'ambiguous'
REASON_MEDIUM_INVALID = 'medium_invalid'
REASON_MEDIUM_EXISTS = 'medium_exists'
REASON_ERROR = 'error'
REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved'
REASON_INVALID_TIME = 'invalid_time'
REASON_ANNULLED = 'annulled'
REASON_ALREADY_EXCHANGED = 'already_exchanged'
REASONS = (
(REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')),
@@ -369,9 +366,6 @@ class Checkin(models.Model):
(REASON_UNAPPROVED, _('Order not approved')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
(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(

View File

@@ -452,16 +452,11 @@ class Item(LoggedModel):
MEDIA_POLICY_REUSE = 'reuse'
MEDIA_POLICY_NEW = 'new'
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
MEDIA_POLICY_APPEND = 'append'
MEDIA_POLICY_APPEND_OR_NEW = 'append_or_new'
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_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, 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')),
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
)
objects = ItemQuerySetManager()
@@ -774,7 +769,7 @@ class Item(LoggedModel):
null=True, blank=True, max_length=16,
verbose_name=_('Reusable media policy'),
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 '
'renewable season tickets or re-chargeable gift card wristbands. '
'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(
max_length=100,
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'),
help_text=_(
'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.'))
if not mt.supports_giftcard and issue_giftcard:
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:
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 '
@@ -2230,7 +2220,7 @@ class Quota(LoggedModel):
class ItemMetaProperty(LoggedModel):
"""
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.
:type event: Event

View File

@@ -129,10 +129,7 @@ class ReusableMedium(LoggedModel):
@property
def is_expired(self):
return self.expires and self.expires < now()
def touch(self):
self.save(update_fields=['updated'])
return self.expires and self.expires > now()
class Meta:
unique_together = (("identifier", "type", "organizer"),)

View File

@@ -287,11 +287,11 @@ def _check_position_constraints(
raise CartPositionError(error_messages['unavailable'])
# 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]
if not mt.medium_created_by_server:
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'])
# Item removed from sales channel

View File

@@ -867,15 +867,6 @@ class RequiredQuestionsError(Exception):
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 _create_answer(question, answer):
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,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
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
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 simulate: If true, the check-in is not saved.
: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():
# 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:
opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk)
@@ -1111,24 +1101,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
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
if isinstance(auth, Device):
device = auth

View File

@@ -23,13 +23,10 @@ import secrets
from django.db import IntegrityError
from django.db.models import Q
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, GiftCardAcceptance, Item
from pretix.base.models.media import MediumKeySet, ReusableMedium
from pretix.base.services.checkin import CheckInError
from pretix.base.models import GiftCardAcceptance
from pretix.base.models.media import MediumKeySet
def create_nfc_mf0aes_keyset(organizer):
@@ -73,174 +70,3 @@ def get_keysets_for_organizer(organizer):
if new_set:
sets.append(new_set)
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

View File

@@ -3506,7 +3506,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
from pretix.base.models import ReusableMedium
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]
if mt.medium_created_by_server and not p.linked_media.exists():
rm = ReusableMedium.objects.create(

View File

@@ -211,25 +211,12 @@ DEFAULTS = {
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Activate reusable media"),
help_text=_("The reusable 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 "
label=_("Activate re-usable 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 re-used for different tickets or gift cards "
"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': {
'default': 'False',
'type': bool,

View File

@@ -20,6 +20,6 @@
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</body>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</html>

View File

@@ -636,7 +636,6 @@ class OrganizerSettingsForm(SettingsForm):
'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no',
'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',

View File

@@ -746,7 +746,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'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.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.email.error': _('Sending of an email has failed.'),
'pretix.event.comment': _('The event\'s internal comment has been updated.'),

View File

@@ -54,8 +54,6 @@
<span class="fa fa-check-circle"></span>
{% elif result.status == "incomplete" %}
<span class="fa fa-question-circle"></span>
{% elif result.status == "exchange" %}
<span class="fa fa-recycle"></span>
{% elif result.status == "error" %}
{% if result.reason == "already_redeemed" %}
<span class="fa fa-warning"></span>
@@ -81,14 +79,6 @@
</li>
{% endfor %}
</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" %}
<h3 class="nomargin-top">{{ reason_labels|getitem:result.reason }}</h3>
{% if result.reason_explanation %}

View File

@@ -222,7 +222,6 @@
<fieldset>
<legend>{% trans "Reusable media" %}</legend>
{% 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 class="panel panel-default">

View File

@@ -2,7 +2,6 @@
{% load i18n %}
{% load bootstrap3 %}
{% load getitem %}
{% load icon %}
{% block inner %}
{% if team %}
<h1>{% trans "Team:" %} {{ team.name }}</h1>
@@ -26,18 +25,6 @@
<legend>{% trans "Organizer permissions" %}</legend>
{% 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>
<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 %}
{% bootstrap_field form|getitem:f layout="control" %}
{% endfor %}
@@ -50,17 +37,6 @@
{% bootstrap_field form.limit_events 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>
<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 %}
{% bootstrap_field form|getitem:f layout="control" %}
{% endfor %}

View File

@@ -50,7 +50,7 @@ from i18nfield.strings import LazyI18nString
from pretix.api.views.checkin import _redeem_process
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.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf
@@ -533,8 +533,6 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView):
checkinlist=self.list,
result=self.result,
reason_labels=dict(Checkin.REASONS),
media_policies=dict(Item.MEDIA_POLICIES),
media_types=dict(MEDIA_TYPES),
)
def form_valid(self, form):

View File

@@ -396,7 +396,6 @@ class OrderDeleteBulkActionView(BaseOrderBulkActionView):
def execute_single(self, instance, form: forms.Form):
instance.gracefully_delete(user=self.request.user)
return True
class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):

View File

@@ -200,7 +200,7 @@ def giftcard_select2(request, **kwargs):
except ValueError:
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(
Q(secret__icontains=query)
).order_by('secret')

View File

@@ -5,16 +5,16 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-09 20:00+0000\n"
"Last-Translator: Mira <weller@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
"de/>\n"
"PO-Revision-Date: 2026-05-27 15:20+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/de/"
">\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\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"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
@@ -11353,7 +11353,7 @@ msgid ""
msgstr ""
"Wenn diese Option deaktiviert ist, werden Tickets nur für Produkte "
"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."
#: pretix/base/settings.py:1813

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-09 20:00+0000\n"
"Last-Translator: Mira <weller@rami.io>\n"
"PO-Revision-Date: 2026-05-27 15:20+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
"pretix/pretix/de_Informal/>\n"
"Language: de_Informal\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\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
#: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -11339,8 +11339,8 @@ msgid ""
"issuing in every product separately."
msgstr ""
"Wenn diese Option deaktiviert ist, werden Tickets nur für Produkte "
"aktiviert, bei denen die Option \"Berechtigt zum Eintritt\" gesetzt ist. Du "
"kannst die Ticketgenerierung auch in den Einstellungen jedes Produktes "
"aktiviert, bei denen die Option \"Berechtigt zum Eintritt\" gesetzt ist. Sie "
"können die Ticketgenerierung auch in den Einstellungen von jedes Produktes "
"einzeln abschalten."
#: pretix/base/settings.py:1813

View File

@@ -4,8 +4,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-08 17:00+0000\n"
"Last-Translator: Sébastien BRUNEAU <s.bruneau@beauvaisis.fr>\n"
"PO-Revision-Date: 2026-05-29 17:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/"
"fr/>\n"
"Language: fr\n"
@@ -13,7 +13,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\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
#: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -35123,7 +35123,7 @@ msgstr "Vous devez cocher toutes les cases en bas de la page."
#: pretix/presale/forms/checkout.py:67
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
msgid ""

View File

@@ -1,4 +1,5 @@
'use strict';
{
const globals = this;

View File

@@ -864,9 +864,6 @@ tbody th {
.checkin-sim-result-status-incomplete {
background: $brand-primary;
}
.checkin-sim-result-status-exchange {
background: $brand-primary;
}
.checkin-sim-result-status-error {
background: $brand-danger;
}

View File

@@ -34,7 +34,7 @@ from tests.const import SAMPLE_PNG
from pretix.api.serializers.item import QuestionSerializer
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
@@ -1253,489 +1253,3 @@ def test_annul_failures(device_client, team, organizer, clist, clist_event2, eve
with scopes_disabled():
ci = p.all_checkins.get()
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"

View File

@@ -3141,13 +3141,24 @@ def test_order_create_use_medium(token_client, organizer, event, item, quota, qu
@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.media_policy = Item.MEDIA_POLICY_REUSE_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]['add_to_reusable_medium'] = medium.pk
res['positions'][0]['answers'][0]['question'] = question.pk
# do not use use_reusable_medium and add_to_reusable_medium
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 == 400
del res['positions'][0]['use_reusable_medium']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
@@ -3168,9 +3179,8 @@ def test_order_create_add_to_medium(token_client, organizer, event, item, quota,
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
del res['positions'][0]['add_to_reusable_medium']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug

View File

@@ -1186,7 +1186,7 @@ def test_rules_reasoning_prefer_number_over_date(event, position, clist):
@pytest.mark.django_db(transaction=True)
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, {})
if 'sqlite' not in settings.DATABASES['default']['ENGINE']:
assert any('FOR UPDATE' in s['sql'] for s in captured)

View File

@@ -4123,8 +4123,8 @@ def test_giftcard_multiple(event):
for p in order.payments.all():
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(amount=Decimal("11.00")).info_data["gift_card"] == gc2.pk
assert order.payments.get(info__icontains=gc1.pk).amount == Decimal('12.00')
assert order.payments.get(info__icontains=gc2.pk).amount == Decimal('11.00')
gc1 = GiftCard.objects.get(pk=gc1.pk)
assert gc1.value == 0
gc2 = GiftCard.objects.get(pk=gc2.pk)