Add Reusable Media Exchange to Checkin API

This commit is contained in:
Martin Gross
2026-04-27 14:37:51 +02:00
parent 82a14a4f83
commit 0dc95a22df
13 changed files with 103 additions and 6 deletions

View File

@@ -871,6 +871,7 @@ 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',
@@ -885,6 +886,7 @@ 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',

View File

@@ -605,6 +605,7 @@ 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',

View File

@@ -69,7 +69,7 @@ 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, RequiredQuestionsError, SQLLogic, perform_checkin, CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, RequiredMediaExchangeError,
) )
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
@@ -454,7 +454,8 @@ 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): media_exchange_supported, source_type='barcode', legacy_url_support=False, simulate=False,
gate=None, use_order_locale=False):
if not checkinlists: if not checkinlists:
raise ValidationError('No check-in list passed.') raise ValidationError('No check-in list passed.')
@@ -463,6 +464,7 @@ 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)
media = None
context = { context = {
'request': request, 'request': request,
@@ -744,6 +746,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
datetime=datetime, datetime=datetime,
questions_supported=questions_supported, questions_supported=questions_supported,
canceled_supported=canceled_supported, canceled_supported=canceled_supported,
media_exchange_supported=media_exchange_supported,
user=user, user=user,
auth=auth, auth=auth,
type=checkin_type, type=checkin_type,
@@ -752,6 +755,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_media=media,
) )
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({
@@ -764,6 +768,16 @@ 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,
}, 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={
@@ -913,6 +927,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true', pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
questions_supported=self.request.data.get('questions_supported', True), questions_supported=self.request.data.get('questions_supported', True),
canceled_supported=self.request.data.get('canceled_supported', False), canceled_supported=self.request.data.get('canceled_supported', False),
media_exchange_supported=self.request.data.get('media_exchange_supported', False),
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=True, legacy_url_support=True,
) )
@@ -949,6 +964,7 @@ class CheckinRPCRedeemView(views.APIView):
questions_supported=s.validated_data['questions_supported'], questions_supported=s.validated_data['questions_supported'],
use_order_locale=s.validated_data['use_order_locale'], use_order_locale=s.validated_data['use_order_locale'],
canceled_supported=True, canceled_supported=True,
media_exchange_supported=s.validated_data.get('media_exchange_supported', False),
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,
) )

View File

@@ -89,7 +89,7 @@ class NfcUidMediaType(BaseMediaType):
icon = 'pretixbase/img/media/nfc_uid.svg' icon = 'pretixbase/img/media/nfc_uid.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = False supports_orderposition = True
def handle_unknown(self, organizer, identifier, user, auth): def handle_unknown(self, organizer, identifier, user, auth):
from pretix.base.models import GiftCard, ReusableMedium from pretix.base.models import GiftCard, ReusableMedium
@@ -129,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 = False supports_orderposition = True
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

View File

@@ -351,6 +351,7 @@ class Checkin(models.Model):
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')),
@@ -366,6 +367,7 @@ 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')),
) )
successful = models.BooleanField( successful = models.BooleanField(

View File

@@ -867,6 +867,15 @@ 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:
@@ -939,7 +948,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): gate=None, media_exchange_supported=False, reusable_media=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.
@@ -951,6 +960,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
questions are not filled out. questions are not filled out.
:param ignore_unpaid: When set to True, this will succeed even when the order is unpaid. :param ignore_unpaid: When set to True, this will succeed even when the order is unpaid.
:param questions_supported: When set to False, questions are ignored :param questions_supported: When set to False, questions are ignored
:param media_exchange_supported: When set to False, media exchanges are ignored and access with un-exchanged media
might be permitted
:param nonce: A random nonce to prevent race conditions. :param nonce: A random nonce to prevent race conditions.
: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.
@@ -1100,6 +1111,34 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'incomplete', 'incomplete',
require_answers require_answers
) )
media_exchange_supported = True
required_media_policy = op.item.media_policy
required_media_type = op.item.media_type
linked_media = op.linked_media
require_media_exchange = required_media_policy and required_media_type and not linked_media.exists()
if require_media_exchange and not force and media_exchange_supported:
raise RequiredMediaExchangeError(
_('You need to exchange your ticket to complete this check-in.'),
'exchange',
required_media_policy,
required_media_type
)
require_reusable_media_usage = required_media_policy and required_media_type and op.organizer.settings.reusable_media_usage_enforced
if require_reusable_media_usage and not force:
if not reusable_media and not linked_media.exists() and media_exchange_supported:
raise RequiredMediaExchangeError(
_('You need to exchange your ticket to complete this check-in.'),
'exchange',
required_media_policy,
required_media_type
)
elif not reusable_media and linked_media.exists():
raise CheckInError(
_('This ticket has already been exchanged - use the reusable media instead.'),
'already_exchanged',
)
device = None device = None
if isinstance(auth, Device): if isinstance(auth, Device):

View File

@@ -217,6 +217,19 @@ DEFAULTS = {
"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 re-usable media for check-in"),
help_text=_("If enabled, a ticket barcode will not be accepted anymore, if a re-usable media has been "
"created and linked to a ticket. Keeping this option turned off will treat the re-usable "
"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,

View File

@@ -192,6 +192,11 @@ class CheckinListSimulatorForm(forms.Form):
initial=True, initial=True,
required=False, required=False,
) )
media_exchange_supported = forms.BooleanField(
label=_("Support for media exchange"),
initial=True,
required=False,
)
gate = SafeModelChoiceField( gate = SafeModelChoiceField(
label=_('Gate'), label=_('Gate'),
empty_label=_('All gates'), empty_label=_('All gates'),

View File

@@ -627,6 +627,7 @@ 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',

View File

@@ -34,6 +34,7 @@
{% bootstrap_field form.gate layout="control" %} {% bootstrap_field form.gate layout="control" %}
{% bootstrap_field form.ignore_unpaid layout="control" %} {% bootstrap_field form.ignore_unpaid layout="control" %}
{% bootstrap_field form.questions_supported layout="control" %} {% bootstrap_field form.questions_supported layout="control" %}
{% bootstrap_field form.media_exchange_supported layout="control" %}
<div class="row"> <div class="row">
<div class="col-md-9 col-md-offset-3"> <div class="col-md-9 col-md-offset-3">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
@@ -53,6 +54,8 @@
<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>
@@ -78,6 +81,14 @@
</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> re-usable media.
<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 %}

View File

@@ -222,6 +222,7 @@
<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">

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, LogEntry, Order, OrderPosition from pretix.base.models import Checkin, LogEntry, Order, OrderPosition, Item
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
@@ -532,6 +532,8 @@ 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):
@@ -551,6 +553,7 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView):
pdf_data=False, pdf_data=False,
questions_supported=form.cleaned_data["questions_supported"], questions_supported=form.cleaned_data["questions_supported"],
canceled_supported=False, canceled_supported=False,
media_exchange_supported=form.cleaned_data["media_exchange_supported"],
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,
simulate=True, simulate=True,

View File

@@ -864,6 +864,9 @@ 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;
} }