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',
'name_scheme',
'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
@@ -885,6 +886,7 @@ 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',

View File

@@ -605,6 +605,7 @@ 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,7 +69,7 @@ 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, RequiredQuestionsError, SQLLogic, perform_checkin,
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, RequiredMediaExchangeError,
)
from pretix.base.signals import checkin_annulled
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,
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:
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
gate = gate or (auth.gate if isinstance(auth, Device) else None)
media = None
context = {
'request': request,
@@ -744,6 +746,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
datetime=datetime,
questions_supported=questions_supported,
canceled_supported=canceled_supported,
media_exchange_supported=media_exchange_supported,
user=user,
auth=auth,
type=checkin_type,
@@ -752,6 +755,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
from_revoked_secret=from_revoked_secret,
simulate=simulate,
gate=gate,
reusable_media=media,
)
except RequiredQuestionsError as e:
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,
}, 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:
if not simulate:
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',
questions_supported=self.request.data.get('questions_supported', True),
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
legacy_url_support=True,
)
@@ -949,6 +964,7 @@ class CheckinRPCRedeemView(views.APIView):
questions_supported=s.validated_data['questions_supported'],
use_order_locale=s.validated_data['use_order_locale'],
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
legacy_url_support=False,
)

View File

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

View File

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

View File

@@ -867,6 +867,15 @@ 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:
@@ -939,7 +948,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):
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
not valid at this time.
@@ -951,6 +960,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
questions are not filled out.
: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 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 datetime: The datetime of the checkin, defaults to now.
: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',
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
if isinstance(auth, Device):

View File

@@ -217,6 +217,19 @@ DEFAULTS = {
"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': {
'default': 'False',
'type': bool,

View File

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

View File

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

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

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

View File

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