diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py
index 6ab5c284ad..41a482066e 100644
--- a/src/pretix/api/views/checkin.py
+++ b/src/pretix/api/views/checkin.py
@@ -396,7 +396,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):
+ source_type='barcode', legacy_url_support=False, simulate=False):
if not checkinlists:
raise ValidationError('No check-in list passed.')
@@ -433,6 +433,8 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
)
raw_barcode_for_checkin = None
from_revoked_secret = False
+ if simulate:
+ common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
# 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or
# parent secret
@@ -472,13 +474,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
revoked_matches = list(
RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
if len(revoked_matches) == 0:
- checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
- 'datetime': datetime,
- 'type': checkin_type,
- 'list': checkinlists[0].pk,
- 'barcode': raw_barcode,
- 'searched_lists': [cl.pk for cl in checkinlists]
- }, user=user, auth=auth)
+ if not simulate:
+ checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
+ 'datetime': datetime,
+ 'type': checkin_type,
+ 'list': checkinlists[0].pk,
+ 'barcode': raw_barcode,
+ 'searched_lists': [cl.pk for cl in checkinlists]
+ }, user=user, auth=auth)
for cl in checkinlists:
for k, s in cl.event.ticket_secret_generators.items():
@@ -492,12 +495,13 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
except:
pass
- Checkin.objects.create(
- position=None,
- successful=False,
- error_reason=Checkin.REASON_INVALID,
- **common_checkin_args,
- )
+ if not simulate:
+ Checkin.objects.create(
+ position=None,
+ successful=False,
+ error_reason=Checkin.REASON_INVALID,
+ **common_checkin_args,
+ )
if force and legacy_url_support and isinstance(auth, Device):
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
@@ -539,19 +543,20 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
from_revoked_secret = True
else:
op = revoked_matches[0].position
- op.order.log_action('pretix.event.checkin.revoked', data={
- 'datetime': datetime,
- 'type': checkin_type,
- 'list': list_by_event[revoked_matches[0].event_id].pk,
- 'barcode': raw_barcode
- }, user=user, auth=auth)
- common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
- Checkin.objects.create(
- position=op,
- successful=False,
- error_reason=Checkin.REASON_REVOKED,
- **common_checkin_args
- )
+ if not simulate:
+ op.order.log_action('pretix.event.checkin.revoked', data={
+ 'datetime': datetime,
+ 'type': checkin_type,
+ 'list': list_by_event[revoked_matches[0].event_id].pk,
+ 'barcode': raw_barcode
+ }, user=user, auth=auth)
+ common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
+ Checkin.objects.create(
+ position=op,
+ successful=False,
+ error_reason=Checkin.REASON_REVOKED,
+ **common_checkin_args
+ )
return Response({
'status': 'error',
'reason': Checkin.REASON_REVOKED,
@@ -588,24 +593,25 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# We choose the first match (regardless of product) for the logging since it's most likely to be the
# base product according to our order_by above.
op = op_candidates[0]
- op.order.log_action('pretix.event.checkin.denied', data={
- 'position': op.id,
- 'positionid': op.positionid,
- 'errorcode': Checkin.REASON_AMBIGUOUS,
- 'reason_explanation': None,
- 'force': force,
- 'datetime': datetime,
- 'type': checkin_type,
- 'list': list_by_event[op.order.event_id].pk,
- }, user=user, auth=auth)
- common_checkin_args['list'] = list_by_event[op.order.event_id]
- Checkin.objects.create(
- position=op,
- successful=False,
- error_reason=Checkin.REASON_AMBIGUOUS,
- error_explanation=None,
- **common_checkin_args,
- )
+ if not simulate:
+ op.order.log_action('pretix.event.checkin.denied', data={
+ 'position': op.id,
+ 'positionid': op.positionid,
+ 'errorcode': Checkin.REASON_AMBIGUOUS,
+ 'reason_explanation': None,
+ 'force': force,
+ 'datetime': datetime,
+ 'type': checkin_type,
+ 'list': list_by_event[op.order.event_id].pk,
+ }, user=user, auth=auth)
+ common_checkin_args['list'] = list_by_event[op.order.event_id]
+ Checkin.objects.create(
+ position=op,
+ successful=False,
+ error_reason=Checkin.REASON_AMBIGUOUS,
+ error_explanation=None,
+ **common_checkin_args,
+ )
return Response({
'status': 'error',
'reason': Checkin.REASON_AMBIGUOUS,
@@ -652,6 +658,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
raw_barcode=raw_barcode_for_checkin,
raw_source_type=source_type,
from_revoked_secret=from_revoked_secret,
+ simulate=simulate,
)
except RequiredQuestionsError as e:
return Response({
@@ -664,23 +671,24 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
except CheckInError as e:
- op.order.log_action('pretix.event.checkin.denied', data={
- 'position': op.id,
- 'positionid': op.positionid,
- 'errorcode': e.code,
- 'reason_explanation': e.reason,
- 'force': force,
- 'datetime': datetime,
- 'type': checkin_type,
- 'list': list_by_event[op.order.event_id].pk,
- }, user=user, auth=auth)
- Checkin.objects.create(
- position=op,
- successful=False,
- error_reason=e.code,
- error_explanation=e.reason,
- **common_checkin_args,
- )
+ if not simulate:
+ op.order.log_action('pretix.event.checkin.denied', data={
+ 'position': op.id,
+ 'positionid': op.positionid,
+ 'errorcode': e.code,
+ 'reason_explanation': e.reason,
+ 'force': force,
+ 'datetime': datetime,
+ 'type': checkin_type,
+ 'list': list_by_event[op.order.event_id].pk,
+ }, user=user, auth=auth)
+ Checkin.objects.create(
+ position=op,
+ successful=False,
+ error_reason=e.code,
+ error_explanation=e.reason,
+ **common_checkin_args,
+ )
return Response({
'status': 'error',
'reason': e.code,
diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py
index 9168f45445..28686cdd73 100644
--- a/src/pretix/base/services/checkin.py
+++ b/src/pretix/base/services/checkin.py
@@ -53,7 +53,8 @@ from django.utils.translation import gettext as _
from django_scopes import scope, scopes_disabled
from pretix.base.models import (
- Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption,
+ Checkin, CheckinList, Device, Event, ItemVariation, Order, OrderPosition,
+ QuestionOption,
)
from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers import OF_SELF
@@ -65,12 +66,13 @@ from pretix.helpers.jsonlogic_query import (
)
-def _build_time(t=None, value=None, ev=None):
+def _build_time(t=None, value=None, ev=None, now_dt=None):
+ now_dt = now_dt or now()
if t == "custom":
return dateutil.parser.parse(value)
elif t == "customtime":
parsed = dateutil.parser.parse(value)
- return now().astimezone(ev.timezone).replace(
+ return now_dt.astimezone(ev.timezone).replace(
hour=parsed.hour,
minute=parsed.minute,
second=parsed.second,
@@ -84,7 +86,42 @@ def _build_time(t=None, value=None, ev=None):
return ev.date_admission or ev.date_from
-def _logic_explain(rules, ev, rule_data):
+def _logic_annotate_for_graphic_explain(rules, ev, rule_data):
+ logic_environment = _get_logic_environment(ev)
+ event = ev if isinstance(ev, Event) else ev.event
+
+ def _evaluate_inners(r):
+ if not isinstance(r, dict):
+ return r
+ operator = list(r.keys())[0]
+ values = r[operator]
+ if operator in ("and", "or"):
+ return {operator: [_evaluate_inners(v) for v in values]}
+ result = logic_environment.apply(r, rule_data)
+ return {**r, '__result': result}
+
+ def _add_var_values(r):
+ if not isinstance(r, dict):
+ return r
+ operator = [k for k in r.keys() if not k.startswith("__")][0]
+ values = r[operator]
+ if operator == "var":
+ var = values[0] if isinstance(values, list) else values
+ val = rule_data[var]
+ if var == "product":
+ val = str(event.items.get(pk=val))
+ elif var == "variation":
+ val = str(ItemVariation.objects.get(item__event=event, pk=val))
+ elif isinstance(val, datetime):
+ val = date_format(val.astimezone(ev.timezone), "SHORT_DATETIME_FORMAT")
+ return {"var": var, "__result": val}
+ else:
+ return {**r, operator: [_add_var_values(v) for v in values]}
+
+ return _add_var_values(_evaluate_inners(rules))
+
+
+def _logic_explain(rules, ev, rule_data, now_dt=None):
"""
Explains when the logic denied the check-in. Only works for a denied check-in.
@@ -114,6 +151,7 @@ def _logic_explain(rules, ev, rule_data):
Additionally, we favor a "close failure". Therefore, in the above example, we'd show "You can only
get in before 17:00". In the middle of the night it would switch to "You can only get in after 09:00".
"""
+ now_dt = now_dt or now()
logic_environment = _get_logic_environment(ev)
_var_values = {'False': False, 'True': True}
_var_explanations = {}
@@ -198,9 +236,9 @@ def _logic_explain(rules, ev, rule_data):
else:
compare_to -= tolerance
- var_weights[vname] = (200, abs(now() - compare_to).total_seconds())
+ var_weights[vname] = (200, abs(now_dt - compare_to).total_seconds())
- if abs(now() - compare_to) < timedelta(hours=12):
+ if abs(now_dt - compare_to) < timedelta(hours=12):
compare_to_text = date_format(compare_to, 'TIME_FORMAT')
else:
compare_to_text = date_format(compare_to, 'SHORT_DATETIME_FORMAT')
@@ -357,7 +395,7 @@ class LazyRuleVars:
@cached_property
def entries_today(self):
tz = self._clist.event.timezone
- midnight = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
+ midnight = self._dt.astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=midnight).count()
@cached_property
@@ -378,7 +416,7 @@ class LazyRuleVars:
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
- return (now() - last_entry.datetime).total_seconds() // 60
+ return (self._dt - last_entry.datetime).total_seconds() // 60
@cached_property
def minutes_since_first_entry(self):
@@ -390,7 +428,7 @@ class LazyRuleVars:
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
- return (now() - last_entry.datetime).total_seconds() // 60
+ return (self._dt - last_entry.datetime).total_seconds() // 60
class SQLLogic:
@@ -693,7 +731,7 @@ def _save_answers(op, answers, given_answers):
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
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):
+ raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False):
"""
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.
@@ -707,6 +745,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
:param questions_supported: When set to False, questions are ignored
: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.
"""
# !!!!!!!!!
@@ -734,7 +773,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'blocked'
)
- if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > now():
+ if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > dt:
if force:
force_used = True
else:
@@ -748,7 +787,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
),
)
- if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < now():
+ if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < dt:
if force:
force_used = True
else:
@@ -773,7 +812,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if q not in given_answers and q not in answers:
require_answers.append(q)
- _save_answers(op, answers, given_answers)
+ if not simulate:
+ _save_answers(op, answers, given_answers)
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
@@ -859,30 +899,33 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
return
if entry_allowed or force:
- ci = Checkin.objects.create(
- position=op,
- type=type,
- list=clist,
- datetime=dt,
- device=device,
- gate=device.gate if device else None,
- nonce=nonce,
- forced=force and (not entry_allowed or from_revoked_secret or force_used),
- force_sent=force,
- raw_barcode=raw_barcode,
- raw_source_type=raw_source_type,
- )
- op.order.log_action('pretix.event.checkin', data={
- 'position': op.id,
- 'positionid': op.positionid,
- 'first': True,
- 'forced': force or op.order.status != Order.STATUS_PAID,
- 'datetime': dt,
- 'type': type,
- 'answers': {k.pk: str(v) for k, v in given_answers.items()},
- 'list': clist.pk
- }, user=user, auth=auth)
- checkin_created.send(op.order.event, checkin=ci)
+ if simulate:
+ return True
+ else:
+ ci = Checkin.objects.create(
+ position=op,
+ type=type,
+ list=clist,
+ datetime=dt,
+ device=device,
+ gate=device.gate if device else None,
+ nonce=nonce,
+ forced=force and (not entry_allowed or from_revoked_secret or force_used),
+ force_sent=force,
+ raw_barcode=raw_barcode,
+ raw_source_type=raw_source_type,
+ )
+ op.order.log_action('pretix.event.checkin', data={
+ 'position': op.id,
+ 'positionid': op.positionid,
+ 'first': True,
+ 'forced': force or op.order.status != Order.STATUS_PAID,
+ 'datetime': dt,
+ 'type': type,
+ 'answers': {k.pk: str(v) for k, v in given_answers.items()},
+ 'list': clist.pk
+ }, user=user, auth=auth)
+ checkin_created.send(op.order.event, checkin=ci)
else:
raise CheckInError(
_('This ticket has already been redeemed.'),
diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py
index f20efa11e6..194fbd55e8 100644
--- a/src/pretix/control/forms/checkin.py
+++ b/src/pretix/control/forms/checkin.py
@@ -31,7 +31,8 @@ from django_scopes.forms import (
)
from pretix.base.channels import get_all_sales_channels
-from pretix.base.models.checkin import CheckinList
+from pretix.base.forms.widgets import SplitDateTimePickerWidget
+from pretix.base.models.checkin import Checkin, CheckinList
from pretix.control.forms import ItemMultipleChoiceField
from pretix.control.forms.widgets import Select2
@@ -177,3 +178,26 @@ class SimpleCheckinListForm(forms.ModelForm):
'subevent': SafeModelChoiceField,
'gates': SafeModelMultipleChoiceField,
}
+
+
+class CheckinListSimulatorForm(forms.Form):
+ raw_barcode = forms.CharField(
+ label=_("Barcode"),
+ )
+ datetime = forms.SplitDateTimeField(
+ label=_("Check-in time"),
+ widget=SplitDateTimePickerWidget(),
+ )
+ checkin_type = forms.ChoiceField(
+ label=_("Check-in type"),
+ choices=Checkin.CHECKIN_TYPES,
+ )
+ ignore_unpaid = forms.BooleanField(
+ label=_("Allow check-in of unpaid order (if check-in list permits it)"),
+ required=False,
+ )
+ questions_supported = forms.BooleanField(
+ label=_("Support for check-in questions"),
+ initial=True,
+ required=False,
+ )
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/index.html b/src/pretix/control/templates/pretixcontrol/checkin/index.html
index 2fc3c1fb7c..07ef3d0073 100644
--- a/src/pretix/control/templates/pretixcontrol/checkin/index.html
+++ b/src/pretix/control/templates/pretixcontrol/checkin/index.html
@@ -16,6 +16,11 @@
{% trans "Edit list configuration" %}
{% endif %}
+
+
+ {% trans "Check-in simulator" %}
+
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html
index 794f1835b8..3a4ccbeee6 100644
--- a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html
+++ b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html
@@ -12,7 +12,15 @@
{% endblock %}
{% block inside %}
{% if checkinlist %}
- {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
+
+ {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
+
+
+ {% trans "Check-in simulator" %}
+
+
{% else %}
{% trans "Check-in list" %}
{% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/lists.html b/src/pretix/control/templates/pretixcontrol/checkin/lists.html
index 0536842774..eb265ec326 100644
--- a/src/pretix/control/templates/pretixcontrol/checkin/lists.html
+++ b/src/pretix/control/templates/pretixcontrol/checkin/lists.html
@@ -133,6 +133,9 @@
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
+
{% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/simulator.html b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html
new file mode 100644
index 0000000000..e7fd624a0c
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html
@@ -0,0 +1,140 @@
+{% extends "pretixcontrol/items/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% load escapejson %}
+{% load getitem %}
+{% load static %}
+{% load compress %}
+{% block title %}{% trans "Check-in simulator" %}{% endblock %}
+{% block inside %}
+
+ {% blocktrans trimmed %} + This tool allows you to validate your check-in configuration. You can enter a barcode plus some + optional parameters and we will show you the response of the check-in list. No actual check-in will + be performed and no modification to the system state is made. + {% endblocktrans %} +
+ + {% if result %} ++ {% trans "The following questions must be answered before check-in can be completed:" %} +
+{{ result.reason_explanation }}
+ {% endif %} + {% endif %} + {% if result.position %} + {% if result.position.require_attention %} ++ {% trans "Special attention required" %} +
+ {% endif %} ++ + + {{ result.position.order }}-{{ result.position.positionid }} +
+ {% if result.position.attendee_name %} ++ + {{ result.position.attendee_name }} +
+ {% endif %} + {% endif %} + {% if result.rule_graph %} +