Compare commits

..

8 Commits

Author SHA1 Message Date
Martin Gross
f052015881 Apply suggestions from code review
Co-authored-by: robbi5 <maxi@richt.name>
2026-04-27 16:28:36 +02:00
Martin Gross
bdbafe4cf1 Remove debugging leftover 2026-04-27 14:45:41 +02:00
Martin Gross
bf8dae2739 isort 2026-04-27 14:44:03 +02:00
Martin Gross
0dc95a22df Add Reusable Media Exchange to Checkin API 2026-04-27 14:37:51 +02:00
dependabot[bot]
82a14a4f83 Update pytest-asyncio requirement from >=0.24 to >=1.3.0 (#6108)
Updates the requirements on [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-asyncio/releases)
- [Commits](https://github.com/pytest-dev/pytest-asyncio/compare/v0.24.0...v1.3.0)

---
updated-dependencies:
- dependency-name: pytest-asyncio
  dependency-version: 1.3.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 12:39:36 +02:00
Kara Engelhardt
ff77a2125a Limit widget frame inner height to 100dvh (Z#23231969)
Fixes a bug where the submit buttons were obscured by the browsers elements on some ios devices
2026-04-27 12:38:32 +02:00
Raphael Michel
97904d8567 Backend: Support are-you-sure for dynamically added form parts (Z#23232506) (#6109) 2026-04-27 12:24:55 +02:00
Raphael Michel
a6a9eb6a6a Subevent selection: Order by date before name (Z#23231460) (#6111) 2026-04-27 12:23:17 +02:00
17 changed files with 108 additions and 9 deletions

View File

@@ -95,7 +95,7 @@ dependencies = [
"requests==2.32.*",
"sentry-sdk==2.58.*",
"sepaxml==2.7.*",
"stripe==15.1.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2026041800",
"tqdm==4.*",
@@ -117,7 +117,7 @@ dev = [
"isort==8.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=0.24",
"pytest-asyncio>=1.3.0",
"pytest-cache",
"pytest-cov",
"pytest-django==4.*",

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,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, RequiredQuestionsError, SQLLogic, perform_checkin,
CheckInError, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic,
perform_checkin,
)
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
@@ -454,7 +455,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 +465,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 +747,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 +756,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 +769,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 +928,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 +965,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.
@@ -1101,6 +1112,33 @@ 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
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 medium instead.'),
'already_exchanged',
)
device = None
if isinstance(auth, Device):
device = auth

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 medium 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 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,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, Item, LogEntry, Order, OrderPosition
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

@@ -619,7 +619,7 @@ def checkinlist_select2(request, **kwargs):
qs = request.event.checkin_lists.select_related('subevent').filter(
qf
).order_by('name')
).order_by('subevent__date_from', 'name', 'pk')
total = qs.count()
pagesize = 20

View File

@@ -71,6 +71,7 @@ $(document).ajaxError(function (event, jqXHR, settings, thrownError) {
});
var form_handlers = function (el) {
el.trigger("rescan.areYouSure");
el.find("[data-formset]").formset(
{
animateForms: 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;
}

View File

@@ -966,6 +966,7 @@ $table-bg-accent: rgba(128, 128, 128, 0.05);
width: 80vw;
max-width: 1080px;
height: 80vh;
max-height: 100dvh;
}
.pretix-widget-frame-inner iframe {
width: 100% !important;