mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Overhaul of our check-in features (#1647)
This commit is contained in:
@@ -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'):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
52
src/pretix/base/migrations/0152_auto_20200511_1504.py
Normal file
52
src/pretix/base/migrations/0152_auto_20200511_1504.py
Normal file
@@ -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(),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 }} · {{ 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">
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
src/pretix/helpers/jsonlogic.py
Normal file
244
src/pretix/helpers/jsonlogic.py
Normal 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)
|
||||
476
src/pretix/static/pretixcontrol/js/ui/checkinrules.js
Normal file
476
src/pretix/static/pretixcontrol/js/ui/checkinrules.js
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
src/tests/base/test_checkin.py
Normal file
602
src/tests/base/test_checkin.py
Normal 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)
|
||||
@@ -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
src/tests/helpers/jsonlogic-tests.json
Normal file
519
src/tests/helpers/jsonlogic-tests.json
Normal 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
src/tests/helpers/test_jsonlogic.py
Normal file
34
src/tests/helpers/test_jsonlogic.py
Normal 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
|
||||
Reference in New Issue
Block a user