New check-in features (#3022)

This commit is contained in:
Raphael Michel
2023-02-09 09:46:46 +01:00
committed by GitHub
parent 7b0d07065f
commit 6902725f3c
69 changed files with 1606 additions and 183 deletions

View File

@@ -109,6 +109,8 @@ class JSONExporter(BaseExporter):
'name': str(variation),
'description': str(variation.description),
'position': variation.position,
'checkin_attention': variation.checkin_attention,
'require_approval': variation.require_approval,
'require_membership': variation.require_membership,
'sales_channels': variation.sales_channels,
'available_from': variation.available_from,
@@ -193,6 +195,9 @@ class JSONExporter(BaseExporter):
'state': position.state,
'secret': position.secret,
'addon_to': position.addon_to_id,
'valid_from': position.valid_from,
'valid_until': position.valid_until,
'blocked': position.blocked,
'answers': [
{
'question': answer.question_id,

View File

@@ -43,6 +43,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import (
@@ -569,6 +570,9 @@ class OrderListExporter(MultiSheetListExporter):
_('Seat zone'),
_('Seat row'),
_('Seat number'),
_('Blocked'),
_('Valid from'),
_('Valid until'),
_('Order comment'),
_('Follow-up date'),
]
@@ -682,6 +686,11 @@ class OrderListExporter(MultiSheetListExporter):
else:
row += ['', '', '', '', '']
row += [
_('Yes') if op.blocked else '',
date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') if op.valid_from else '',
date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') if op.valid_until else '',
]
row.append(order.comment)
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
acache = {}

View File

@@ -0,0 +1,55 @@
# Generated by Django 3.2.17 on 2023-02-08 09:39
import django.core.serializers.json
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0229_invoice_payment_provider_stamp'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='checkin_attention',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='order',
name='valid_if_pending',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='orderposition',
name='blocked',
field=models.JSONField(null=True),
),
migrations.AddField(
model_name='orderposition',
name='valid_from',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='orderposition',
name='valid_until',
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name='BlockedTicketSecret',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('secret', models.TextField(db_index=True)),
('blocked', models.BooleanField()),
('updated', models.DateTimeField(auto_now=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocked_secrets', to='pretixbase.event')),
('position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocked_secrets', to='pretixbase.orderposition')),
],
options={
'unique_together': {('event', 'secret')},
} if 'mysql' not in settings.DATABASES['default']['ENGINE'] else {}
),
]

View File

@@ -106,10 +106,14 @@ class CheckinList(LoggedModel):
order__event=self.event,
)
if not ignore_status:
qs = qs.filter(
canceled=False,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID],
)
if self.include_pending:
qs = qs.filter(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING], canceled=False)
else:
qs = qs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True),
canceled=False
)
if self.subevent_id:
qs = qs.filter(subevent_id=self.subevent_id)
@@ -327,6 +331,8 @@ class Checkin(models.Model):
REASON_ALREADY_REDEEMED = 'already_redeemed'
REASON_AMBIGUOUS = 'ambiguous'
REASON_ERROR = 'error'
REASON_BLOCKED = 'blocked'
REASON_INVALID_TIME = 'invalid_time'
REASONS = (
(REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')),
@@ -338,6 +344,8 @@ class Checkin(models.Model):
(REASON_PRODUCT, _('Ticket type not allowed here')),
(REASON_AMBIGUOUS, _('Ticket code is ambiguous on list')),
(REASON_ERROR, _('Server error')),
(REASON_BLOCKED, _('Ticket blocked')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
)
successful = models.BooleanField(

View File

@@ -870,6 +870,13 @@ class ItemVariation(models.Model):
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
'that unlocks this variation.')
)
checkin_attention = models.BooleanField(
verbose_name=_('Requires special attention'),
default=False,
help_text=_('If you set this, the check-in app will show a visible warning that this ticket requires special '
'attention. You can use this for example for student tickets to indicate to the person at '
'check-in that the student ID card still needs to be checked.')
)
objects = ScopedManager(organizer='item__event__organizer')

View File

@@ -122,6 +122,9 @@ class Order(LockModel, LoggedModel):
* ``STATUS_EXPIRED``
* ``STATUS_CANCELED``
:param valid_if_pending: Treat this order like a paid order for most purposes (such as check-in), even if it is
still unpaid.
:type valid_if_pending: bool
:param event: The event this order belongs to
:type event: Event
:param customer: The customer this order belongs to
@@ -177,6 +180,9 @@ class Order(LockModel, LoggedModel):
verbose_name=_("Status"),
db_index=True
)
valid_if_pending = models.BooleanField(
default=False,
)
testmode = models.BooleanField(default=False)
event = models.ForeignKey(
Event,
@@ -645,7 +651,7 @@ class Order(LockModel, LoggedModel):
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
).select_related('item').prefetch_related('issued_gift_cards')
)
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
cancelable = all([op.item.allow_cancel and not op.has_checkin and not op.blocked for op in positions])
if not cancelable or not positions:
return False
for op in positions:
@@ -848,7 +854,7 @@ class Order(LockModel, LoggedModel):
) and (
self.status == Order.STATUS_PAID
or (
(self.event.settings.ticket_download_pending or self.total == Decimal("0.00")) and
(self.valid_if_pending or self.event.settings.ticket_download_pending) and
self.status == Order.STATUS_PENDING and
not self.require_approval
)
@@ -2201,17 +2207,31 @@ class OrderPosition(AbstractPosition):
:type canceled: bool
:param pseudonymization_id: The QR code content for lead scanning
:type pseudonymization_id: str
:param blocked: A list of reasons why this order position is blocked. Blocked positions can't be used for check-in and
other purposes. Each entry should be a short string that can be translated into a human-readable
description by a plugin. If the position is not blocked, the value must be ``None``, not an empty
list.
:type blocked: list
:param valid_from: The ticket will not be considered valid before this date. If the value is ``None``, no check on
ticket level is made.
:type valid_from: datetime
:param valid_until: The ticket will not be considered valid after this date. If the value is ``None``, no check on
ticket level is made.
:type valid_until: datetime
"""
positionid = models.PositiveIntegerField(default=1)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='all_positions',
on_delete=models.PROTECT
)
voucher_budget_use = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
@@ -2225,6 +2245,7 @@ class OrderPosition(AbstractPosition):
max_digits=10, decimal_places=2,
verbose_name=_('Tax value')
)
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
pseudonymization_id = models.CharField(
@@ -2232,8 +2253,21 @@ class OrderPosition(AbstractPosition):
unique=True,
db_index=True
)
canceled = models.BooleanField(default=False)
blocked = models.JSONField(null=True, blank=True)
valid_from = models.DateTimeField(
verbose_name=_("Valid from"),
null=True,
blank=True,
)
valid_until = models.DateTimeField(
verbose_name=_("Valid until"),
null=True,
blank=True,
)
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
@@ -2264,6 +2298,12 @@ class OrderPosition(AbstractPosition):
def sort_key(self):
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid
@cached_property
def require_checkin_attention(self):
if self.order.checkin_attention or self.item.checkin_attention or (self.variation_id and self.variation.checkin_attention):
return True
return False
@property
def checkins(self):
"""
@@ -2276,11 +2316,30 @@ class OrderPosition(AbstractPosition):
def generate_ticket(self):
if self.item.generate_tickets is not None:
return self.item.generate_tickets
if self.blocked:
return False
return (
(self.order.event.settings.ticket_download_addons or not self.addon_to_id) and
(self.event.settings.ticket_download_nonadm or self.item.admission)
)
@property
def blocked_reasons(self):
from ..signals import orderposition_blocked_display
if not self.blocked:
return []
reasons = {}
for b in self.blocked:
for recv, response in orderposition_blocked_display.send(self.event, orderposition=self, block_name=b):
if response:
reasons[b] = response
break
else:
reasons[b] = b
return reasons
@classmethod
def transform_cart_positions(cls, cp: List, order) -> list:
from . import Voucher
@@ -2363,6 +2422,11 @@ class OrderPosition(AbstractPosition):
event=self.order.event, position=self, force_invalidate=True, save=False
)
if not self.blocked:
self.blocked = None
elif not isinstance(self.blocked, list) or any(not isinstance(b, str) for b in self.blocked):
raise TypeError("blocked needs to be a list of strings")
if not self.pseudonymization_id:
self.assign_pseudonymization_id()
@@ -2941,6 +3005,26 @@ class RevokedTicketSecret(models.Model):
created = models.DateTimeField(auto_now_add=True)
class BlockedTicketSecret(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='blocked_secrets')
position = models.ForeignKey(
OrderPosition,
on_delete=models.SET_NULL,
related_name='blocked_secrets',
null=True,
)
secret = models.TextField(db_index=True)
blocked = models.BooleanField()
updated = models.DateTimeField(auto_now=True)
class Meta:
if 'mysql' not in settings.DATABASES['default']['ENGINE']:
# MySQL does not support indexes on TextField(). Django knows this and just ignores db_index, but it will
# not silently ignore the UNIQUE index, causing this table to fail. I'm so glad we're deprecating MySQL
# in a few months, so we'll just live without an unique index until then.
unique_together = (('event', 'secret'),)
@receiver(post_delete, sender=CachedTicket)
def cachedticket_delete(sender, instance, **kwargs):
if instance.file:

