mirror of
https://github.com/pretix/pretix.git
synced 2026-05-03 14:54:04 +00:00
Overhaul of our check-in features (#1647)
This commit is contained in:
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)
|
||||
|
||||
Reference in New Issue
Block a user