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 with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} + {% if 'can_change_event_settings' in request.eventpermset %} + + + {% trans "Edit list configuration" %} + + {% endif %} +

+

{% trans "Check-in simulator" %}

+

+ {% 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 %} +

+
+ {% csrf_token %} + {% bootstrap_form_errors form %} + {% bootstrap_field form.raw_barcode layout="control" %} + {% bootstrap_field form.datetime layout="control" %} + {% bootstrap_field form.checkin_type layout="control" %} + {% bootstrap_field form.ignore_unpaid layout="control" %} + {% bootstrap_field form.questions_supported layout="control" %} +
+
+ +
+
+
+ {% if result %} +
+
+
+
{% trans "Result" %}
+
+
+ {% if result.status == "ok" %} + + {% elif result.status == "incomplete" %} + + {% elif result.status == "error" %} + {% if result.reason == "already_redeemed" %} + + {% else %} + + {% endif %} + {% endif %} +
+
+ {% if result.status == "ok" %} +

{% trans "Valid check-in" %}

+ {% elif result.status == "incomplete" %} +

{% trans "Additional information required" %}

+

+ {% trans "The following questions must be answered before check-in can be completed:" %} +

+ + {% elif result.status == "error" %} +

{{ reason_labels|getitem:result.reason }}

+ {% if result.reason_explanation %} +