View File

@@ -646,6 +646,52 @@ class Locale(ImportColumn):
order.locale = value
class ValidFrom(ImportColumn):
identifier = 'valid_from'
verbose_name = gettext_lazy('Valid from')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = self.event.timezone.localize(d)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_from = value
class ValidUntil(ImportColumn):
identifier = 'valid_until'
verbose_name = gettext_lazy('Valid until')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = self.event.timezone.localize(d)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_until = value
class Saleschannel(ImportColumn):
identifier = 'sales_channel'
verbose_name = gettext_lazy('Sales channel')
@@ -816,7 +862,9 @@ def get_all_columns(event):
Locale(event),
Saleschannel(event),
SeatColumn(event),
Comment(event)
Comment(event),
ValidFrom(event),
ValidUntil(event),
]
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
default.append(QuestionColumn(event, q))

View File

@@ -8,4 +8,6 @@ message Ticket {
int64 item = 2;
int64 variation = 3;
int64 subevent = 4;
optional int64 validFromUnixTime = 5;
optional int64 validUntilUnixTime = 6;
}

View File

@@ -37,7 +37,7 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42\x33\n#eu.pretix.libpretixsync.crypto.sig1B\x0cTicketProtosb\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11pretix_sig1.proto\"\xb7\x01\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x12\x1e\n\x11validFromUnixTime\x18\x05 \x01(\x03H\x00\x88\x01\x01\x12\x1f\n\x12validUntilUnixTime\x18\x06 \x01(\x03H\x01\x88\x01\x01\x42\x14\n\x12_validFromUnixTimeB\x15\n\x13_validUntilUnixTimeB3\n#eu.pretix.libpretixsync.crypto.sig1B\x0cTicketProtosb\x06proto3')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'pretix_sig1_pb2', globals())
@@ -45,6 +45,6 @@ if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n#eu.pretix.libpretixsync.crypto.sig1B\014TicketProtos'
_TICKET._serialized_start=21
_TICKET._serialized_end=94
_TICKET._serialized_start=22
_TICKET._serialized_end=205
# @@protoc_insertion_point(module_scope)

