Overhaul of our check-in features (#1647)

This commit is contained in:
Raphael Michel
2020-05-13 18:01:49 +02:00
committed by GitHub
parent 640b9c876d
commit c056db46b6
36 changed files with 2604 additions and 169 deletions
+21
View File
@@ -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
+9
View File
@@ -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
}
-22
View File
@@ -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?
------------------------------------------------------
+3 -4
View File
@@ -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'):
+1 -1
View File
@@ -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):
+2 -2
View File
@@ -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']
+10 -2
View File
@@ -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({
+1 -1
View File
@@ -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
@@ -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(),
),
]
+59 -7
View File
@@ -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 "<Checkin: pos {} on list '{}' at {}>".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()
+17
View File
@@ -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:
+138 -48
View File
@@ -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)
+1 -1
View File
@@ -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)
+33 -4
View File
@@ -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,
+11 -6
View File
@@ -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')]
+14 -2
View File
@@ -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(
@@ -12,8 +12,8 @@
{% if 'can_change_event_settings' in request.eventpermset %}
<a href="{% url "control:event.orders.checkinlists.edit" event=request.event.slug organizer=request.event.organizer.slug list=checkinlist.pk %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit list" %}
<span class="fa fa-wrench"></span>
{% trans "Edit list configuration" %}
</a>
{% endif %}
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlistpdf&checkinlistpdf-list={{ checkinlist.pk }}"
@@ -122,19 +122,30 @@
{{ e.secret|slice:":10" }}…
</td>
<td>
{% if not e.last_checked_in %}
{% if not e.last_entry %}
<span class="label label-danger">{% trans "Not checked in" %}</span>
{% else %}
<span class="label label-success">{% trans "Checked in" %}</span>
{% if e.auto_checked_in %}
<span class="fa fa-magic text-muted"
data-toggle="tooltip" title="{% trans "Checked in automatically" %}"></span>
{% if e.last_exit and e.last_exit_aware > e.last_entry_aware %}
<span class="label label-success">{% trans "Checked in but left" %}</span>
{% else %}
<span class="label label-success">{% trans "Checked in" %}</span>
{% if e.auto_checked_in %}
<span class="fa fa-magic text-muted"
data-toggle="tooltip" title="{% trans "Checked in automatically" %}"></span>
{% endif %}
{% endif %}
{% endif %}
</td>
<td>
{% 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 %}
<small><br>
{% blocktrans trimmed with date=e.last_exit_aware|date:"SHORT_DATETIME_FORMAT" %}
Exit: {{ date }}
{% endblocktrans %}
</small>
{% endif %}
</td>
</tr>
@@ -146,6 +157,9 @@
<button type="submit" class="btn btn-primary btn-save">
{% trans "Check-In selected attendees" %}
</button>
<button type="submit" class="btn btn-default btn-save" name="checkout" value="true">
{% trans "Check-Out selected attendees" %}
</button>
<button type="submit" class="btn btn-default btn-save" name="revert" value="true">
{% trans "Revert selected check-ins" %}
</button>
@@ -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 @@
<h1>{% trans "Check-in list" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal">
<script type="text/plain"
id="product-select2">{% url "control:event.items.select2" event=request.event.slug organizer=request.organizer.slug %}</script>
<script type="text/plain"
id="variations-select2">{% url "control:event.items.variations.select2" event=request.event.slug organizer=request.organizer.slug %}</script>
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
{% if form.subevent %}
{% bootstrap_field form.subevent layout="control" %}
{% endif %}
{% bootstrap_field form.include_pending layout="control" %}
</fieldset>
<fieldset>
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% 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" %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced" %}</legend>
<div class="alert alert-info">
{% 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 %}
<br>
</div>
<div class="alert alert-warning">
{% blocktrans trimmed %}
Make sure to always use the latest version of our scanning apps for these options to work.
{% endblocktrans %}
<br>
<strong>
{% 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 %}
</strong>
</div>
<legend>{% trans "Products" %}</legend>
<p>
{% blocktrans trimmed %}
Please select the products that should be part of this check-in list.
{% endblocktrans %}
</p>
{% bootstrap_field form.all_products layout="control" %}
{% bootstrap_field form.limit_products layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced" %}</legend>
{% bootstrap_field form.auto_checkin_sales_channels layout="control" %}
</fieldset>
{% 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" %}
<h3>{% trans "Custom check-in rule" %}</h3>
<div id="rules-editor" class="form-inline">
<checkin-rules-editor></checkin-rules-editor>
</div>
<div class="disabled-withoutjs sr-only">
{{ form.rules }}
</div>
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% compress js %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
{% endcompress %}
{% endblock %}
@@ -97,7 +97,13 @@
</div>
</td>
{% if request.event.has_subevents %}
<td>{{ cl.subevent.name }} {{ cl.subevent.get_date_range_display }}</td>
{% if cl.subevent %}
<td>{{ cl.subevent.name }} {{ cl.subevent.get_date_range_display }}</td>
{% else %}
<td>
<em>{% trans "All" %}</em>
</td>
{% endif %}
{% endif %}
<td>
{% for channel in cl.auto_checkin_sales_channels %}
@@ -121,7 +127,7 @@
<td class="text-right flip">
<a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-default btn-sm"><i class="fa fa-eye"></i></a>
{% if "can_change_event_settings" in request.eventpermset %}
<a href="{% url "control:event.orders.checkinlists.edit" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.orders.checkinlists.edit" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
<a href="{% url "control:event.orders.checkinlists.delete" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
@@ -298,10 +298,14 @@
{% endif %}
{% if line.checkins.all %}
{% for c in line.checkins.all %}
{% if c.auto_checked_in %}
{% if c.type == "exit" %}
<span class="fa fa-fw fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}"></span>
{% elif c.forced %}
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
{% elif c.auto_checked_in %}
<span class="fa fa-fw fa-magic" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
{% else %}
<span class="fa fa-fw fa-check" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}First scanned: {{ date }}{% endblocktrans %}"></span>
<span class="fa fa-fw fa-check" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Entry scan: {{ date }}{% endblocktrans %}"></span>
{% endif %}
{% endfor %}
{% endif %}
@@ -324,6 +328,10 @@
{% if line.subevent %}
<br/>
<span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
{% if event.settings.show_times %}
<span class="fa fa-clock-o"></span>
{{ line.subevent.date_from|date:"TIME_FORMAT" }}
{% endif %}
{% endif %}
{% if not line.canceled %}
<div class="position-buttons">
+2
View File
@@ -162,6 +162,8 @@ urlpatterns = [
url(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
url(r'^items/(?P<item>\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<category>\d+)/delete$', item.CategoryDelete.as_view(),
+57 -14
View File
@@ -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(
+2 -2
View File
@@ -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',
+4 -4
View File
@@ -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 = []
+3 -3
View File
@@ -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:
+64 -2
View File
@@ -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', '')
+244
View File
@@ -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)
@@ -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: ('<input class="form-control">'),
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: ('<select>\n' +
' <slot></slot>\n' +
' </select>'),
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 = $("<span>").append(
$("<span>").addClass("primary").append($("<div>").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: ('<div v-bind:class="classObject">'
+ '<div class="btn-group pull-right">'
+ '<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithOR">OR</button>'
+ '<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithAND">AND</button> '
+ '<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="cutOut" v-if="operands && operands.length == 1 && (operator === \'or\' || operator == \'and\')"><span class="fa fa-cut"></span></button>'
+ '<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="remove" v-if="level > 0"><span class="fa fa-trash"></span></button>'
+ '</div>'
+ '<select v-bind:value="variable" v-on:input="setVariable" required class="form-control">'
+ '<option value="and">' + gettext('All of the conditions below (AND)') + '</option>'
+ '<option value="or">' + gettext('At least one of the conditions below (OR)') + '</option>'
+ '<option v-for="(v, name) in vars" :value="name">{{ v.label }}</option>'
+ '</select> '
+ '<select v-bind:value="operator" v-on:input="setOperator" required class="form-control" v-if="operator !== \'or\' && operator !== \'and\'">'
+ '<option></option>'
+ '<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>'
+ '</select> '
+ '<select v-bind:value="timeType" v-on:input="setTimeType" required class="form-control" v-if="vartype == \'datetime\'">'
+ '<option value="date_from">' + gettext('Event start') + '</option>'
+ '<option value="date_to">' + gettext('Event end') + '</option>'
+ '<option value="date_admission">' + gettext('Event admission') + '</option>'
+ '<option value="custom">' + gettext('custom time') + '</option>'
+ '</select> '
+ '<datetimefield v-if="vartype == \'datetime\' && timeType == \'custom\'" :value="timeValue" v-on:input="setTimeValue"></datetimefield>'
+ '<input class="form-control" required type="number" v-if="vartype == \'datetime\' && timeType && timeType != \'custom\'" v-bind:value="timeTolerance" v-on:input="setTimeTolerance" placeholder="' + gettext('Tolerance (minutes)') + '">'
+ '<input class="form-control" required type="number" v-if="vartype == \'int\' && cardinality > 1" v-bind:value="rightoperand" v-on:input="setRightOperandNumber">'
+ '<lookup-select2 required v-if="vartype == \'product\' && operator == \'inList\'" :multiple="true" :value="rightoperand" v-on:input="setRightOperandProductList" :url="productSelectURL"></lookup-select2>'
+ '<lookup-select2 required v-if="vartype == \'variation\' && operator == \'inList\'" :multiple="true" :value="rightoperand" v-on:input="setRightOperandVariationList" :url="variationSelectURL"></lookup-select2>'
+ '<div class="checkin-rule-childrules" v-if="operator === \'or\' || operator === \'and\'">'
+ '<div v-for="(op, opi) in operands">'
+ '<checkin-rule :rule="op" :index="opi" :level="level + 1" v-if="typeof op === \'object\'"></checkin-rule>'
+ '</div>'
+ '<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" @click.prevent="addOperand"><span class="fa fa-plus-circle"></span> ' + gettext('Add condition') + '</button>'
+ '</div>'
+ '</div>'
),
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: ('<div class="checkin-rules-editor">'
+ '<checkin-rule :rule="this.$root.rules" :level="0" :index="0" v-if="hasRules"></checkin-rule>'
+ '<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" v-if="!hasRules" @click.prevent="addRule"><span class="fa fa-plus-circle"></span> ' + gettext('Add condition') + '</button>'
+ '</div>'
),
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));
}
},
}
})
});
@@ -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;
}
}
@@ -667,6 +667,13 @@ h1 .label {
}
}
.withoutjs {
display: none !important;
}
.nojs .withoutjs {
display: block !important;
}
.nojs .requirejs {
display: none !important;
}
+85 -6
View File
@@ -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():
+1 -1
View File
@@ -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']
+602
View File
@@ -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)
+19 -2
View File
@@ -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):
+519
View File
@@ -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"
]
+34
View File
@@ -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