{{ 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 %} +
+
+ +
+ +
+ {% endif %} +
+
+ {% endif %} + + + {% if DEBUG %} + + {% else %} + + {% endif %} + {% compress js %} + + + + + + + + + + + {% endcompress %} + {% compress js %} + + + + + {% endcompress %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 22da38b4a4..869735223a 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -429,6 +429,7 @@ urlpatterns = [ re_path(r'^checkinlists/add$', checkin.CheckinListCreate.as_view(), name='event.orders.checkinlists.add'), re_path(r'^checkinlists/select2$', typeahead.checkinlist_select2, name='event.orders.checkinlists.select2'), re_path(r'^checkinlists/(?P\d+)/$', checkin.CheckInListShow.as_view(), name='event.orders.checkinlists.show'), + re_path(r'^checkinlists/(?P\d+)/simulator$', checkin.CheckInListSimulator.as_view(), name='event.orders.checkinlists.simulator'), re_path(r'^checkinlists/(?P\d+)/bulk_action$', checkin.CheckInListBulkActionView.as_view(), name='event.orders.checkinlists.bulk_action'), re_path(r'^checkinlists/(?P\d+)/change$', checkin.CheckinListUpdate.as_view(), name='event.orders.checkinlists.edit'), diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index 62274d30a4..f2c66e31c6 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -19,6 +19,19 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Jakob Schnell, jasonwaiting@live.hk, pajowu +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. +import secrets from datetime import timezone import dateutil.parser @@ -32,14 +45,21 @@ from django.urls import reverse from django.utils.functional import cached_property from django.utils.timezone import is_aware, make_aware, now from django.utils.translation import gettext_lazy as _ -from django.views.generic import ListView +from django.views.generic import FormView, ListView +from i18nfield.strings import LazyI18nString +from pretix.api.views.checkin import _redeem_process from pretix.base.channels import get_all_sales_channels from pretix.base.models import Checkin, Order, OrderPosition from pretix.base.models.checkin import CheckinList +from pretix.base.services.checkin import ( + LazyRuleVars, _logic_annotate_for_graphic_explain, +) from pretix.base.signals import checkin_created from pretix.base.views.tasks import AsyncPostView -from pretix.control.forms.checkin import CheckinListForm +from pretix.control.forms.checkin import ( + CheckinListForm, CheckinListSimulatorForm, +) from pretix.control.forms.filter import ( CheckinFilterForm, CheckinListAttendeeFilterForm, ) @@ -48,18 +68,6 @@ from pretix.control.views import CreateView, PaginationMixin, UpdateView from pretix.helpers.compat import CompatDeleteView from pretix.helpers.models import modelcopy -# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of -# the Apache License 2.0 can be obtained at . -# -# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A -# full history of changes and contributors is available at . -# -# This file contains Apache-licensed contributions copyrighted by: Jakob Schnell, jasonwaiting@live.hk, pajowu -# -# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations under the License. - class CheckInListQueryMixin: @@ -469,3 +477,63 @@ class CheckinListView(EventPermissionRequiredMixin, PaginationMixin, ListView): ctx = super().get_context_data() ctx['filter_form'] = self.filter_form return ctx + + +class CheckInListSimulator(EventPermissionRequiredMixin, FormView): + template_name = 'pretixcontrol/checkin/simulator.html' + permission = 'can_view_orders' + form_class = CheckinListSimulatorForm + + def dispatch(self, request, *args, **kwargs): + self.list = get_object_or_404(self.request.event.checkin_lists.all(), pk=kwargs.get("list")) + self.result = None + r = super().dispatch(request, *args, **kwargs) + r['Content-Security-Policy'] = 'script-src \'unsafe-eval\'' + return r + + def get_initial(self): + return { + 'datetime': now() + } + + def get_context_data(self, **kwargs): + return super().get_context_data( + **kwargs, + checkinlist=self.list, + result=self.result, + reason_labels=dict(Checkin.REASONS), + ) + + def form_valid(self, form): + self.result = _redeem_process( + checkinlists=[self.list], + raw_barcode=form.cleaned_data["raw_barcode"], + answers_data={}, + datetime=form.cleaned_data["datetime"], + force=False, + checkin_type=form.cleaned_data["checkin_type"], + ignore_unpaid=form.cleaned_data["ignore_unpaid"], + untrusted_input=True, + user=self.request.user, + auth=None, + expand=[], + nonce=secrets.token_hex(12), + pdf_data=False, + questions_supported=form.cleaned_data["questions_supported"], + canceled_supported=False, + request=self.request, # this is not clean, but we need it in the serializers for URL generation + legacy_url_support=False, + simulate=True, + ).data + + if form.cleaned_data["checkin_type"] == Checkin.TYPE_ENTRY and self.list.rules and self.result.get("position")\ + and (self.result["status"] in ("ok", "incomplete") or self.result["reason"] == "rules"): + op = OrderPosition.objects.get(pk=self.result["position"]["id"]) + rule_data = LazyRuleVars(op, self.list, form.cleaned_data["datetime"]) + rule_graph = _logic_annotate_for_graphic_explain(self.list.rules, op.subevent or self.list.event, rule_data) + self.result["rule_graph"] = rule_graph + + if self.result.get("questions"): + for q in self.result["questions"]: + q["question"] = LazyI18nString(q["question"]) + return self.get(self.request, self.args, self.kwargs) diff --git a/src/pretix/helpers/jsonlogic.py b/src/pretix/helpers/jsonlogic.py index 74e305f903..9e6626c113 100644 --- a/src/pretix/helpers/jsonlogic.py +++ b/src/pretix/helpers/jsonlogic.py @@ -213,7 +213,7 @@ class Logic(): data = data or {} - operator = list(tests.keys())[0] + operator = [k for k in tests.keys() if not k.startswith("__")][0] values = tests[operator] # Easy syntax for unary operators, like {"var": "x"} instead of strict diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js index eb382fea6d..15165cd205 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js @@ -93,13 +93,19 @@ $(function () { }, }; - Vue.component('checkin-rule', CheckinRule.default); - var app = new Vue({ - el: '#rules-editor', - components: { + var components = { + CheckinRulesVisualization: CheckinRulesVisualization.default, + } + if (typeof CheckinRule !== "undefined") { + Vue.component('checkin-rule', CheckinRule.default); + components = { CheckinRulesEditor: CheckinRulesEditor.default, CheckinRulesVisualization: CheckinRulesVisualization.default, - }, + } + } + var app = new Vue({ + el: '#rules-editor', + components: components, data: function () { return { rules: {}, @@ -187,17 +193,23 @@ $(function () { }, created: function () { this.rules = JSON.parse($("#id_rules").val()); - this.items = JSON.parse($("#items").html()); + if ($("#items").length) { + this.items = JSON.parse($("#items").html()); - var root = this.$root - function _update() { - root.all_products = $("#id_all_products").prop("checked") - root.limit_products = $("input[name=limit_products]:checked").map(function () { return parseInt($(this).val()) }).toArray() + var root = this.$root + + function _update() { + root.all_products = $("#id_all_products").prop("checked") + root.limit_products = $("input[name=limit_products]:checked").map(function () { + return parseInt($(this).val()) + }).toArray() + } + + $("#id_all_products, input[name=limit_products]").on("change", function () { + _update(); + }) + _update() } - $("#id_all_products, input[name=limit_products]").on("change", function () { - _update(); - }) - _update() }, watch: { rules: { diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue index 54e8886e5b..b7fedc720a 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue @@ -106,6 +106,9 @@ export default { let [new_children, new_tails] = _add_to_graph(operand) for (let new_child of new_tails) { graph.nodes_by_id[new_child].children.push(...children) + for (let c of children) { + graph.nodes_by_id[c].parent = graph.nodes_by_id[new_child] + } } if (tails === null) { tails = new_tails diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue index 81c398daab..3ef8229302 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue @@ -3,20 +3,27 @@ - + +
{{ vardata.label }}
+ + {{varresult}} + - {{ op.label }} {{ rightoperand }} + {{ op.label }} {{ rightoperand }}
{{ vardata.label }}
+ + {{varresult}} + {{ op.label }}
@@ -37,7 +44,11 @@
- {{ vardata.label }}
+ {{ vardata.label }} + + ({{varresult}}) + +
{{ rightoperand.objectList.map((o) => o.lookup[2]).join(", ") }} @@ -45,9 +56,23 @@
- - + + + + + + + + + + + + + @@ -121,6 +146,14 @@ vardata () { return this.$root.VARS[this.variable]; }, + varresult () { + const op = this.operator; + if (this.node.rule[op] && this.node.rule[op][0]) { + return this.node.rule[op][0]["__result"]; + } else { + return ""; + } + }, rightoperand () { const op = this.operator; if (this.node.rule[op] && typeof this.node.rule[op][1] !== "undefined") { @@ -136,8 +169,30 @@ return this.node.rule[this.operator] }, operator: function () { - return Object.keys(this.node.rule)[0]; + return Object.keys(this.node.rule).filter(function (k) { return !k.startsWith("__") })[0]; }, + result: function () { + return typeof this.node.rule.__result == "undefined" ? null : !!this.node.rule.__result + }, + resultInclParents: function () { + if (typeof this.node.rule.__result == "undefined") + return null + + function _p(node) { + if (node.parent) { + return node.rule.__result && _p(node.parent) + } + return node.rule.__result + } + return _p(this.node) + }, + nodeClass: function () { + return { + "node": true, + "node-true": this.result === true, + "node-false": this.result === false, + } + } }, methods: { df (val) { diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index 9f84ef4708..14f850ae3d 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -716,6 +716,12 @@ table td > .checkbox input[type="checkbox"] { stroke: $brand-primary; } } + .node-false { + stroke: $brand-danger; + } + .node-true { + stroke: $brand-success; + } .edge { stroke: $gray-light; stroke-width: 2px; @@ -724,6 +730,9 @@ table td > .checkbox input[type="checkbox"] { .check { fill: $brand-success; } + .error { + fill: $brand-danger; + } .text { font-size: 12px; width: 100%; @@ -739,6 +748,10 @@ table td > .checkbox input[type="checkbox"] { position: absolute; top: 10px; right: 10px; + opacity: 0; + } + &:hover .tools { + opacity: 1; } &.maximized { diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 7ad35f892c..5d82411710 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -830,6 +830,30 @@ tbody th { } } +.nomargin-top { + margin-top: 0; +} + +.checkin-sim-result { + text-align: center; + padding: 20px; + .fa { + color: white; + font-size: 50px; + } +} +.checkin-sim-result-status-ok { + background: $brand-success; +} +.checkin-sim-result-status-incomplete { + background: $brand-primary; +} +.checkin-sim-result-status-error { + background: $brand-danger; +} +.checkin-sim-result-status-error.checkin-sim-result-reason-already_redeemed { + background: $brand-warning; +} .withoutjs { display: none !important;