View File

@@ -23,6 +23,7 @@ import base64
import inspect
import struct
from collections import namedtuple
from datetime import datetime
from typing import Optional
from cryptography.hazmat.backends.openssl.backend import Backend
@@ -85,10 +86,12 @@ class BaseTicketSecretGenerator:
return None
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
attendee_name: str = None, current_secret: str = None, force_invalidate=False) -> str:
attendee_name: str = None, valid_from: datetime = None, valid_until: datetime = None,
current_secret: str = None, force_invalidate=False) -> str:
"""
Generate a new secret for a ticket with product ``item``, variation ``variation``, subevent ``subevent``,
attendee name ``attendee_name`` (can be ``None``) and the current secret ``current_secret`` (if any).
attendee name ``attendee_name`` (can be ``None``), earliest validity ``valid_from``, lastest validity
``valid_until``, and the current secret ``current_secret`` (if any).
The result must be a string that should only contain the characters ``A-Za-z0-9+/=``.
@@ -118,7 +121,8 @@ class RandomTicketSecretGenerator(BaseTicketSecretGenerator):
use_revocation_list = False
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
attendee_name: str = None, current_secret: str = None, force_invalidate=False):
attendee_name: str = None, valid_from: datetime = None, valid_until: datetime = None,
current_secret: str = None, force_invalidate=False) -> str:
if current_secret and not force_invalidate:
return current_secret
return get_random_string(
@@ -202,15 +206,23 @@ class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
opaque_id = ticket.seed
return self.ParsedSecret(item=item, subevent=subevent, variation=variation, opaque_id=opaque_id, attendee_name=None)
def _encode_time(self, t):
if t is None:
return 0
return int(t.timestamp())
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
current_secret: str = None, force_invalidate=False):
attendee_name: str = None, valid_from: datetime = None, valid_until: datetime = None,
current_secret: str = None, force_invalidate=False) -> str:
if current_secret and not force_invalidate:
ticket = self._parse(current_secret)
if ticket:
unchanged = (
ticket.item == item.pk and
ticket.variation == (variation.pk if variation else 0) and
ticket.subevent == (subevent.pk if subevent else 0)
ticket.subevent == (subevent.pk if subevent else 0) and
ticket.validFromUnixTime == self._encode_time(valid_from) and
ticket.validUntilUnixTime == self._encode_time(valid_until)
)
if unchanged:
return current_secret
@@ -220,6 +232,8 @@ class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
t.item = item.pk
t.variation = variation.pk if variation else 0
t.subevent = subevent.pk if subevent else 0
t.validFromUnixTime = self._encode_time(valid_from)
t.validUntilUnixTime = self._encode_time(valid_until)
payload = t.SerializeToString()
result = base64.b64encode(self._sign_payload(payload)).decode()[::-1]
return result
@@ -236,8 +250,13 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
force_invalidate = True
kwargs = {}
if 'attendee_name' in inspect.signature(gen.generate_secret).parameters:
params = inspect.signature(gen.generate_secret).parameters
if 'attendee_name' in params:
kwargs['attendee_name'] = position.attendee_name
if 'valid_from' in params:
kwargs['valid_from'] = position.valid_from
if 'valid_until' in params:
kwargs['valid_until'] = position.valid_until
secret = gen.generate_secret(
item=position.item,
variation=position.variation,

View File

@@ -23,7 +23,7 @@ import logging
from decimal import Decimal
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
from django.utils.translation import gettext
from i18nfield.strings import LazyI18nString
@@ -122,9 +122,13 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
orders_to_cancel = event.orders.annotate(pcnt=Subquery(s, output_field=IntegerField())).filter(
has_blocked = OrderPosition.objects.filter(order_id=OuterRef('pk'), blocked__isnull=False)
orders_to_cancel = event.orders.annotate(
pcnt=Subquery(s, output_field=IntegerField()),
has_blocked=Exists(has_blocked),
).filter(
status__in=[Order.STATUS_PAID, Order.STATUS_PENDING, Order.STATUS_EXPIRED],
pcnt__gt=0
pcnt__gt=0,
).all()
if subevent or subevents_from:
@@ -146,13 +150,14 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=True
Q(has_subevent=True, has_other_subevent=True) |
Q(has_subevent=True, has_blocked=True)
)
orders_to_cancel = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=False
has_subevent=True, has_other_subevent=False, has_blocked=False
)
for se in subevents:
@@ -167,7 +172,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
else:
subevents = None
subevent_ids = set()
orders_to_change = event.orders.none()
orders_to_change = orders_to_cancel.filter(has_blocked=True)
orders_to_cancel = orders_to_cancel.filter(has_blocked=False)
event.log_action(
'pretix.event.canceled', user=user,
)
@@ -247,7 +253,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
ocm = OrderChangeManager(o, user=user, notify=False)
for p in o.positions.all():
if p.subevent_id in subevent_ids:
if (not event.has_subevents or p.subevent_id in subevent_ids) and not p.blocked:
total += p.price
ocm.cancel(p)
positions.append(p)

View File

@@ -721,6 +721,34 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'canceled' if canceled_supported else 'unpaid'
)
if op.blocked:
raise CheckInError(
_('This ticket has been blocked.'), # todo provide reason
'blocked'
)
if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > now():
raise CheckInError(
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
)
if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < now():
raise CheckInError(
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
)
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
checkin_questions = list(
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
@@ -751,8 +779,13 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
_('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
elif op.order.status != Order.STATUS_PAID and not force and op.order.require_approval:
raise CheckInError(
_('This order is not yet approved.'),
'unpaid'
)
elif op.order.status != Order.STATUS_PAID and not force and not op.order.valid_if_pending and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
):
raise CheckInError(
_('This order is not marked as paid.'),

View File

@@ -68,7 +68,8 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
@@ -250,7 +251,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
generate_invoice(order)
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
def extend_order(order: Order, new_date: datetime, force: bool=False, valid_if_pending: bool=None, user: User=None, auth=None):
"""
Extends the deadline of an order. If the order is already expired, the quota will be checked to
see if this is actually still possible. If ``force`` is set to ``True``, the result of this check
@@ -261,19 +262,35 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic
def change(was_expired=True):
old_date = order.expires
order.expires = new_date
if was_expired:
order.status = Order.STATUS_PENDING
order.save(update_fields=['expires'] + (['status'] if was_expired else []))
order.log_action(
'pretix.event.order.expirychanged',
user=user,
auth=auth,
data={
'expires': order.expires,
'state_change': was_expired
}
)
if valid_if_pending is not None and valid_if_pending != order.valid_if_pending:
order.valid_if_pending = valid_if_pending
if valid_if_pending:
order.log_action(
'pretix.event.order.valid_if_pending.set',
user=user,
auth=auth,
)
else:
order.log_action(
'pretix.event.order.valid_if_pending.unset',
user=user,
auth=auth,
)
order.save(update_fields=['valid_if_pending', 'expires'] + (['status'] if was_expired else []))
if old_date != new_date:
order.log_action(
'pretix.event.order.expirychanged',
user=user,
auth=auth,
data={
'expires': order.expires,
'state_change': was_expired
}
)
if was_expired:
num_invoices = order.invoices.filter(is_cancellation=False).count()
@@ -1203,6 +1220,7 @@ def expire_orders(sender, **kwargs):
qs = Order.objects.filter(
expires__lt=now(),
status=Order.STATUS_PENDING,
valid_if_pending=False,
require_approval=False
).exclude(
Exists(
@@ -1247,11 +1265,17 @@ def send_expiry_warnings(sender, **kwargs):
with language(o.locale, settings.region):
o.expiry_reminder_sent = True
o.save(update_fields=['expiry_reminder_sent'])
email_template = settings.mail_text_order_expire_warning
email_context = get_email_context(event=o.event, order=o)
if settings.payment_term_expire_automatically:
can_autoexpire = (
settings.payment_term_expire_automatically and
not o.valid_if_pending and
not o.fees.filter(fee_type=OrderFee.FEE_TYPE_CANCELLATION).exists()
)
if can_autoexpire:
email_template = settings.mail_text_order_expire_warning
email_subject = settings.mail_subject_order_expire_warning
else:
email_template = settings.mail_text_order_pending_warning
email_subject = settings.mail_subject_order_pending_warning
try:
@@ -1310,8 +1334,7 @@ def send_download_reminders(sender, **kwargs):
positions = o.positions.select_related('item')
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or not \
o.event.settings.ticket_download_pending:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
continue
send = False
for p in positions:
@@ -1405,12 +1428,17 @@ class OrderChangeManager:
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff'))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
ChangeValidFromOperation = namedtuple('ChangeValidFromOperation', ('position', 'valid_from'))
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name'))
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
self.order = order
@@ -1514,6 +1542,18 @@ class OrderChangeManager:
def regenerate_secret(self, position: OrderPosition):
self._operations.append(self.RegenerateSecretOperation(position))
def change_valid_from(self, position: OrderPosition, new_value: datetime):
self._operations.append(self.ChangeValidFromOperation(position, new_value))
def change_valid_until(self, position: OrderPosition, new_value: datetime):
self._operations.append(self.ChangeValidUntilOperation(position, new_value))
def add_block(self, position: OrderPosition, block_name: str):
self._operations.append(self.AddBlockOperation(position, block_name))
def remove_block(self, position: OrderPosition, block_name: str):
self._operations.append(self.RemoveBlockOperation(position, block_name))
def change_price(self, position: OrderPosition, price: Decimal):
tax_rule = self._current_tax_rules().get(position.pk, position.tax_rule) or TaxRule.zero()
price = tax_rule.tax(price, base_price_is='gross')
@@ -1595,7 +1635,8 @@ class OrderChangeManager:
self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None):
if isinstance(seat, str):
if not seat:
seat = None
@@ -1649,7 +1690,8 @@ class OrderChangeManager:
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership))
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until))
def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1961,6 +2003,7 @@ class OrderChangeManager:
def _perform_operations(self):
nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1
split_positions = []
secret_dirty = set()
for op in self._operations:
if isinstance(op, self.ItemOperation):
@@ -1986,9 +2029,7 @@ class OrderChangeManager:
else:
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
secret_dirty.add(op.position)
op.position.save()
elif isinstance(op, self.MembershipOperation):
self.order.log_action('pretix.event.order.changed.membership', user=self.user, auth=self.auth, data={
@@ -2009,9 +2050,7 @@ class OrderChangeManager:
'new_seat_id': op.seat.pk if op.seat else None,
})
op.position.seat = op.seat
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
secret_dirty.add(op.position)
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
@@ -2023,9 +2062,7 @@ class OrderChangeManager:
'new_price': op.position.price
})
op.position.subevent = op.subevent
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
secret_dirty.add(op.position)
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
@@ -2131,8 +2168,10 @@ class OrderChangeManager:
opa.canceled = True
if opa.voucher:
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
if opa in secret_dirty:
secret_dirty.remove(opa)
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
event=self.event, position=opa, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
)
opa.save(update_fields=['canceled', 'secret'])
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
@@ -2149,6 +2188,8 @@ class OrderChangeManager:
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
)
if op.position in secret_dirty:
secret_dirty.remove(op.position)
op.position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create(
@@ -2156,7 +2197,7 @@ class OrderChangeManager:
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -2169,6 +2210,8 @@ class OrderChangeManager:
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
})
elif isinstance(op, self.SplitOperation):
split_positions.append(op.position)
@@ -2176,12 +2219,79 @@ class OrderChangeManager:
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=True, save=True
)
if op.position in secret_dirty:
secret_dirty.remove(op.position)
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.order.pk})
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
})
elif isinstance(op, self.ChangeValidFromOperation):
self.order.log_action('pretix.event.order.changed.valid_from', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'new_value': op.valid_from.isoformat() if op.valid_from else None,
'old_value': op.position.valid_from.isoformat() if op.position.valid_from else None,
})
op.position.valid_from = op.valid_from
op.position.save(update_fields=['valid_from'])
secret_dirty.add(op.position)
elif isinstance(op, self.ChangeValidUntilOperation):
self.order.log_action('pretix.event.order.changed.valid_until', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'new_value': op.valid_until.isoformat() if op.valid_until else None,
'old_value': op.position.valid_until.isoformat() if op.position.valid_until else None,
})
op.position.valid_until = op.valid_until
op.position.save(update_fields=['valid_until'])
secret_dirty.add(op.position)
elif isinstance(op, self.AddBlockOperation):
self.order.log_action('pretix.event.order.changed.add_block', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'block_name': op.block_name,
})
if op.position.blocked:
if op.block_name not in op.position.blocked:
op.position.blocked = op.position.blocked + [op.block_name]
else:
op.position.blocked = [op.block_name]
op.position.save(update_fields=['blocked'])
if op.position.blocked:
op.position.blocked_secrets.update_or_create(
event=self.event,
secret=op.position.secret,
defaults={
'blocked': True,
'updated': now(),
}
)
elif isinstance(op, self.RemoveBlockOperation):
self.order.log_action('pretix.event.order.changed.remove_block', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'block_name': op.block_name,
})
if op.position.blocked and op.block_name in op.position.blocked:
op.position.blocked = [b for b in op.position.blocked if b != op.block_name]
if not op.position.blocked:
op.position.blocked = None
op.position.save(update_fields=['blocked'])
if not op.position.blocked:
try:
bs = op.position.blocked_secrets.get(secret=op.position.secret)
bs.blocked = False
bs.save()
except BlockedTicketSecret.DoesNotExist:
pass
# todo: revoke list handling
for p in secret_dirty:
assign_ticket_secret(
event=self.event, position=p, force_invalidate=False, save=True
)
if split_positions:
self.split_order = self._create_split_order(split_positions)

View File

@@ -2021,6 +2021,19 @@ your payment before {expire_date}.
You can view the payment information and the status of your order at
{url}
Best regards,
Your {event} team"""))
},
'mail_text_order_pending_warning': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
we did not yet receive a full payment for your order for {event}.
Please keep in mind that you are required to pay before {expire_date}.
You can view the payment information and the status of your order at
{url}
Best regards,
Your {event} team"""))
},

View File

@@ -538,6 +538,19 @@ keyword argument will contain the event to **copy from**. The keyword arguments
in the new event of the respective types.
"""
orderposition_blocked_display = EventPluginSignal()
"""
Arguments: ``orderposition``, ``block_name``
To display the reason for a blocked ticket to a backend user,
``pretix.base.signals.orderposition_block_display`` will be sent out.
The first received response that is not ``None`` will be used to display the block
to the user. The receivers are expected to return plain text.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
item_copy_data = EventPluginSignal()
"""
Arguments: ``source``, ``target``