diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 34e49aa272..b3051226da 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -30,6 +30,9 @@ position_count integer Number of ticke checkin_count integer Number of check-ins performed on this list (read-only). include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state. auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels. +allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in. +allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan. +rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged. ===================================== ========================== ======================================================= .. versionchanged:: 1.10 @@ -48,6 +51,11 @@ auto_checkin_sales_channels list of strings All items on th The ``auto_checkin_sales_channels`` field has been added. +.. versionchanged:: 3.9 + + The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``, + ``allow_entry_after_exit``, and ``rules`` attributes have been added. + Endpoints --------- @@ -89,6 +97,9 @@ Endpoints "limit_products": [], "include_pending": false, "subevent": null, + "allow_multiple_entries": false, + "allow_entry_after_exit": true, + "rules": {}, "auto_checkin_sales_channels": [ "pretixpos" ] @@ -133,6 +144,9 @@ Endpoints "limit_products": [], "include_pending": false, "subevent": null, + "allow_multiple_entries": false, + "allow_entry_after_exit": true, + "rules": {}, "auto_checkin_sales_channels": [ "pretixpos" ] @@ -229,6 +243,8 @@ Endpoints "all_products": false, "limit_products": [1, 2], "subevent": null, + "allow_multiple_entries": false, + "allow_entry_after_exit": true, "auto_checkin_sales_channels": [ "pretixpos" ] @@ -251,6 +267,8 @@ Endpoints "limit_products": [1, 2], "include_pending": false, "subevent": null, + "allow_multiple_entries": false, + "allow_entry_after_exit": true, "auto_checkin_sales_channels": [ "pretixpos" ] @@ -303,6 +321,8 @@ Endpoints "limit_products": [1, 2], "include_pending": false, "subevent": null, + "allow_multiple_entries": false, + "allow_entry_after_exit": true, "auto_checkin_sales_channels": [ "pretixpos" ] @@ -696,6 +716,7 @@ Order position endpoints ``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``. * ``already_redeemed`` - Ticket already has been redeemed * ``product`` - Tickets with this product may not be scanned at this device + * ``rules`` - Check-in prevented by a user-defined rule :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 10d0f44a47..20a735b195 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -195,6 +195,7 @@ pseudonymization_id string A random ID, e. checkins list of objects List of check-ins with this ticket ├ list integer Internal ID of the check-in list ├ datetime datetime Time of check-in +├ type string Type of scan (defaults to ``entry``) └ auto_checked_in boolean Indicates if this check-in been performed automatically by the system downloads list of objects List of ticket download options ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) @@ -251,6 +252,10 @@ pdf_data object Data object req The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added. +.. versionchanged:: 3.9 + + The ``checkin.type`` attribute has been added. + .. _order-payment-resource: Order payment resource @@ -413,6 +418,7 @@ List of all orders "checkins": [ { "list": 44, + "type": "entry", "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } @@ -575,6 +581,7 @@ Fetching individual orders "checkins": [ { "list": 44, + "type": "entry", "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } @@ -1471,6 +1478,7 @@ List of all order positions "checkins": [ { "list": 44, + "type": "entry", "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } @@ -1576,6 +1584,7 @@ Fetching individual positions "checkins": [ { "list": 44, + "type": "entry", "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } diff --git a/doc/user/faq.rst b/doc/user/faq.rst index 3fc1f9b21c..75c6f8ee61 100644 --- a/doc/user/faq.rst +++ b/doc/user/faq.rst @@ -58,28 +58,6 @@ method without creating a new order. If payment deadlines were dependent on the forth could either allow someone to extend their deadline forever, or render someones order invalid by moving the date back in the past. -How can I revert a check-in? ----------------------------- - -Neither our apps nor our web interface can currently undo the check-in of a tickets. We know that this is -inconvenient for some of you, but we have a good reason for it: - -Our Desktop and Android apps both support an asynchronous mode in which they can scan tickets while staying -independent of their internet connection. When scanning with multiple devices, it can of course happen that two -devices scan the same ticket without knowing of the other scan. As soon as one of the devices regains connectivity, it -will upload its activity and the server marks the ticket as checked in -- regardless of the order in which the two -scans were made and uploaded (which could be two different orders). - -If we'd provide a "check out" feature, it would not only be used to fix an accidental scan, but scan at entry and -exit to count the current number of people inside etc. In this case, the order of operations matters very much for them -to make sense and provide useful results. This makes implementing an asynchronous mode much more complicated. - -In this trade off, we chose offline-capabilities over the check out feature. We plan on solving this problem in the -future, but we're not there yet. - -If you're just *testing* the check-in capabilities and want to clean out everything for the real process, you can just -delete and re-create the check-in list. - Why does pretix not support any 1D (linear) bar codes? ------------------------------------------------------ diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index b6aac58d62..5ac2c44958 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -14,7 +14,8 @@ class CheckinListSerializer(I18nAwareModelSerializer): class Meta: model = CheckinList fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count', - 'include_pending', 'auto_checkin_sales_channels') + 'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit', + 'rules') def validate(self, data): data = super().validate(data) @@ -28,9 +29,7 @@ class CheckinListSerializer(I18nAwareModelSerializer): raise ValidationError(_('One or more items do not belong to this event.')) if event.has_subevents: - if not full_data.get('subevent'): - raise ValidationError(_('Subevent cannot be null for event series.')) - if event != full_data.get('subevent').event: + if full_data.get('subevent') and event != full_data.get('subevent').event: raise ValidationError(_('The subevent does not belong to this event.')) else: if full_data.get('subevent'): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index d799f9eddd..328534d9b2 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -122,7 +122,7 @@ class AnswerSerializer(I18nAwareModelSerializer): class CheckinSerializer(I18nAwareModelSerializer): class Meta: model = Checkin - fields = ('datetime', 'list', 'auto_checked_in') + fields = ('datetime', 'list', 'auto_checked_in', 'type') class OrderDownloadsField(serializers.Field): diff --git a/src/pretix/api/views/__init__.py b/src/pretix/api/views/__init__.py index 45f5f7e44a..4cf6126ea9 100644 --- a/src/pretix/api/views/__init__.py +++ b/src/pretix/api/views/__init__.py @@ -41,8 +41,8 @@ class ConditionalListView: return super().list(request, **kwargs) lmd = request.event.logentry_set.filter( - content_type__model=self.queryset.model._meta.model_name, - content_type__app_label=self.queryset.model._meta.app_label, + content_type__model=self.get_queryset().model._meta.model_name, + content_type__app_label=self.get_queryset().model._meta.app_label, ).aggregate( m=Max('datetime') )['m'] diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index e6adcbde92..845a2abb25 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -88,8 +88,9 @@ class CheckinListViewSet(viewsets.ModelViewSet): pqs = OrderPosition.objects.filter( order__event=clist.event, order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []), - subevent=clist.subevent, ) + if clist.subevent: + pqs = pqs.filter(subevent=clist.subevent) if not clist.all_products: pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True)) cqs = cqs.filter(position__item__in=clist.limit_products.values_list('id', flat=True)) @@ -201,10 +202,13 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): qs = OrderPosition.objects.filter( order__event=self.request.event, - subevent=self.checkinlist.subevent ).annotate( last_checked_in=Subquery(cqs) ) + if self.checkinlist.subevent: + qs = qs.filter( + subevent=self.checkinlist.subevent + ) if self.request.query_params.get('ignore_status', 'false') != 'true' and not ignore_status: qs = qs.filter( @@ -251,6 +255,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): @action(detail=True, methods=['POST']) def redeem(self, *args, **kwargs): force = bool(self.request.data.get('force', False)) + type = self.request.data.get('type', None) or Checkin.TYPE_ENTRY + if type not in dict(Checkin.CHECKIN_TYPES): + raise ValidationError("Invalid check-in type.") ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False)) nonce = self.request.data.get('nonce') op = self.get_object(ignore_status=True) @@ -283,6 +290,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): canceled_supported=self.request.data.get('canceled_supported', False), user=self.request.user, auth=self.request.auth, + type=type, ) except RequiredQuestionsError as e: return Response({ diff --git a/src/pretix/api/webhooks.py b/src/pretix/api/webhooks.py index 0af6a76cae..bb7d622de6 100644 --- a/src/pretix/api/webhooks.py +++ b/src/pretix/api/webhooks.py @@ -170,7 +170,7 @@ def register_default_webhook_events(sender, **kwargs): @app.task(base=TransactionAwareTask) def notify_webhooks(logentry_id: int): - logentry = LogEntry.all.get(id=logentry_id) + logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id) if not logentry.organizer: return # We need to know the organizer diff --git a/src/pretix/base/migrations/0152_auto_20200511_1504.py b/src/pretix/base/migrations/0152_auto_20200511_1504.py new file mode 100644 index 0000000000..ee514a2917 --- /dev/null +++ b/src/pretix/base/migrations/0152_auto_20200511_1504.py @@ -0,0 +1,52 @@ +# Generated by Django 3.0.5 on 2020-05-11 15:04 + +import django.db.models.deletion +import django_countries.fields +import jsonfallback.fields +from django.db import migrations, models + +import pretix.helpers.countries + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0151_auto_20200421_0737'), + ] + + operations = [ + migrations.AddField( + model_name='checkin', + name='device', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='checkins', to='pretixbase.Device'), + ), + migrations.AddField( + model_name='checkin', + name='forced', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='checkin', + name='type', + field=models.CharField(default='entry', max_length=100), + ), + migrations.AddField( + model_name='checkinlist', + name='allow_entry_after_exit', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='checkinlist', + name='allow_multiple_entries', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='checkinlist', + name='rules', + field=jsonfallback.fields.FallbackJSONField(default=dict), + ), + migrations.AlterUniqueTogether( + name='checkin', + unique_together=set(), + ), + ] diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 35eff8a9a4..8df9f780c7 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -1,8 +1,9 @@ from django.db import models -from django.db.models import Exists, OuterRef +from django.db.models import Exists, F, Max, OuterRef, Q, Subquery from django.utils.timezone import now from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes import ScopedManager +from jsonfallback.fields import FallbackJSONField from pretix.base.models import LoggedModel from pretix.base.models.fields import MultiStringField @@ -19,6 +20,15 @@ class CheckinList(LoggedModel): default=False, help_text=_('With this option, people will be able to check in even if the ' 'order have not been paid.')) + allow_entry_after_exit = models.BooleanField( + verbose_name=_('Allow re-entering after an exit scan'), + default=True + ) + allow_multiple_entries = models.BooleanField( + verbose_name=_('Allow multiple entries per ticket'), + help_text=_('Use this option to turn off warnings if a ticket is scanned a second time.'), + default=False + ) auto_checkin_sales_channels = MultiStringField( default=[], @@ -28,6 +38,7 @@ class CheckinList(LoggedModel): 'any of the selected sales channels. This option can be useful when tickets sold at the box office ' 'are not checked again before entry and should be considered validated directly upon purchase.') ) + rules = FallbackJSONField(default=dict, blank=True) objects = ScopedManager(organizer='event__organizer') @@ -40,13 +51,43 @@ class CheckinList(LoggedModel): qs = OrderPosition.objects.filter( order__event=self.event, - order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID], - subevent=self.subevent + order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [ + Order.STATUS_PAID], ) + if self.subevent_id: + qs = qs.filter(subevent_id=self.subevent_id) if not self.all_products: qs = qs.filter(item__in=self.limit_products.values_list('id', flat=True)) return qs + @property + def inside_count(self): + return self.positions.annotate( + last_entry=Subquery( + Checkin.objects.filter( + position_id=OuterRef('pk'), + list_id=self.pk, + type=Checkin.TYPE_ENTRY, + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') + ), + last_exit=Subquery( + Checkin.objects.filter( + position_id=OuterRef('pk'), + list_id=self.pk, + type=Checkin.TYPE_EXIT, + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') + ), + ).filter( + Q(last_entry__isnull=False) + & Q( + Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry')) + ) + ).count() + @property def checkin_count(self): return self.event.cache.get_or_set( @@ -88,20 +129,31 @@ class CheckinList(LoggedModel): class Checkin(models.Model): """ - A check-in object is created when a person enters the event. + A check-in object is created when a person enters or exits the event. """ + TYPE_ENTRY = 'entry' + TYPE_EXIT = 'exit' + CHECKIN_TYPES = ( + (TYPE_ENTRY, _('Entry')), + (TYPE_EXIT, _('Exit')), + ) position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins', on_delete=models.CASCADE) datetime = models.DateTimeField(default=now) nonce = models.CharField(max_length=190, null=True, blank=True) list = models.ForeignKey( 'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT, ) + type = models.CharField(max_length=100, choices=CHECKIN_TYPES, default=TYPE_ENTRY) + forced = models.BooleanField(default=False) + device = models.ForeignKey( + 'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True + ) auto_checked_in = models.BooleanField(default=False) objects = ScopedManager(organizer='position__order__event__organizer') class Meta: - unique_together = (('list', 'position'),) + ordering = (('-datetime'),) def __repr__(self): return "".format( @@ -109,12 +161,12 @@ class Checkin(models.Model): ) def save(self, **kwargs): + super().save(**kwargs) self.position.order.touch() self.list.event.cache.delete('checkin_count') self.list.touch() - super().save(**kwargs) def delete(self, **kwargs): - self.position.order.touch() super().delete(**kwargs) + self.position.order.touch() self.list.touch() diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index d69750315b..00095b7e1b 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -627,12 +627,29 @@ class Event(EventMixin, LoggedModel): q.dependency_question = question_map[q.dependency_question_id] q.save(update_fields=['dependency_question']) + def _walk_rules(rules): + if isinstance(rules, dict): + for k, v in rules.items(): + if k == 'lookup': + if v[0] == 'product': + v[1] = str(item_map.get(int(v[1]), 0).pk) + elif v[0] == 'variation': + v[1] = str(variation_map.get(int(v[1]), 0).pk) + else: + _walk_rules(v) + elif isinstance(rules, list): + for i in rules: + _walk_rules(i) + checkin_list_map = {} for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'): items = list(cl.limit_products.all()) checkin_list_map[cl.pk] = cl cl.pk = None cl.event = self + rules = cl.rules + _walk_rules(rules) + cl.rules = rules cl.save() cl.log_action('pretix.object.cloned') for i in items: diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 38580e0139..f90223a5a8 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -1,13 +1,87 @@ +from datetime import timedelta + +import dateutil from django.db import transaction -from django.db.models import Prefetch +from django.db.models.functions import TruncDate from django.dispatch import receiver -from django.utils.timezone import now +from django.utils.functional import cached_property +from django.utils.timezone import now, override from django.utils.translation import gettext as _ from pretix.base.models import ( - Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption, + Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption, ) from pretix.base.signals import checkin_created, order_placed +from pretix.helpers.jsonlogic import Logic + + +def get_logic_environment(ev): + def build_time(t=None, value=None): + if t == "custom": + return dateutil.parser.parse(value) + elif t == 'date_from': + return ev.date_from + elif t == 'date_to': + return ev.date_to + elif t == 'date_admission': + return ev.date_admission or ev.date_from + + def is_before(t1, t2, tolerance=None): + if tolerance: + return t1 < t2 + timedelta(minutes=float(tolerance)) + else: + return t1 < t2 + + logic = Logic() + logic.add_operation('objectList', lambda *objs: list(objs)) + logic.add_operation('lookup', lambda model, pk, str: int(pk)) + logic.add_operation('inList', lambda a, b: a in b) + logic.add_operation('buildTime', build_time) + logic.add_operation('isBefore', is_before) + logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol)) + return logic + + +class LazyRuleVars: + def __init__(self, position, clist, dt): + self._position = position + self._clist = clist + self._dt = dt + + def __getitem__(self, item): + if item[0] != '_' and hasattr(self, item): + return getattr(self, item) + raise KeyError() + + @property + def now(self): + return self._dt + + @property + def product(self): + return self._position.item_id + + @property + def variation(self): + return self._position.variation_id + + @cached_property + def entries_number(self): + return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist).count() + + @cached_property + def entries_today(self): + tz = self._clist.event.timezone + midnight = now().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 + def entries_days(self): + tz = self._clist.event.timezone + with override(tz): + return self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).annotate( + day=TruncDate('datetime') + ).values('day').distinct().count() class CheckInError(Exception): @@ -62,7 +136,7 @@ def _save_answers(op, answers, given_answers): @transaction.atomic 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): + user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY): """ 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. @@ -79,18 +153,11 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, """ dt = datetime or now() - # Fetch order position with related objects - op = OrderPosition.all.select_related( - 'item', 'variation', 'order', 'addon_to' - ).prefetch_related( - 'item__questions', - Prefetch( - 'item__questions', - queryset=Question.objects.filter(ask_during_checkin=True), - to_attr='checkin_questions' - ), - 'answers' - ).get(pk=op.pk) + # Lock order positions + op = OrderPosition.all.select_for_update().get(pk=op.pk) + checkin_questions = list( + clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id]) + ) if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING): raise CheckInError( @@ -98,19 +165,25 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, 'canceled' if canceled_supported else 'unpaid' ) - answers = {a.question: a for a in op.answers.all()} require_answers = [] - for q in op.item.checkin_questions: - if q not in given_answers and q not in answers: - require_answers.append(q) + if checkin_questions: + answers = {a.question: a for a in op.answers.all()} + for q in checkin_questions: + if q not in given_answers and q not in answers: + require_answers.append(q) - _save_answers(op, answers, given_answers) + _save_answers(op, answers, given_answers) if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]: raise CheckInError( _('This order position has an invalid product for this check-in list.'), 'product' ) + elif clist.subevent_id and op.subevent_id != clist.subevent_id: + raise CheckInError( + _('This order position has an invalid date for this check-in list.'), + 'product' + ) elif op.order.status != Order.STATUS_PAID and not force and not ( ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING ): @@ -124,40 +197,56 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, 'incomplete', require_answers ) - else: - try: - ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={ - 'datetime': dt, - 'nonce': nonce, - }) - except Checkin.MultipleObjectsReturned: - ci, created = Checkin.objects.filter(position=op, list=clist).last(), False - if created or (nonce and nonce == ci.nonce): - if created: - op.order.log_action('pretix.event.checkin', data={ - 'position': op.id, - 'positionid': op.positionid, - 'first': True, - 'forced': op.order.status != Order.STATUS_PAID, - 'datetime': dt, - 'list': clist.pk - }, user=user, auth=auth) - checkin_created.send(op.order.event, checkin=ci) - else: - if not force: + if type == Checkin.TYPE_ENTRY and clist.rules and not force: + rule_data = LazyRuleVars(op, clist, dt) + logic = get_logic_environment(op.subevent or clist.event) + if not logic.apply(clist.rules, rule_data): raise CheckInError( - _('This ticket has already been redeemed.'), - 'already_redeemed', + _('This entry is not permitted due to custom rules.'), + 'rules' ) + + device = None + if isinstance(auth, Device): + device = auth + + last_ci = op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce').first() + entry_allowed = ( + type == Checkin.TYPE_EXIT or + clist.allow_multiple_entries or + last_ci is None or + (clist.allow_entry_after_exit and last_ci.type == Checkin.TYPE_EXIT) + ) + + if nonce and ((last_ci and last_ci.nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()): + return + + if entry_allowed or force: + ci = Checkin.objects.create( + position=op, + type=type, + list=clist, + datetime=dt, + device=device, + nonce=nonce, + forced=force and not entry_allowed, + ) op.order.log_action('pretix.event.checkin', data={ 'position': op.id, 'positionid': op.positionid, - 'first': False, - 'forced': force, + 'first': True, + 'forced': force or op.order.status != Order.STATUS_PAID, 'datetime': dt, + 'type': type, 'list': clist.pk }, user=user, auth=auth) + checkin_created.send(op.order.event, checkin=ci) + else: + raise CheckInError( + _('This ticket has already been redeemed.'), + 'already_redeemed', + ) @receiver(order_placed, dispatch_uid="autocheckin_order_placed") @@ -172,5 +261,6 @@ def order_placed(sender, **kwargs): for op in order.positions.all(): for cl in cls: if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}: - ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True) - checkin_created.send(event, checkin=ci) + if not cl.subevent_id or cl.subevent_id == op.subevent_id: + ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True) + checkin_created.send(event, checkin=ci) diff --git a/src/pretix/base/services/notifications.py b/src/pretix/base/services/notifications.py index ac58b6752f..9ce6973088 100644 --- a/src/pretix/base/services/notifications.py +++ b/src/pretix/base/services/notifications.py @@ -16,7 +16,7 @@ from pretix.helpers.urls import build_absolute_uri @app.task(base=TransactionAwareTask) @scopes_disabled() def notify(logentry_id: int): - logentry = LogEntry.all.get(id=logentry_id) + logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id) if not logentry.event: return # Ignore, we only have event-related notifications right now types = get_all_notification_types(logentry.event) diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py index 7699c49c23..642f7cf659 100644 --- a/src/pretix/control/forms/checkin.py +++ b/src/pretix/control/forms/checkin.py @@ -35,11 +35,10 @@ class CheckinListForm(forms.ModelForm): 'event': self.event.slug, 'organizer': self.event.organizer.slug, }), - 'data-placeholder': pgettext_lazy('subevent', 'Date') + 'data-placeholder': pgettext_lazy('subevent', 'All dates') } ) self.fields['subevent'].widget.choices = self.fields['subevent'].choices - self.fields['subevent'].required = True else: del self.fields['subevent'] @@ -52,13 +51,43 @@ class CheckinListForm(forms.ModelForm): 'limit_products', 'subevent', 'include_pending', - 'auto_checkin_sales_channels' + 'auto_checkin_sales_channels', + 'allow_multiple_entries', + 'allow_entry_after_exit', + 'rules', + ] + widgets = { + 'limit_products': forms.CheckboxSelectMultiple(attrs={ + 'data-inverse-dependency': '<[name$=all_products]' + }), + 'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(), + } + field_classes = { + 'limit_products': SafeModelMultipleChoiceField, + 'subevent': SafeModelChoiceField, + } + + +class SimpleCheckinListForm(forms.ModelForm): + def __init__(self, **kwargs): + self.event = kwargs.pop('event') + kwargs.pop('locales', None) + super().__init__(**kwargs) + self.fields['limit_products'].queryset = self.event.items.all() + + class Meta: + model = CheckinList + localized_fields = '__all__' + fields = [ + 'name', + 'all_products', + 'limit_products', + 'include_pending', ] widgets = { 'limit_products': forms.CheckboxSelectMultiple(attrs={ 'data-inverse-dependency': '<[name$=all_products]' }), - 'auto_checkin_sales_channels': forms.CheckboxSelectMultiple() } field_classes = { 'limit_products': SafeModelMultipleChoiceField, diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 75c46a3a83..51a85fd7c6 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -757,10 +757,10 @@ class CheckInFilterForm(FilterForm): '-code': ('-order__code', '-item__name'), 'email': ('order__email', 'item__name'), '-email': ('-order__email', '-item__name'), - 'status': (FixedOrderBy(F('last_checked_in'), nulls_first=True, descending=True), 'order__code'), - '-status': (FixedOrderBy(F('last_checked_in'), nulls_last=True), '-order__code'), - 'timestamp': (FixedOrderBy(F('last_checked_in'), nulls_first=True), 'order__code'), - '-timestamp': (FixedOrderBy(F('last_checked_in'), nulls_last=True, descending=True), '-order__code'), + 'status': (FixedOrderBy(F('last_entry'), nulls_first=True, descending=True), 'order__code'), + '-status': (FixedOrderBy(F('last_entry'), nulls_last=True), '-order__code'), + 'timestamp': (FixedOrderBy(F('last_entry'), nulls_first=True), 'order__code'), + '-timestamp': (FixedOrderBy(F('last_entry'), nulls_last=True, descending=True), '-order__code'), 'item': ('item__name', 'variation__value', 'order__code'), '-item': ('-item__name', '-variation__value', '-order__code'), 'seat': ('seat__sorting_rank', 'seat__guid'), @@ -783,6 +783,7 @@ class CheckInFilterForm(FilterForm): label=_('Check-in status'), choices=( ('', _('All attendees')), + ('2', pgettext_lazy('checkin state', 'Present')), ('1', _('Checked in')), ('0', _('Not checked in')), ), @@ -823,9 +824,13 @@ class CheckInFilterForm(FilterForm): if fdata.get('status'): s = fdata.get('status') if s == '1': - qs = qs.filter(last_checked_in__isnull=False) + qs = qs.filter(last_entry__isnull=False) + elif s == '2': + qs = qs.filter(last_entry__isnull=False).filter( + Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry')) + ) elif s == '0': - qs = qs.filter(last_checked_in__isnull=True) + qs = qs.filter(last_entry__isnull=True) if fdata.get('ordering'): ob = self.orders[fdata.get('ordering')] diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 72d9a6bb4c..6005a8837c 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -14,7 +14,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy from i18nfield.strings import LazyI18nString from pretix.base.models import ( - CheckinList, Event, ItemVariation, LogEntry, OrderPosition, + Checkin, CheckinList, Event, ItemVariation, LogEntry, OrderPosition, ) from pretix.base.signals import logentry_display from pretix.base.templatetags.money import money_filter @@ -134,7 +134,7 @@ def _display_checkin(event, logentry): show_dt = False if 'datetime' in data: dt = dateutil.parser.parse(data.get('datetime')) - show_dt = abs((logentry.datetime - dt).total_seconds()) > 60 or 'forced' in data + show_dt = abs((logentry.datetime - dt).total_seconds()) > 5 or 'forced' in data tz = pytz.timezone(event.settings.timezone) dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") @@ -146,6 +146,18 @@ def _display_checkin(event, logentry): else: checkin_list = _("(unknown)") + if data.get('type') == Checkin.TYPE_EXIT: + if show_dt: + return _('Position #{posid} has been checked out at {datetime} for list "{list}".').format( + posid=data.get('positionid'), + datetime=dt_formatted, + list=checkin_list + ) + else: + return _('Position #{posid} has been checked out for list "{list}".').format( + posid=data.get('positionid'), + list=checkin_list + ) if data.get('first'): if show_dt: return _('Position #{posid} has been checked in at {datetime} for list "{list}".').format( diff --git a/src/pretix/control/templates/pretixcontrol/checkin/index.html b/src/pretix/control/templates/pretixcontrol/checkin/index.html index 7a23ea2607..0d13b1db29 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/index.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/index.html @@ -12,8 +12,8 @@ {% if 'can_change_event_settings' in request.eventpermset %} - - {% trans "Edit list" %} + + {% trans "Edit list configuration" %} {% endif %} - {% if not e.last_checked_in %} + {% if not e.last_entry %} {% trans "Not checked in" %} {% else %} - {% trans "Checked in" %} - {% if e.auto_checked_in %} - + {% if e.last_exit and e.last_exit_aware > e.last_entry_aware %} + {% trans "Checked in but left" %} + {% else %} + {% trans "Checked in" %} + {% if e.auto_checked_in %} + + {% endif %} {% endif %} {% endif %} - {% if e.last_checked_in %} - {{ e.last_checked_in_aware|date:"SHORT_DATETIME_FORMAT" }} + {% if e.last_entry %} + {{ e.last_entry_aware|date:"SHORT_DATETIME_FORMAT" }} + {% endif %} + {% if e.last_exit %} +
+ {% blocktrans trimmed with date=e.last_exit_aware|date:"SHORT_DATETIME_FORMAT" %} + Exit: {{ date }} + {% endblocktrans %} +
{% endif %} @@ -146,6 +157,9 @@ + diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html index fca3979ebb..4ef22fd8c2 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html @@ -1,6 +1,8 @@ {% extends "pretixcontrol/items/base.html" %} {% load i18n %} {% load bootstrap3 %} +{% load static %} +{% load compress %} {% block title %} {% if checkinlist %} {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} @@ -15,35 +17,66 @@

{% trans "Check-in list" %}

{% endif %}
+ + {% csrf_token %} {% bootstrap_form_errors form %} -
- {% trans "General information" %} - {% bootstrap_field form.name layout="control" %} - {% if form.subevent %} - {% bootstrap_field form.subevent layout="control" %} - {% endif %} - {% bootstrap_field form.include_pending layout="control" %} -
-
+
+
+ {% trans "General" %} + {% bootstrap_field form.name layout="control" %} + {% if form.subevent %} + {% bootstrap_field form.subevent layout="control" %} + {% endif %} + {% bootstrap_field form.include_pending layout="control" %} + {% bootstrap_field form.all_products layout="control" %} + {% bootstrap_field form.limit_products layout="control" %} +
+
+ {% trans "Advanced" %} +
+ {% blocktrans trimmed %} + These settings on this page are intended for professional users with very specific check-in + situations. Please reach out to support if you have questions about setting this up. + {% endblocktrans %} +
+
+
+ {% blocktrans trimmed %} + Make sure to always use the latest version of our scanning apps for these options to work. + {% endblocktrans %} +
+ + {% blocktrans trimmed %} + If you make use of these advanced options, we recommend using our Android and Desktop apps. + Custom check-in rules do not work offline with our iOS scanning app. + {% endblocktrans %} + +
- {% trans "Products" %} -

- {% blocktrans trimmed %} - Please select the products that should be part of this check-in list. - {% endblocktrans %} -

- {% bootstrap_field form.all_products layout="control" %} - {% bootstrap_field form.limit_products layout="control" %} -
-
- {% trans "Advanced" %} - {% bootstrap_field form.auto_checkin_sales_channels layout="control" %} -
+ {% bootstrap_field form.allow_multiple_entries layout="control" %} + {% bootstrap_field form.allow_entry_after_exit layout="control" %} + {% bootstrap_field form.auto_checkin_sales_channels layout="control" %} + +

{% trans "Custom check-in rule" %}

+
+ +
+
+ {{ form.rules }} +
+
+
+ {% compress js %} + + + {% endcompress %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/checkin/lists.html b/src/pretix/control/templates/pretixcontrol/checkin/lists.html index 409f5e4157..d0eef0571d 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/lists.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/lists.html @@ -97,7 +97,13 @@ {% if request.event.has_subevents %} - {{ cl.subevent.name }} – {{ cl.subevent.get_date_range_display }} + {% if cl.subevent %} + {{ cl.subevent.name }} – {{ cl.subevent.get_date_range_display }} + {% else %} + + {% trans "All" %} + + {% endif %} {% endif %} {% for channel in cl.auto_checkin_sales_channels %} @@ -121,7 +127,7 @@
{% if "can_change_event_settings" in request.eventpermset %} - + {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 6d5040fbba..aeec5e4f3e 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -298,10 +298,14 @@ {% endif %} {% if line.checkins.all %} {% for c in line.checkins.all %} - {% if c.auto_checked_in %} + {% if c.type == "exit" %} + + {% elif c.forced %} + + {% elif c.auto_checked_in %} {% else %} - + {% endif %} {% endfor %} {% endif %} @@ -324,6 +328,10 @@ {% if line.subevent %}
{{ line.subevent.name }} · {{ line.subevent.get_date_range_display }} + {% if event.settings.show_times %} + + {{ line.subevent.date_from|date:"TIME_FORMAT" }} + {% endif %} {% endif %} {% if not line.canceled %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 929bc17d9f..fed5edde73 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -162,6 +162,8 @@ urlpatterns = [ url(r'^items/(?P\d+)/down$', item.item_move_down, name='event.items.down'), url(r'^items/(?P\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'), url(r'^items/typeahead/meta/$', typeahead.item_meta_values, name='event.items.meta.typeahead'), + url(r'^items/select2$', typeahead.items_select2, name='event.items.select2'), + url(r'^items/select2/variation$', typeahead.variations_select2, name='event.items.variations.select2'), url(r'^categories/$', item.CategoryList.as_view(), name='event.items.categories'), url(r'^categories/select2$', typeahead.category_select2, name='event.items.categories.select2'), url(r'^categories/(?P\d+)/delete$', item.CategoryDelete.as_view(), diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index 4ea6803a9c..002e4a1c36 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -30,7 +30,15 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): def get_queryset(self, filter=True): cqs = Checkin.objects.filter( position_id=OuterRef('pk'), - list_id=self.list.pk + list_id=self.list.pk, + type=Checkin.TYPE_ENTRY + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') + cqs_exit = Checkin.objects.filter( + position_id=OuterRef('pk'), + list_id=self.list.pk, + type=Checkin.TYPE_EXIT ).order_by().values('position_id').annotate( m=Max('datetime') ).values('m') @@ -38,13 +46,17 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): qs = OrderPosition.objects.filter( order__event=self.request.event, order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.list.include_pending else [Order.STATUS_PAID], - subevent=self.list.subevent ).annotate( - last_checked_in=Subquery(cqs), + last_entry=Subquery(cqs), + last_exit=Subquery(cqs_exit), auto_checked_in=Exists( Checkin.objects.filter(position_id=OuterRef('pk'), list_id=self.list.pk, auto_checked_in=True) ) ).select_related('item', 'variation', 'order', 'addon_to') + if self.list.subevent: + qs = qs.filter( + subevent=self.list.subevent + ) if not self.list.all_products: qs = qs.filter(item__in=self.list.limit_products.values_list('id', flat=True)) @@ -69,19 +81,35 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['checkinlist'] = self.list - ctx['seats'] = self.list.subevent.seating_plan if self.list.subevent else self.request.event.seating_plan + if self.request.event.has_subevents: + ctx['seats'] = ( + self.list.subevent.seating_plan_id if self.list.subevent + else self.request.event.subevents.filter(seating_plan__isnull=False).exists() + ) + else: + ctx['seats'] = self.request.event.seating_plan_id ctx['filter_form'] = self.filter_form for e in ctx['entries']: - if e.last_checked_in: - if isinstance(e.last_checked_in, str): + if e.last_entry: + if isinstance(e.last_entry, str): # Apparently only happens on SQLite - e.last_checked_in_aware = make_aware(dateutil.parser.parse(e.last_checked_in), UTC) - elif not is_aware(e.last_checked_in): + e.last_entry_aware = make_aware(dateutil.parser.parse(e.last_entry), UTC) + elif not is_aware(e.last_entry): # Apparently only happens on MySQL - e.last_checked_in_aware = make_aware(e.last_checked_in, UTC) + e.last_entry_aware = make_aware(e.last_entry, UTC) else: # This would be correct, so guess on which database it works… Yes, it's PostgreSQL. - e.last_checked_in_aware = e.last_checked_in + e.last_entry_aware = e.last_entry + if e.last_exit: + if isinstance(e.last_exit, str): + # Apparently only happens on SQLite + e.last_exit_aware = make_aware(dateutil.parser.parse(e.last_exit), UTC) + elif not is_aware(e.last_exit): + # Apparently only happens on MySQL + e.last_exit_aware = make_aware(e.last_exit, UTC) + else: + # This would be correct, so guess on which database it works… Yes, it's PostgreSQL. + e.last_exit_aware = e.last_exit return ctx def post(self, request, *args, **kwargs): @@ -111,17 +139,22 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): messages.success(request, _('The selected check-ins have been reverted.')) else: for op in positions: - created = False if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING): - ci, created = Checkin.objects.get_or_create(position=op, list=self.list, defaults={ - 'datetime': now(), - }) + t = Checkin.TYPE_EXIT if request.POST.get('checkout') == 'true' else Checkin.TYPE_ENTRY + if self.list.allow_multiple_entries or t != Checkin.TYPE_ENTRY: + ci = Checkin.objects.create(position=op, list=self.list, datetime=now(), type=t) + created = True + else: + ci, created = Checkin.objects.get_or_create(position=op, list=self.list, defaults={ + 'datetime': now(), + }) op.order.log_action('pretix.event.checkin', data={ 'position': op.id, 'positionid': op.positionid, 'first': created, 'forced': False, 'datetime': now(), + 'type': t, 'list': self.list.pk, 'web': True }, user=request.user) @@ -177,6 +210,11 @@ class CheckinListCreate(EventPermissionRequiredMixin, CreateView): permission = 'can_change_event_settings' context_object_name = 'checkinlist' + def dispatch(self, request, *args, **kwargs): + r = super().dispatch(request, *args, **kwargs) + r['Content-Security-Policy'] = 'script-src \'unsafe-eval\'' + return r + def get_success_url(self) -> str: return reverse('control:event.orders.checkinlists', kwargs={ 'organizer': self.request.event.organizer.slug, @@ -204,6 +242,11 @@ class CheckinListUpdate(EventPermissionRequiredMixin, UpdateView): permission = 'can_change_event_settings' context_object_name = 'checkinlist' + def dispatch(self, request, *args, **kwargs): + r = super().dispatch(request, *args, **kwargs) + r['Content-Security-Policy'] = 'script-src \'unsafe-eval\'' + return r + def get_object(self, queryset=None) -> CheckinList: try: return self.request.event.checkin_lists.get( diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index 1b00f7eb4d..7568922cb3 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -268,8 +268,8 @@ def checkin_widget(sender, subevent=None, lazy=False, **kwargs): for cl in qs: widgets.append({ 'content': None if lazy else NUM_WIDGET.format( - num='{}/{}'.format(cl.checkin_count, cl.position_count), - text=_('Checked in – {list}').format(list=escape(cl.name)) + num='{}/{}'.format(cl.inside_count, cl.position_count), + text=_('Present – {list}').format(list=escape(cl.name)) ), 'lazy': 'checkin-{}'.format(cl.pk), 'display_size': 'small', diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 9adbdf0551..c396fbf6d6 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -38,9 +38,9 @@ from pretix.base.decimal import round_decimal from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import ( - CachedCombinedTicket, CachedFile, CachedTicket, Invoice, InvoiceAddress, - Item, ItemVariation, LogEntry, Order, QuestionAnswer, Quota, - generate_position_secret, generate_secret, + CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice, + InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer, + Quota, generate_position_secret, generate_secret, ) from pretix.base.models.orders import ( CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund, @@ -252,7 +252,7 @@ class OrderDetail(OrderView): ).prefetch_related( 'item__questions', 'issued_gift_cards', Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')), - 'checkins', 'checkins__list' + Prefetch('checkins', queryset=Checkin.objects.select_related('list').order_by('datetime')), ).order_by('positionid') positions = [] diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index 026617ee37..c3dca2b04b 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -25,7 +25,7 @@ from pretix.base.models.items import ( ) from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.quotas import QuotaAvailability -from pretix.control.forms.checkin import CheckinListForm +from pretix.control.forms.checkin import SimpleCheckinListForm from pretix.control.forms.filter import SubEventFilterForm from pretix.control.forms.item import QuotaForm from pretix.control.forms.subevents import ( @@ -192,11 +192,11 @@ class SubEventEditorMixin(MetaDataEditorMixin): 'include_pending': False, } ] - extra = 1 + extra = 0 formsetclass = inlineformset_factory( SubEvent, CheckinList, - form=CheckinListForm, formset=CheckinListFormSet, + form=SimpleCheckinListForm, formset=CheckinListFormSet, can_order=False, can_delete=True, extra=extra, ) if self.object: diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 7748fcc481..7e5c8f3a7a 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -13,8 +13,8 @@ from django.utils.timezone import make_aware from django.utils.translation import gettext as _, pgettext from pretix.base.models import ( - EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue, Order, - Organizer, User, Voucher, + EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue, + ItemVariation, Order, Organizer, User, Voucher, ) from pretix.control.forms.event import EventWizardCopyForm from pretix.control.permissions import event_permission_required @@ -322,6 +322,68 @@ def quotas_select2(request, **kwargs): return JsonResponse(doc) +@event_permission_required(None) +def items_select2(request, **kwargs): + query = request.GET.get('query', '') + try: + page = int(request.GET.get('page', '1')) + except ValueError: + page = 1 + + qs = request.event.items.filter( + name__icontains=i18ncomp(query) + ).order_by('position') + + total = qs.count() + pagesize = 20 + offset = (page - 1) * pagesize + doc = { + 'results': [ + { + 'id': e.pk, + 'text': str(e), + } + for e in qs[offset:offset + pagesize] + ], + 'pagination': { + "more": total >= (offset + pagesize) + } + } + return JsonResponse(doc) + + +@event_permission_required(None) +def variations_select2(request, **kwargs): + query = request.GET.get('query', '') + try: + page = int(request.GET.get('page', '1')) + except ValueError: + page = 1 + + q = Q(item__event=request.event) + for word in query.split(): + q &= Q(value__icontains=i18ncomp(word)) | Q(item__name__icontains=i18ncomp(ord)) + + qs = ItemVariation.objects.filter(q).order_by('item__position', 'item__name', 'position', 'value').select_related('item') + + total = qs.count() + pagesize = 20 + offset = (page - 1) * pagesize + doc = { + 'results': [ + { + 'id': e.pk, + 'text': str(e.item) + " – " + str(e), + } + for e in qs[offset:offset + pagesize] + ], + 'pagination': { + "more": total >= (offset + pagesize) + } + } + return JsonResponse(doc) + + @event_permission_required(None) def category_select2(request, **kwargs): query = request.GET.get('query', '') diff --git a/src/pretix/helpers/jsonlogic.py b/src/pretix/helpers/jsonlogic.py new file mode 100644 index 0000000000..e7a8d59b68 --- /dev/null +++ b/src/pretix/helpers/jsonlogic.py @@ -0,0 +1,244 @@ +""" +This is a Python implementation of the following jsonLogic JS library: +https://github.com/jwadhams/json-logic-js + +Implementation is built upon the implementation at https://github.com/nadirizr/json-logic-py +Copyright (c) 2015 nadirizr, The MIT License + +We vendor this library since it is simple enough and upstream seems unmaintained. + +In particular, we changed: + +* Full test coverage +* Fully passing tests against shared tests suite at 2020-04-19 +* Option to add custom operations +""" +import logging +from functools import reduce + +logger = logging.getLogger(__name__) + + +def if_(*args): + """Implements the 'if' operator with support for multiple elseif-s.""" + for i in range(0, len(args) - 1, 2): + if args[i]: + return args[i + 1] + if len(args) % 2: + return args[-1] + else: + return None + + +def soft_equals(a, b): + """Implements the '==' operator, which does type JS-style coertion.""" + if isinstance(a, str) or isinstance(b, str): + return str(a) == str(b) + if isinstance(a, bool) or isinstance(b, bool): + return bool(a) is bool(b) + return a == b + + +def hard_equals(a, b): + """Implements the '===' operator.""" + if type(a) != type(b): + return False + return a == b + + +def less(a, b, *args): + """Implements the '<' operator with JS-style type coertion.""" + types = set([type(a), type(b)]) + if float in types or int in types: + try: + a, b = float(a), float(b) + except (TypeError, ValueError): + # NaN + return False + return a < b and (not args or less(b, *args)) + + +def less_or_equal(a, b, *args): + """Implements the '<=' operator with JS-style type coertion.""" + return ( + less(a, b) or soft_equals(a, b) + ) and (not args or less_or_equal(b, *args)) + + +def to_numeric(arg): + """ + Converts a string either to int or to float. + This is important, because e.g. {"!==": [{"+": "0"}, 0.0]} + """ + if isinstance(arg, str): + if '.' in arg: + return float(arg) + else: + return int(arg) + return arg + + +def plus(*args): + """Sum converts either to ints or to floats.""" + return sum(to_numeric(arg) for arg in args) + + +def minus(*args): + """Also, converts either to ints or to floats.""" + if len(args) == 1: + return -to_numeric(args[0]) + return to_numeric(args[0]) - to_numeric(args[1]) + + +def merge(*args): + """Implements the 'merge' operator for merging lists.""" + ret = [] + for arg in args: + if isinstance(arg, list) or isinstance(arg, tuple): + ret += list(arg) + else: + ret.append(arg) + return ret + + +def get_var(data, var_name="", not_found=None): + """Gets variable value from data dictionary.""" + if var_name == "" or var_name is None: + return data + try: + for key in str(var_name).split('.'): + try: + data = data[key] + except TypeError: + data = data[int(key)] + except (KeyError, TypeError, ValueError): + return not_found + else: + return data + + +def missing(data, *args): + """Implements the missing operator for finding missing variables.""" + not_found = object() + if args and isinstance(args[0], list): + args = args[0] + ret = [] + for arg in args: + if get_var(data, arg, not_found) is not_found: + ret.append(arg) + return ret + + +def missing_some(data, min_required, args): + """Implements the missing_some operator for finding missing variables.""" + if min_required < 1: + return [] + found = 0 + not_found = object() + ret = [] + for arg in args: + if get_var(data, arg, not_found) is not_found: + ret.append(arg) + else: + found += 1 + if found >= min_required: + return [] + return ret + + +operations = { + "==": soft_equals, + "===": hard_equals, + "!=": lambda a, b: not soft_equals(a, b), + "!==": lambda a, b: not hard_equals(a, b), + ">": lambda a, b: less(b, a), + ">=": lambda a, b: less(b, a) or soft_equals(a, b), + "<": less, + "<=": less_or_equal, + "!": lambda a: not a, + "!!": bool, + "%": lambda a, b: a % b, + "and": lambda *args: reduce(lambda total, arg: total and arg, args, True), + "or": lambda *args: reduce(lambda total, arg: total or arg, args, False), + "?:": lambda a, b, c: b if a else c, + "if": if_, + "log": lambda a: logger.info(a) or a, + "in": lambda a, b: a in b if "__contains__" in dir(b) else False, + "cat": lambda *args: "".join(str(arg) for arg in args), + "+": plus, + "*": lambda *args: reduce(lambda total, arg: total * float(arg), args, 1), + "-": minus, + "/": lambda a, b=None: a if b is None else float(a) / float(b), + "min": lambda *args: min(args), + "max": lambda *args: max(args), + "merge": merge, + "count": lambda *args: sum(1 if a else 0 for a in args), + "substr": lambda a, b, c=None: a[b:] if c is None else a[b:][:c], +} + + +class Logic(): + def __init__(self): + self._operations = {} + + def add_operation(self, name, func): + self._operations[name] = func + + def apply(self, tests, data=None): + """Executes the json-logic with given data.""" + # You've recursed to a primitive, stop! + if tests is None or not isinstance(tests, dict): + return tests + + data = data or {} + + operator = list(tests.keys())[0] + values = tests[operator] + + # Easy syntax for unary operators, like {"var": "x"} instead of strict + # {"var": ["x"]} + if not isinstance(values, list) and not isinstance(values, tuple): + values = [values] + + # Array-level operations + if operator == 'none': + return not any(self.apply(values[1], i) for i in self.apply(values[0], data)) + if operator == 'all': + elements = self.apply(values[0], data) + if not elements: + return False + return all(self.apply(values[1], i) for i in elements) + if operator == 'some': + return any(self.apply(values[1], i) for i in self.apply(values[0], data)) + if operator == 'reduce': + return reduce( + lambda acc, el: self.apply(values[1], {'current': el, 'accumulator': acc}), + self.apply(values[0], data) or [], + self.apply(values[2], data) + ) + if operator == 'map': + return [ + self.apply(values[1], i) for i in (self.apply(values[0], data) or []) + ] + if operator == 'filter': + return [ + i for i in self.apply(values[0], data) + if self.apply(values[1], i) + ] + + # Recursion! + values = [self.apply(val, data) for val in values] + + if operator == 'var': + return get_var(data, *values) + if operator == 'missing': + return missing(data, *values) + if operator == 'missing_some': + return missing_some(data, *values) + + if operator in operations: + return operations[operator](*values) + elif operator in self._operations: + return self._operations[operator](*values) + else: + raise ValueError("Unrecognized operation %s" % operator) diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js new file mode 100644 index 0000000000..7bb72f3256 --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js @@ -0,0 +1,476 @@ +$(document).ready(function () { + var TYPEOPS = { + 'product': { + 'inList': { + 'label': gettext('is one of'), + 'cardinality': 2, + } + }, + 'variation': { + 'inList': { + 'label': gettext('is one of'), + 'cardinality': 2, + } + }, + 'datetime': { + 'isBefore': { + 'label': gettext('is before'), + 'cardinality': 2, + }, + 'isAfter': { + 'label': gettext('is after'), + 'cardinality': 2, + }, + }, + 'int': { + '<': { + 'label': '<', + 'cardinality': 2, + }, + '<=': { + 'label': '≤', + 'cardinality': 2, + }, + '>': { + 'label': '>', + 'cardinality': 2, + }, + '>=': { + 'label': '≥', + 'cardinality': 2, + }, + '==': { + 'label': '=', + 'cardinality': 2, + }, + '!=': { + 'label': '≠', + 'cardinality': 2, + }, + }, + }; + var VARS = { + 'product': { + 'label': gettext('Product'), + 'type': 'product', + }, + 'variation': { + 'label': gettext('Product variation'), + 'type': 'variation', + }, + 'now': { + 'label': gettext('Current date and time'), + 'type': 'datetime', + }, + 'entries_number': { + 'label': gettext('Number of previous entries'), + 'type': 'int', + }, + 'entries_today': { + 'label': gettext('Number of previous entries since midnight'), + 'type': 'int', + }, + 'entries_days': { + 'label': gettext('Number of days with a previous entry'), + 'type': 'int', + }, + }; + + Vue.component("datetimefield", { + props: ["required", "value"], + template: (''), + mounted: function () { + var vm = this; + var multiple = this.multiple; + $(this.$el) + .datetimepicker(this.opts()) + .trigger("change") + .on("dp.change", function (e) { + vm.$emit("input", $(this).data('DateTimePicker').date().toISOString()); + }); + if (!vm.value) { + $(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); + } else { + $(this.$el).data("DateTimePicker").date(moment(vm.value)); + } + }, + methods: { + opts: function () { + return { + format: $("body").attr("data-datetimeformat"), + locale: $("body").attr("data-datetimelocale"), + useCurrent: false, + showClear: this.required, + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + previous: 'fa fa-chevron-left', + next: 'fa fa-chevron-right', + today: 'fa fa-screenshot', + clear: 'fa fa-trash', + close: 'fa fa-remove' + } + }; + } + }, + watch: { + value: function (val) { + $(this.$el).data('DateTimePicker').date(moment(val)); + }, + }, + destroyed: function () { + $(this.$el) + .off() + .datetimepicker("destroy"); + } + }); + + Vue.component("lookup-select2", { + props: ["required", "value", "placeholder", "url", "multiple"], + template: (''), + mounted: function () { + var vm = this; + var multiple = this.multiple; + $(this.$el) + .select2(this.opts()) + .val(this.value) + .trigger("change") + // emit event on change. + .on("change", function (e) { + vm.$emit("input", $(this).select2('data')); + }); + if (vm.value) { + for (var i = 0; i < vm.value["objectList"].length; i++) { + var option = new Option(vm.value["objectList"][i]["lookup"][2], vm.value["objectList"][i]["lookup"][1], true, true); + $(vm.$el).append(option); + } + } + $(vm.$el).trigger("change"); + }, + methods: { + opts: function () { + return { + theme: "bootstrap", + delay: 100, + width: '100%', + multiple: true, + allowClear: this.required, + language: $("body").attr("data-select2-locale"), + ajax: { + url: this.url, + data: function (params) { + return { + query: params.term, + page: params.page || 1 + } + } + }, + templateResult: function (res) { + if (!res.id) { + return res.text; + } + var $ret = $("").append( + $("").addClass("primary").append($("
").text(res.text).html()) + ); + return $ret; + }, + }; + } + }, + watch: { + placeholder: function (val) { + $(this.$el).empty().select2(this.opts()); + this.build(); + }, + required: function (val) { + $(this.$el).empty().select2(this.opts()); + this.build(); + }, + url: function (val) { + $(this.$el).empty().select2(this.opts()); + this.build(); + }, + }, + destroyed: function () { + $(this.$el) + .off() + .select2("destroy"); + } + }); + + Vue.component('checkin-rule', { + template: ('
' + + '
' + + '' + + ' ' + + '' + + '' + + '
' + + ' ' + + ' ' + + ' ' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '' + + '
' + + '
' + ), + computed: { + variable: function () { + var op = this.operator; + if (op === "and" || op === "or") { + return op; + } else if (this.rule[op] && this.rule[op][0]) { + return this.rule[op][0]["var"]; + } else { + return null; + } + }, + rightoperand: function () { + var op = this.operator; + if (op === "and" || op === "or") { + return null; + } else if (this.rule[op] && typeof this.rule[op][1] !== "undefined") { + return this.rule[op][1]; + } else { + return null; + } + }, + operator: function () { + return Object.keys(this.rule)[0]; + }, + operands: function () { + return this.rule[this.operator]; + }, + classObject: function () { + var c = { + 'checkin-rule': true + }; + c['checkin-rule-' + this.variable] = true; + return c; + }, + vartype: function () { + if (this.variable && VARS[this.variable]) { + return VARS[this.variable]['type']; + } + }, + timeType: function () { + if (this.rightoperand && this.rightoperand['buildTime']) { + return this.rightoperand['buildTime'][0]; + } + }, + timeTolerance: function () { + var op = this.operator; + if ((op === "isBefore" || op === "isAfter") && this.rule[op] && typeof this.rule[op][2] !== "undefined") { + return this.rule[op][2]; + } else { + return null; + } + }, + timeValue: function () { + if (this.rightoperand && this.rightoperand['buildTime']) { + return this.rightoperand['buildTime'][1]; + } + }, + cardinality: function () { + if (this.vartype && TYPEOPS[this.vartype] && TYPEOPS[this.vartype][this.operator]) { + return TYPEOPS[this.vartype][this.operator]['cardinality']; + } + }, + operators: function () { + return TYPEOPS[this.vartype]; + }, + productSelectURL: function () { + return $("#product-select2").text(); + }, + variationSelectURL: function () { + return $("#variations-select2").text(); + }, + vars: function () { + return VARS; + }, + }, + methods: { + setVariable: function (event) { + var current_op = Object.keys(this.rule)[0]; + var current_val = this.rule[current_op]; + + if (event.target.value === "and" || event.target.value === "or") { + if (current_val[0] && current_val[0]["var"]) { + current_val = []; + } + this.$set(this.rule, event.target.value, current_val); + this.$delete(this.rule, current_op); + } else { + if (current_val !== "and" && current_val !== "or" && current_val[0] && VARS[event.target.value]['type'] === this.vartype) { + this.$set(this.rule[current_op][0], "var", event.target.value); + } else { + this.$delete(this.rule, current_op); + this.$set(this.rule, "!!", [{"var": event.target.value}]); + } + } + }, + setOperator: function (event) { + var current_op = Object.keys(this.rule)[0]; + var current_val = this.rule[current_op]; + this.$delete(this.rule, current_op); + this.$set(this.rule, event.target.value, current_val); + }, + setRightOperandNumber: function (event) { + if (this.rule[this.operator].length === 1) { + this.rule[this.operator].push(parseInt(event.target.value)); + } else { + this.$set(this.rule[this.operator], 1, parseInt(event.target.value)); + } + }, + setTimeTolerance: function (event) { + if (this.rule[this.operator].length === 2) { + this.rule[this.operator].push(parseInt(event.target.value)); + } else { + this.$set(this.rule[this.operator], 2, parseInt(event.target.value)); + } + }, + setTimeType: function (event) { + var time = { + "buildTime": [event.target.value] + }; + if (this.rule[this.operator].length === 1) { + this.rule[this.operator].push(time); + } else { + this.$set(this.rule[this.operator], 1, time); + } + }, + setTimeValue: function (val) { + console.log(val); + this.$set(this.rule[this.operator][1]["buildTime"], 1, val); + }, + setRightOperandProductList: function (val) { + var products = { + "objectList": [] + }; + for (var i = 0; i < val.length; i++) { + products["objectList"].push({ + "lookup": [ + "product", + val[i].id, + val[i].text + ] + }); + } + if (this.rule[this.operator].length === 1) { + this.rule[this.operator].push(products); + } else { + this.$set(this.rule[this.operator], 1, products); + } + }, + setRightOperandVariationList: function (val) { + var products = { + "objectList": [] + }; + for (var i = 0; i < val.length; i++) { + products["objectList"].push({ + "lookup": [ + "variation", + val[i].id, + val[i].text + ] + }); + } + if (this.rule[this.operator].length === 1) { + this.rule[this.operator].push(products); + } else { + this.$set(this.rule[this.operator], 1, products); + } + }, + addOperand: function () { + this.rule[this.operator].push({"": []}); + }, + wrapWithOR: function () { + var r = JSON.parse(JSON.stringify(this.rule)); + this.$delete(this.rule, this.operator); + this.$set(this.rule, "or", [r]); + }, + wrapWithAND: function () { + var r = JSON.parse(JSON.stringify(this.rule)); + this.$delete(this.rule, this.operator); + this.$set(this.rule, "and", [r]); + }, + cutOut: function () { + var cop = Object.keys(this.operands[0])[0]; + var r = this.operands[0][cop]; + this.$delete(this.rule, this.operator); + this.$set(this.rule, cop, r); + }, + remove: function () { + this.$parent.rule[this.$parent.operator].splice(this.index, 1); + }, + }, + props: { + rule: Object, + level: Number, + index: Number, + } + }); + + Vue.component('checkin-rules-editor', { + template: ('
' + + '' + + '' + + '
' + ), + computed: { + hasRules: function () { + return hasRules = !!Object.keys(this.$root.rules).length; + } + }, + methods: { + addRule: function () { + this.$set(this.$root.rules, "and", []); + }, + }, + }); + + var app = new Vue({ + el: '#rules-editor', + data: function () { + return { + rules: {}, + hasRules: false, + }; + }, + created: function () { + this.rules = JSON.parse($("#id_rules").val()); + }, + watch: { + rules: { + deep: true, + handler: function (newval) { + $("#id_rules").val(JSON.stringify(newval)); + } + }, + } + }) +}); diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index ea727771ba..561679f3f8 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -523,3 +523,20 @@ table td > .checkbox input[type="checkbox"] { margin-bottom: 10px; } } + +#rules-editor { + .checkin-rule { + border-left: 4px solid $brand-primary; + background: rgba(0, 0, 0, 0.05); + padding: 5px 15px 5px 15px; + margin: 5px 0; + position: relative; + } + + .checkin-rule-and { + border-left: 4px solid $brand-danger; + } + .checkin-rule-or { + border-left: 4px solid $brand-success; + } +} diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 47c0d73e3b..6c65658e5d 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -667,6 +667,13 @@ h1 .label { } } +.withoutjs { + display: none !important; +} +.nojs .withoutjs { + display: block !important; +} + .nojs .requirejs { display: none !important; } diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index 546a43bb29..a5eec78ed3 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -125,7 +125,10 @@ TEST_LIST_RES = { "position_count": 0, "checkin_count": 0, "include_pending": False, - "subevent": None + "allow_multiple_entries": False, + "allow_entry_after_exit": True, + "subevent": None, + "rules": {} } @@ -187,7 +190,8 @@ def test_list_create(token_client, organizer, event, item, item_on_wrong_event): "name": "VIP", "limit_products": [item.pk], "all_products": False, - "subevent": None + "subevent": None, + "rules": {"==": [0, 1]} }, format='json' ) @@ -197,6 +201,7 @@ def test_list_create(token_client, organizer, event, item, item_on_wrong_event): assert cl.name == "VIP" assert cl.limit_products.count() == 1 assert not cl.all_products + assert cl.rules == {"==": [0, 1]} resp = token_client.post( '/api/v1/organizers/{}/events/{}/checkinlists/'.format(organizer.slug, event.slug), @@ -275,8 +280,7 @@ def test_list_create_with_subevent(token_client, organizer, event, event3, item, }, format='json' ) - assert resp.status_code == 400 - assert resp.content.decode() == '{"non_field_errors":["Subevent cannot be null for event series."]}' + assert resp.status_code == 201 resp = token_client.post( '/api/v1/organizers/{}/events/{}/checkinlists/'.format(organizer.slug, event.slug), @@ -372,7 +376,8 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a { 'list': clist_all.pk, 'datetime': c.datetime.isoformat().replace('+00:00', 'Z'), - 'auto_checked_in': False + 'auto_checked_in': False, + 'type': 'entry', } ] resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?has_checkin=1'.format( @@ -410,7 +415,8 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a { 'list': clist_all.pk, 'datetime': c.datetime.isoformat().replace('+00:00', 'Z'), - 'auto_checked_in': False + 'auto_checked_in': False, + 'type': 'entry', } ] resp = token_client.get( @@ -450,6 +456,39 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a assert [p2, p1] == resp.data['results'] +@pytest.mark.django_db +def test_list_all_items_positions_by_subevent(token_client, organizer, event, clist, clist_all, item, other_item, order, subevent): + with scopes_disabled(): + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + pfirst = order.positions.first() + pfirst.subevent = se2 + pfirst.save() + p1 = dict(TEST_ORDERPOSITION1_RES) + p1["id"] = pfirst.pk + p1["subevent"] = se2.pk + p1["item"] = item.pk + plast = order.positions.last() + plast.subevent = subevent + plast.save() + p2 = dict(TEST_ORDERPOSITION2_RES) + p2["id"] = plast.pk + p2["item"] = other_item.pk + p2["subevent"] = subevent.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p1, p2] == resp.data['results'] + + clist_all.subevent = subevent + clist_all.save() + resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format( + organizer.slug, event.slug, clist_all.pk + )) + assert resp.status_code == 200 + assert [p2] == resp.data['results'] + + @pytest.mark.django_db def test_list_limited_items_positions(token_client, organizer, event, clist, item, order): p1 = dict(TEST_ORDERPOSITION1_RES) @@ -607,6 +646,46 @@ def test_reupload_same_nonce(token_client, organizer, clist, event, order): assert resp.data['status'] == 'ok' +@pytest.mark.django_db +def test_allow_multiple(token_client, organizer, clist, event, order): + clist.allow_multiple_entries = True + clist.save() + with scopes_disabled(): + p = order.positions.first() + resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format( + organizer.slug, event.slug, clist.pk, p.pk + ), {}, format='json') + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format( + organizer.slug, event.slug, clist.pk, p.pk + ), {}, format='json') + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + assert p.checkins.count() == 2 + + +@pytest.mark.django_db +def test_allow_multiple_reupload_same_nonce(token_client, organizer, clist, event, order): + clist.allow_multiple_entries = True + clist.save() + with scopes_disabled(): + p = order.positions.first() + resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format( + organizer.slug, event.slug, clist.pk, p.pk + ), {'nonce': 'foobar'}, format='json') + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format( + organizer.slug, event.slug, clist.pk, p.pk + ), {'nonce': 'foobar'}, format='json') + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + assert p.checkins.count() == 1 + + @pytest.mark.django_db def test_multiple_different_list(token_client, organizer, clist, clist_all, event, order): with scopes_disabled(): diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index b479a930b6..36ce29055a 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -783,7 +783,7 @@ def test_orderposition_list(token_client, organizer, event, order, item, subeven with scopes_disabled(): cl = event.checkin_lists.create(name="Default") op.checkins.create(datetime=datetime.datetime(2017, 12, 26, 10, 0, 0, tzinfo=UTC), list=cl) - res['checkins'] = [{'datetime': '2017-12-26T10:00:00Z', 'list': cl.pk, 'auto_checked_in': False}] + res['checkins'] = [{'datetime': '2017-12-26T10:00:00Z', 'list': cl.pk, 'auto_checked_in': False, 'type': 'entry'}] resp = token_client.get( '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=true'.format(organizer.slug, event.slug)) assert [res] == resp.data['results'] diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py new file mode 100644 index 0000000000..5682742c77 --- /dev/null +++ b/src/tests/base/test_checkin.py @@ -0,0 +1,602 @@ +import time +from datetime import datetime, timedelta +from decimal import Decimal + +import pytest +from django.conf import settings +from django.utils.timezone import now +from django_scopes import scope +from freezegun import freeze_time + +from pretix.base.models import Checkin, Event, Order, OrderPosition, Organizer +from pretix.base.services.checkin import ( + CheckInError, RequiredQuestionsError, perform_checkin, +) + + +@pytest.fixture(scope='function') +def event(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), + plugins='pretix.plugins.banktransfer' + ) + with scope(organizer=o): + yield event + + +@pytest.fixture +def clist(event): + c = event.checkin_lists.create(name="Default", all_products=True) + return c + + +@pytest.fixture +def item(event): + return event.items.create(name="Ticket", default_price=3, admission=True) + + +@pytest.fixture +def position(event, item): + order = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PAID, locale='en', + datetime=now() - timedelta(days=4), + expires=now() - timedelta(hours=4) + timedelta(days=10), + total=Decimal('23.00'), + ) + return OrderPosition.objects.create( + order=order, item=item, variation=None, + price=Decimal("23.00"), attendee_name_parts={"full_name": "Peter"}, positionid=1 + ) + + +@pytest.mark.django_db +def test_checkin_valid(position, clist): + perform_checkin(position, clist, {}) + assert position.checkins.count() == 1 + + +@pytest.mark.django_db +def test_checkin_canceled_order(position, clist): + o = position.order + o.status = Order.STATUS_CANCELED + o.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'unpaid' + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}, canceled_supported=True) + assert excinfo.value.code == 'canceled' + assert position.checkins.count() == 0 + + o.status = Order.STATUS_EXPIRED + o.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}, canceled_supported=True) + assert excinfo.value.code == 'canceled' + assert position.checkins.count() == 0 + + +@pytest.mark.django_db +def test_checkin_canceled_position(position, clist): + position.canceled = True + position.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'unpaid' + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}, canceled_supported=True) + assert excinfo.value.code == 'canceled' + assert position.checkins.count() == 0 + + +@pytest.mark.django_db +def test_checkin_invalid_product(position, clist): + clist.all_products = False + clist.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'product' + clist.limit_products.add(position.item) + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_checkin_invalid_subevent(position, clist, event): + event.has_subevents = True + event.save() + se1 = event.subevents.create(name="Foo", date_from=event.date_from) + se2 = event.subevents.create(name="Foo", date_from=event.date_from) + position.subevent = se1 + position.save() + clist.subevent = se2 + clist.save() + + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'product' + + +@pytest.mark.django_db +def test_checkin_all_subevents(position, clist, event): + event.has_subevents = True + event.save() + se1 = event.subevents.create(name="Foo", date_from=event.date_from) + position.subevent = se1 + position.save() + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_unpaid(position, clist): + o = position.order + o.status = Order.STATUS_PENDING + o.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'unpaid' + + +@pytest.mark.django_db +def test_unpaid_include_pending_ignore(position, clist): + o = position.order + o.status = Order.STATUS_PENDING + o.save() + clist.include_pending = True + clist.save() + perform_checkin(position, clist, {}, ignore_unpaid=True) + + +@pytest.mark.django_db +def test_unpaid_ignore_without_include_pendung(position, clist): + o = position.order + o.status = Order.STATUS_PENDING + o.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'unpaid' + + +@pytest.mark.django_db +def test_unpaid_force(position, clist): + o = position.order + o.status = Order.STATUS_PENDING + o.save() + perform_checkin(position, clist, {}, force=True) + + +@pytest.mark.django_db +def test_required_question_missing(event, position, clist): + q = event.questions.create( + question="Quo vadis?", + type="S", + required=True, + ask_during_checkin=True, + ) + q.items.add(position.item) + with pytest.raises(RequiredQuestionsError) as excinfo: + perform_checkin(position, clist, {}, questions_supported=True) + assert excinfo.value.code == 'incomplete' + assert excinfo.value.questions == [q] + + +@pytest.mark.django_db +def test_required_question_missing_but_not_supported(event, position, clist): + q = event.questions.create( + question="Quo vadis?", + type="S", + required=True, + ask_during_checkin=True, + ) + q.items.add(position.item) + perform_checkin(position, clist, {}, questions_supported=False) + + +@pytest.mark.django_db +def test_required_question_missing_but_forced(event, position, clist): + q = event.questions.create( + question="Quo vadis?", + type="S", + required=True, + ask_during_checkin=True, + ) + q.items.add(position.item) + perform_checkin(position, clist, {}, questions_supported=True, force=True) + + +@pytest.mark.django_db +def test_optional_question_missing(event, position, clist): + q = event.questions.create( + question="Quo vadis?", + type="S", + required=False, + ask_during_checkin=True, + ) + q.items.add(position.item) + with pytest.raises(RequiredQuestionsError) as excinfo: + perform_checkin(position, clist, {}, questions_supported=True) + assert excinfo.value.code == 'incomplete' + assert excinfo.value.questions == [q] + + +@pytest.mark.django_db +def test_required_online_question_missing(event, position, clist): + q = event.questions.create( + question="Quo vadis?", + type="S", + required=True, + ask_during_checkin=False, + ) + q.items.add(position.item) + perform_checkin(position, clist, {}, questions_supported=True) + + +@pytest.mark.django_db +def test_question_filled_previously(event, position, clist): + q = event.questions.create( + question="Quo vadis?", + type="S", + required=True, + ask_during_checkin=True, + ) + q.items.add(position.item) + position.answers.create(question=q, answer='Foo') + perform_checkin(position, clist, {}, questions_supported=True) + + +@pytest.mark.django_db +def test_question_filled(event, position, clist): + q = event.questions.create( + question="Quo vadis?", + type="S", + required=True, + ask_during_checkin=True, + ) + q.items.add(position.item) + perform_checkin(position, clist, {q: 'Foo'}, questions_supported=True) + a = position.answers.get() + assert a.question == q + assert a.answer == 'Foo' + + +@pytest.mark.django_db +def test_single_entry(position, clist): + perform_checkin(position, clist, {}) + + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'already_redeemed' + + assert position.checkins.count() == 1 + + +@pytest.mark.django_db +def test_single_entry_repeat_nonce(position, clist): + perform_checkin(position, clist, {}, nonce='foo') + perform_checkin(position, clist, {}, nonce='foo') + + assert position.checkins.count() == 1 + + +@pytest.mark.django_db +def test_multi_entry(position, clist): + clist.allow_multiple_entries = True + clist.save() + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + + assert position.checkins.count() == 2 + + +@pytest.mark.django_db +def test_multi_entry_repeat_nonce(position, clist): + clist.allow_multiple_entries = True + clist.save() + perform_checkin(position, clist, {}, nonce='foo') + perform_checkin(position, clist, {}, nonce='foo') + + assert position.checkins.count() == 1 + + +@pytest.mark.django_db +def test_single_entry_forced_reentry(position, clist): + perform_checkin(position, clist, {}, force=True) + + perform_checkin(position, clist, {}, force=True, nonce='bla') + perform_checkin(position, clist, {}, force=True, nonce='bla') + + assert position.checkins.count() == 2 + assert not position.checkins.last().forced + assert position.checkins.first().forced + assert position.order.all_logentries().count() == 2 + + +@pytest.mark.django_db +def test_multi_exit(position, clist): + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) + + assert position.checkins.count() == 3 + + +@pytest.mark.django_db +def test_single_entry_after_exit_ordered_by_date(position, clist): + dt1 = now() - timedelta(minutes=10) + dt2 = now() - timedelta(minutes=5) + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT, datetime=dt2) + time.sleep(1) + perform_checkin(position, clist, {}, datetime=dt1) + perform_checkin(position, clist, {}) + + assert position.checkins.count() == 3 + + +@pytest.mark.django_db +def test_single_entry_after_exit(position, clist): + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) + perform_checkin(position, clist, {}) + + assert position.checkins.count() == 3 + + +@pytest.mark.django_db +def test_single_entry_after_exit_forbidden(position, clist): + clist.allow_entry_after_exit = False + clist.save() + + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'already_redeemed' + + assert position.checkins.count() == 2 + + +@pytest.mark.django_db +def test_rules_simple(position, clist): + clist.rules = {'and': [False, True]} + clist.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}, type='exit') + assert excinfo.value.code == 'rules' + + clist.rules = {'and': [True, True]} + clist.save() + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_rules_product(event, position, clist): + i2 = event.items.create(name="Ticket", default_price=3, admission=True) + clist.rules = { + "inList": [ + {"var": "product"}, { + "objectList": [ + {"lookup": ["product", str(i2.pk), "Ticket"]}, + ] + } + ] + } + clist.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + clist.rules = { + "inList": [ + {"var": "product"}, { + "objectList": [ + {"lookup": ["product", str(i2.pk), "Ticket"]}, + {"lookup": ["product", str(position.item.pk), "Ticket"]}, + ] + } + ] + } + clist.save() + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_rules_variation(item, position, clist): + v1 = item.variations.create(value="A") + v2 = item.variations.create(value="B") + position.variation = v2 + position.save() + clist.rules = { + "inList": [ + {"var": "variation"}, { + "objectList": [ + {"lookup": ["variation", str(v1.pk), "Ticket – A"]}, + ] + } + ] + } + clist.save() + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + clist.rules = { + "inList": [ + {"var": "variation"}, { + "objectList": [ + {"lookup": ["variation", str(v1.pk), "Ticket – A"]}, + {"lookup": ["variation", str(v2.pk), "Ticket – B"]}, + ] + } + ] + } + clist.save() + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_rules_scan_number(position, clist): + # Ticket is valid three times + clist.allow_multiple_entries = True + clist.rules = {"<": [{"var": "entries_number"}, 3]} + clist.save() + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) + perform_checkin(position, clist, {}) + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + +@pytest.mark.django_db +def test_rules_scan_today(event, position, clist): + # Ticket is valid three times per day + event.settings.timezone = 'Europe/Berlin' + clist.allow_multiple_entries = True + clist.rules = {"<": [{"var": "entries_today"}, 3]} + clist.save() + with freeze_time("2020-01-01 10:00:00"): + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) + perform_checkin(position, clist, {}) + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + with freeze_time("2020-01-01 22:50:00"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + with freeze_time("2020-01-01 23:10:00"): + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + +@pytest.mark.django_db +def test_rules_scan_days(event, position, clist): + # Ticket is valid unlimited times, but only on two arbitrary days + event.settings.timezone = 'Europe/Berlin' + clist.allow_multiple_entries = True + clist.rules = {"or": [{">": [{"var": "entries_today"}, 0]}, {"<": [{"var": "entries_days"}, 2]}]} + clist.save() + with freeze_time("2020-01-01 10:00:00"): + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + + with freeze_time("2020-01-03 10:00:00"): + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + perform_checkin(position, clist, {}) + + with freeze_time("2020-01-03 22:50:00"): + perform_checkin(position, clist, {}) + + with freeze_time("2020-01-03 23:50:00"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + +@pytest.mark.django_db +def test_rules_time_isafter_tolerance(event, position, clist): + # Ticket is valid starting 10 minutes before admission time + event.settings.timezone = 'Europe/Berlin' + event.date_admission = event.timezone.localize(datetime(2020, 1, 1, 12, 0, 0)) + event.save() + clist.rules = {"isAfter": [{"var": "now"}, {"buildTime": ["date_admission"]}, 10]} + clist.save() + with freeze_time("2020-01-01 10:45:00"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + with freeze_time("2020-01-01 10:51:00"): + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_rules_time_isafter_no_tolerance(event, position, clist): + # Ticket is valid only after admission time + event.settings.timezone = 'Europe/Berlin' + event.date_from = event.timezone.localize(datetime(2020, 1, 1, 12, 0, 0)) + # also tests that date_admission falls back to date_from + event.save() + clist.rules = {"isAfter": [{"var": "now"}, {"buildTime": ["date_admission"]}]} + clist.save() + with freeze_time("2020-01-01 10:51:00"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + with freeze_time("2020-01-01 11:01:00"): + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_rules_time_isbefore_with_tolerance(event, position, clist): + # Ticket is valid until 10 minutes after end time + event.settings.timezone = 'Europe/Berlin' + event.date_to = event.timezone.localize(datetime(2020, 1, 1, 12, 0, 0)) + event.save() + clist.rules = {"isBefore": [{"var": "now"}, {"buildTime": ["date_to"]}, 10]} + clist.save() + with freeze_time("2020-01-01 11:11:00"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + with freeze_time("2020-01-01 11:09:00"): + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_rules_time_isafter_custom_time(event, position, clist): + # Ticket is valid starting at a custom time + event.settings.timezone = 'Europe/Berlin' + clist.rules = {"isAfter": [{"var": "now"}, {"buildTime": ["custom", "2020-01-01T22:00:00.000Z"]}, None]} + clist.save() + with freeze_time("2020-01-01 21:55:00"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + with freeze_time("2020-01-01 22:05:00"): + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db +def test_rules_isafter_subevent(position, clist, event): + event.has_subevents = True + event.save() + event.settings.timezone = 'Europe/Berlin' + se1 = event.subevents.create(name="Foo", date_from=event.timezone.localize(datetime(2020, 2, 1, 12, 0, 0))) + position.subevent = se1 + position.save() + clist.rules = {"isAfter": [{"var": "now"}, {"buildTime": ["date_admission"]}]} + clist.save() + with freeze_time("2020-02-01 10:51:00"): + with pytest.raises(CheckInError) as excinfo: + perform_checkin(position, clist, {}) + assert excinfo.value.code == 'rules' + + with freeze_time("2020-02-01 11:01:00"): + perform_checkin(position, clist, {}) + + +@pytest.mark.django_db(transaction=True) +def test_position_queries(django_assert_num_queries, position, clist): + with django_assert_num_queries(11) as captured: + perform_checkin(position, clist, {}) + if 'sqlite' not in settings.DATABASES['default']['ENGINE']: + assert any('FOR UPDATE' in s['sql'] for s in captured) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 108bc905c4..814040f4b3 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1707,7 +1707,16 @@ class EventTest(TestCase): que1.items.add(i1) event1.settings.foo_setting = 23 event1.settings.tax_rate_default = tr7 - cl1 = event1.checkin_lists.create(name="All", all_products=False) + cl1 = event1.checkin_lists.create( + name="All", all_products=False, + rules={ + "and": [ + {"isBefore": [{"var": "now"}, {"buildTime": ["date_from"]}, None]}, + {"inList": [{"var": "product"}, {"objectList": [{"lookup": ["product", str(i1.pk), "Text"]}]}]}, + {"inList": [{"var": "variation"}, {"objectList": [{"lookup": ["variation", str(v1.pk), "Text"]}]}]} + ] + } + ) cl1.limit_products.add(i1) event2 = Event.objects.create( @@ -1739,7 +1748,15 @@ class EventTest(TestCase): assert event2.settings.foo_setting == '23' assert event2.settings.tax_rate_default == trnew assert event2.checkin_lists.count() == 1 - assert [i.pk for i in event2.checkin_lists.first().limit_products.all()] == [i1new.pk] + clnew = event2.checkin_lists.first() + assert [i.pk for i in clnew.limit_products.all()] == [i1new.pk] + assert clnew.rules == { + "and": [ + {"isBefore": [{"var": "now"}, {"buildTime": ["date_from"]}, None]}, + {"inList": [{"var": "product"}, {"objectList": [{"lookup": ["product", str(i1new.pk), "Text"]}]}]}, + {"inList": [{"var": "variation"}, {"objectList": [{"lookup": ["variation", str(i1new.variations.get().pk), "Text"]}]}]} + ] + } @classscope(attr='organizer') def test_presale_has_ended(self): diff --git a/src/tests/helpers/jsonlogic-tests.json b/src/tests/helpers/jsonlogic-tests.json new file mode 100644 index 0000000000..13de57e221 --- /dev/null +++ b/src/tests/helpers/jsonlogic-tests.json @@ -0,0 +1,519 @@ +[ + "# Non-rules get passed through", + [ true, {}, true ], + [ false, {}, false ], + [ 17, {}, 17 ], + [ 3.14, {}, 3.14 ], + [ "apple", {}, "apple" ], + [ null, {}, null ], + [ ["a","b"], {}, ["a","b"] ], + + "# Single operator tests", + [ {"==":[1,1]}, {}, true ], + [ {"==":[1,"1"]}, {}, true ], + [ {"==":[1,2]}, {}, false ], + [ {"===":[1,1]}, {}, true ], + [ {"===":[1,"1"]}, {}, false ], + [ {"===":[1,2]}, {}, false ], + [ {"!=":[1,2]}, {}, true ], + [ {"!=":[1,1]}, {}, false ], + [ {"!=":[1,"1"]}, {}, false ], + [ {"!==":[1,2]}, {}, true ], + [ {"!==":[1,1]}, {}, false ], + [ {"!==":[1,"1"]}, {}, true ], + [ {">":[2,1]}, {}, true ], + [ {">":[1,1]}, {}, false ], + [ {">":[1,2]}, {}, false ], + [ {">":["2",1]}, {}, true ], + [ {">=":[2,1]}, {}, true ], + [ {">=":[1,1]}, {}, true ], + [ {">=":[1,2]}, {}, false ], + [ {">=":["2",1]}, {}, true ], + [ {"<":[2,1]}, {}, false ], + [ {"<":[1,1]}, {}, false ], + [ {"<":[1,2]}, {}, true ], + [ {"<":["1",2]}, {}, true ], + [ {"<":[1,2,3]}, {}, true ], + [ {"<":[1,1,3]}, {}, false ], + [ {"<":[1,4,3]}, {}, false ], + [ {"<=":[2,1]}, {}, false ], + [ {"<=":[1,1]}, {}, true ], + [ {"<=":[1,2]}, {}, true ], + [ {"<=":["1",2]}, {}, true ], + [ {"<=":[1,2,3]}, {}, true ], + [ {"<=":[1,4,3]}, {}, false ], + [ {"!":[false]}, {}, true ], + [ {"!":false}, {}, true ], + [ {"!":[true]}, {}, false ], + [ {"!":true}, {}, false ], + [ {"!":0}, {}, true ], + [ {"!":1}, {}, false ], + [ {"or":[true,true]}, {}, true ], + [ {"or":[false,true]}, {}, true ], + [ {"or":[true,false]}, {}, true ], + [ {"or":[false,false]}, {}, false ], + [ {"or":[false,false,true]}, {}, true ], + [ {"or":[false,false,false]}, {}, false ], + [ {"or":[false]}, {}, false ], + [ {"or":[true]}, {}, true ], + [ {"or":[1,3]}, {}, 1 ], + [ {"or":[3,false]}, {}, 3 ], + [ {"or":[false,3]}, {}, 3 ], + [ {"and":[true,true]}, {}, true ], + [ {"and":[false,true]}, {}, false ], + [ {"and":[true,false]}, {}, false ], + [ {"and":[false,false]}, {}, false ], + [ {"and":[true,true,true]}, {}, true ], + [ {"and":[true,true,false]}, {}, false ], + [ {"and":[false]}, {}, false ], + [ {"and":[true]}, {}, true ], + [ {"and":[1,3]}, {}, 3 ], + [ {"and":[3,false]}, {}, false ], + [ {"and":[false,3]}, {}, false ], + [ {"?:":[true,1,2]}, {}, 1 ], + [ {"?:":[false,1,2]}, {}, 2 ], + [ {"in":["Bart",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, true ], + [ {"in":["Milhouse",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, false ], + [ {"in":["Spring","Springfield"]}, {}, true ], + [ {"in":["i","team"]}, {}, false ], + [ {"cat":"ice"}, {}, "ice" ], + [ {"cat":["ice"]}, {}, "ice" ], + [ {"cat":["ice","cream"]}, {}, "icecream" ], + [ {"cat":[1,2]}, {}, "12" ], + [ {"cat":["Robocop",2]}, {}, "Robocop2" ], + [ {"cat":["we all scream for ","ice","cream"]}, {}, "we all scream for icecream" ], + [ {"%":[1,2]}, {}, 1 ], + [ {"%":[2,2]}, {}, 0 ], + [ {"%":[3,2]}, {}, 1 ], + [ {"max":[1,2,3]}, {}, 3 ], + [ {"max":[1,3,3]}, {}, 3 ], + [ {"max":[3,2,1]}, {}, 3 ], + [ {"max":[1]}, {}, 1 ], + [ {"min":[1,2,3]}, {}, 1 ], + [ {"min":[1,1,3]}, {}, 1 ], + [ {"min":[3,2,1]}, {}, 1 ], + [ {"min":[1]}, {}, 1 ], + + [ {"+":[1,2]}, {}, 3 ], + [ {"+":[2,2,2]}, {}, 6 ], + [ {"+":[1]}, {}, 1 ], + [ {"+":["1",1]}, {}, 2 ], + [ {"*":[3,2]}, {}, 6 ], + [ {"*":[2,2,2]}, {}, 8 ], + [ {"*":[1]}, {}, 1 ], + [ {"*":["1",1]}, {}, 1 ], + [ {"-":[2,3]}, {}, -1 ], + [ {"-":[3,2]}, {}, 1 ], + [ {"-":[3]}, {}, -3 ], + [ {"-":["1",1]}, {}, 0 ], + [ {"/":[4,2]}, {}, 2 ], + [ {"/":[2,4]}, {}, 0.5 ], + [ {"/":["1",1]}, {}, 1 ], + + "Substring", + [{"substr":["jsonlogic", 4]}, null, "logic"], + [{"substr":["jsonlogic", -5]}, null, "logic"], + [{"substr":["jsonlogic", 0, 1]}, null, "j"], + [{"substr":["jsonlogic", -1, 1]}, null, "c"], + [{"substr":["jsonlogic", 4, 5]}, null, "logic"], + [{"substr":["jsonlogic", -5, 5]}, null, "logic"], + [{"substr":["jsonlogic", -5, -2]}, null, "log"], + [{"substr":["jsonlogic", 1, -5]}, null, "son"], + + "Merge arrays", + [{"merge":[]}, null, []], + [{"merge":[[1]]}, null, [1]], + [{"merge":[[1],[]]}, null, [1]], + [{"merge":[[1], [2]]}, null, [1,2]], + [{"merge":[[1], [2], [3]]}, null, [1,2,3]], + [{"merge":[[1, 2], [3]]}, null, [1,2,3]], + [{"merge":[[1], [2, 3]]}, null, [1,2,3]], + "Given non-array arguments, merge converts them to arrays", + [{"merge":1}, null, [1]], + [{"merge":[1,2]}, null, [1,2]], + [{"merge":[1,[2]]}, null, [1,2]], + + "Too few args", + [{"if":[]}, null, null], + [{"if":[true]}, null, true], + [{"if":[false]}, null, false], + [{"if":["apple"]}, null, "apple"], + + "Simple if/then/else cases", + [{"if":[true, "apple"]}, null, "apple"], + [{"if":[false, "apple"]}, null, null], + [{"if":[true, "apple", "banana"]}, null, "apple"], + [{"if":[false, "apple", "banana"]}, null, "banana"], + + "Empty arrays are falsey", + [{"if":[ [], "apple", "banana"]}, null, "banana"], + [{"if":[ [1], "apple", "banana"]}, null, "apple"], + [{"if":[ [1,2,3,4], "apple", "banana"]}, null, "apple"], + + "Empty strings are falsey, all other strings are truthy", + [{"if":[ "", "apple", "banana"]}, null, "banana"], + [{"if":[ "zucchini", "apple", "banana"]}, null, "apple"], + [{"if":[ "0", "apple", "banana"]}, null, "apple"], + + "You can cast a string to numeric with a unary + ", + [{"===":[0,"0"]}, null, false], + [{"===":[0,{"+":"0"}]}, null, true], + [{"if":[ {"+":"0"}, "apple", "banana"]}, null, "banana"], + [{"if":[ {"+":"1"}, "apple", "banana"]}, null, "apple"], + + "Zero is falsy, all other numbers are truthy", + [{"if":[ 0, "apple", "banana"]}, null, "banana"], + [{"if":[ 1, "apple", "banana"]}, null, "apple"], + [{"if":[ 3.1416, "apple", "banana"]}, null, "apple"], + [{"if":[ -1, "apple", "banana"]}, null, "apple"], + + "Truthy and falsy definitions matter in Boolean operations", + [{"!" : [ [] ]}, {}, true], + [{"!!" : [ [] ]}, {}, false], + [{"and" : [ [], true ]}, {}, [] ], + [{"or" : [ [], true ]}, {}, true ], + + [{"!" : [ 0 ]}, {}, true], + [{"!!" : [ 0 ]}, {}, false], + [{"and" : [ 0, true ]}, {}, 0 ], + [{"or" : [ 0, true ]}, {}, true ], + + [{"!" : [ "" ]}, {}, true], + [{"!!" : [ "" ]}, {}, false], + [{"and" : [ "", true ]}, {}, "" ], + [{"or" : [ "", true ]}, {}, true ], + + [{"!" : [ "0" ]}, {}, false], + [{"!!" : [ "0" ]}, {}, true], + [{"and" : [ "0", true ]}, {}, true ], + [{"or" : [ "0", true ]}, {}, "0" ], + + "If the conditional is logic, it gets evaluated", + [{"if":[ {">":[2,1]}, "apple", "banana"]}, null, "apple"], + [{"if":[ {">":[1,2]}, "apple", "banana"]}, null, "banana"], + + "If the consequents are logic, they get evaluated", + [{"if":[ true, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "apple"], + [{"if":[ false, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "banana"], + + "If/then/elseif/then cases", + [{"if":[true, "apple", true, "banana"]}, null, "apple"], + [{"if":[true, "apple", false, "banana"]}, null, "apple"], + [{"if":[false, "apple", true, "banana"]}, null, "banana"], + [{"if":[false, "apple", false, "banana"]}, null, null], + + [{"if":[true, "apple", true, "banana", "carrot"]}, null, "apple"], + [{"if":[true, "apple", false, "banana", "carrot"]}, null, "apple"], + [{"if":[false, "apple", true, "banana", "carrot"]}, null, "banana"], + [{"if":[false, "apple", false, "banana", "carrot"]}, null, "carrot"], + + [{"if":[false, "apple", false, "banana", false, "carrot"]}, null, null], + [{"if":[false, "apple", false, "banana", false, "carrot", "date"]}, null, "date"], + [{"if":[false, "apple", false, "banana", true, "carrot", "date"]}, null, "carrot"], + [{"if":[false, "apple", true, "banana", false, "carrot", "date"]}, null, "banana"], + [{"if":[false, "apple", true, "banana", true, "carrot", "date"]}, null, "banana"], + [{"if":[true, "apple", false, "banana", false, "carrot", "date"]}, null, "apple"], + [{"if":[true, "apple", false, "banana", true, "carrot", "date"]}, null, "apple"], + [{"if":[true, "apple", true, "banana", false, "carrot", "date"]}, null, "apple"], + [{"if":[true, "apple", true, "banana", true, "carrot", "date"]}, null, "apple"], + + "# Compound Tests", + [ {"and":[{">":[3,1]},true]}, {}, true ], + [ {"and":[{">":[3,1]},false]}, {}, false ], + [ {"and":[{">":[3,1]},{"!":true}]}, {}, false ], + [ {"and":[{">":[3,1]},{"<":[1,3]}]}, {}, true ], + [ {"?:":[{">":[3,1]},"visible","hidden"]}, {}, "visible" ], + + "# Data-Driven", + [ {"var":["a"]},{"a":1},1 ], + [ {"var":["b"]},{"a":1},null ], + [ {"var":["a"]},null,null ], + [ {"var":"a"},{"a":1},1 ], + [ {"var":"b"},{"a":1},null ], + [ {"var":"a"},null,null ], + [ {"var":["a", 1]},null,1 ], + [ {"var":["b", 2]},{"a":1},2 ], + [ {"var":"a.b"},{"a":{"b":"c"}},"c" ], + [ {"var":"a.q"},{"a":{"b":"c"}},null ], + [ {"var":["a.q", 9]},{"a":{"b":"c"}},9 ], + [ {"var":1}, ["apple","banana"], "banana" ], + [ {"var":"1"}, ["apple","banana"], "banana" ], + [ {"var":"1.1"}, ["apple",["banana","beer"]], "beer" ], + [ {"and":[{"<":[{"var":"temp"},110]},{"==":[{"var":"pie.filling"},"apple"]}]},{"temp":100,"pie":{"filling":"apple"}},true ], + [ {"var":[{"?:":[{"<":[{"var":"temp"},110]},"pie.filling","pie.eta"]}]},{"temp":100,"pie":{"filling":"apple","eta":"60s"}},"apple" ], + [ {"in":[{"var":"filling"},["apple","cherry"]]},{"filling":"apple"},true ], + [ {"var":"a.b.c"}, null, null ], + [ {"var":"a.b.c"}, {"a":null}, null ], + [ {"var":"a.b.c"}, {"a":{"b":null}}, null ], + [ {"var":""}, 1, 1 ], + [ {"var":null}, 1, 1 ], + [ {"var":[]}, 1, 1 ], + + "Missing", + [{"missing":[]}, null, []], + [{"missing":["a"]}, null, ["a"]], + [{"missing":"a"}, null, ["a"]], + [{"missing":"a"}, {"a":"apple"}, []], + [{"missing":["a"]}, {"a":"apple"}, []], + [{"missing":["a","b"]}, {"a":"apple"}, ["b"]], + [{"missing":["a","b"]}, {"b":"banana"}, ["a"]], + [{"missing":["a","b"]}, {"a":"apple", "b":"banana"}, []], + [{"missing":["a","b"]}, {}, ["a","b"]], + [{"missing":["a","b"]}, null, ["a","b"]], + + [{"missing":["a.b"]}, null, ["a.b"]], + [{"missing":["a.b"]}, {"a":"apple"}, ["a.b"]], + [{"missing":["a.b"]}, {"a":{"c":"apple cake"}}, ["a.b"]], + [{"missing":["a.b"]}, {"a":{"b":"apple brownie"}}, []], + [{"missing":["a.b", "a.c"]}, {"a":{"b":"apple brownie"}}, ["a.c"]], + + + "Missing some", + [{"missing_some":[1, ["a", "b"]]}, {"a":"apple"}, [] ], + [{"missing_some":[1, ["a", "b"]]}, {"b":"banana"}, [] ], + [{"missing_some":[1, ["a", "b"]]}, {"a":"apple", "b":"banana"}, [] ], + [{"missing_some":[1, ["a", "b"]]}, {"c":"carrot"}, ["a", "b"]], + + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana"}, [] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "c":"carrot"}, [] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana", "c":"carrot"}, [] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "d":"durian"}, ["b", "c"] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"d":"durian", "e":"eggplant"}, ["a", "b", "c"] ], + + + "Missing and If are friends, because empty arrays are falsey in JsonLogic", + [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"a":"apple"}, "found it"], + [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"b":"banana"}, "missed it"], + + "Missing, Merge, and If are friends. VIN is always required, APR is only required if financing is true.", + [ + {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, + {"financing":true}, + ["vin","apr"] + ], + + [ + {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, + {"financing":false}, + ["vin"] + ], + + "Filter, map, all, none, and some", + [ + {"filter":[{"var":"integers"}, true]}, + {"integers":[1,2,3]}, + [1,2,3] + ], + [ + {"filter":[{"var":"integers"}, false]}, + {"integers":[1,2,3]}, + [] + ], + [ + {"filter":[{"var":"integers"}, {">=":[{"var":""},2]}]}, + {"integers":[1,2,3]}, + [2,3] + ], + [ + {"filter":[{"var":"integers"}, {"%":[{"var":""},2]}]}, + {"integers":[1,2,3]}, + [1,3] + ], + + [ + {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]}, + {"integers":[1,2,3]}, + [2,4,6] + ], + [ + {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]}, + null, + [] + ], + [ + {"map":[{"var":"desserts"}, {"var":"qty"}]}, + {"desserts":[ + {"name":"apple","qty":1}, + {"name":"brownie","qty":2}, + {"name":"cupcake","qty":3} + ]}, + [1,2,3] + ], + + [ + {"reduce":[ + {"var":"integers"}, + {"+":[{"var":"current"}, {"var":"accumulator"}]}, + 0 + ]}, + {"integers":[1,2,3,4]}, + 10 + ], + [ + {"reduce":[ + {"var":"integers"}, + {"+":[{"var":"current"}, {"var":"accumulator"}]}, + 0 + ]}, + null, + 0 + ], + [ + {"reduce":[ + {"var":"integers"}, + {"*":[{"var":"current"}, {"var":"accumulator"}]}, + 1 + ]}, + {"integers":[1,2,3,4]}, + 24 + ], + [ + {"reduce":[ + {"var":"integers"}, + {"*":[{"var":"current"}, {"var":"accumulator"}]}, + 0 + ]}, + {"integers":[1,2,3,4]}, + 0 + ], + [ + {"reduce": [ + {"var":"desserts"}, + {"+":[ {"var":"accumulator"}, {"var":"current.qty"}]}, + 0 + ]}, + {"desserts":[ + {"name":"apple","qty":1}, + {"name":"brownie","qty":2}, + {"name":"cupcake","qty":3} + ]}, + 6 + ], + + + [ + {"all":[{"var":"integers"}, {">=":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"all":[{"var":"integers"}, {"==":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[]}, + false + ], + [ + {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"all":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"all":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[]}, + false + ], + + + [ + {"none":[{"var":"integers"}, {">=":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"none":[{"var":"integers"}, {"==":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[]}, + true + ], + [ + {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"none":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"none":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[]}, + true + ], + + [ + {"some":[{"var":"integers"}, {">=":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"some":[{"var":"integers"}, {"==":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[]}, + false + ], + [ + {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"some":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"some":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[]}, + false + ], + + "EOF" +] diff --git a/src/tests/helpers/test_jsonlogic.py b/src/tests/helpers/test_jsonlogic.py new file mode 100644 index 0000000000..85a13a0369 --- /dev/null +++ b/src/tests/helpers/test_jsonlogic.py @@ -0,0 +1,34 @@ +import json +import os + +import pytest + +from pretix.helpers.jsonlogic import Logic + +with open(os.path.join(os.path.dirname(__file__), 'jsonlogic-tests.json'), 'r') as f: + data = json.load(f) + params = [r for r in data if isinstance(r, list)] + +params += [ + ({"==": [True, True]}, {}, True), + ({"==": [True, False]}, {}, False), + ({"<": [0, "foo"]}, {}, False), + ({"+": [3.4, "0.1"]}, {}, 3.5), + ({"missing_some": [0, {'var': ''}]}, {}, []), +] + + +@pytest.mark.parametrize("logic,data,expected", params) +def test_shared_tests(logic, data, expected): + assert Logic().apply(logic, data) == expected + + +def test_unknown_operator(): + with pytest.raises(ValueError): + assert Logic().apply({'unknownOp': []}, {}) + + +def test_custom_operation(): + logic = Logic() + logic.add_operation('double', lambda a: a * 2) + assert logic.apply({'double': [{'var': 'value'}]}, {'value': 3}) == 6