mirror of
https://github.com/pretix/pretix.git
synced 2026-05-10 16:04:02 +00:00
Add Reusable Media Exchange to Checkin API
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user