forked from CGM_Public/pretix_original
New check-in features (#3022)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
55
src/pretix/base/migrations/0230_auto_20230208_0939.py
Normal file
55
src/pretix/base/migrations/0230_auto_20230208_0939.py
Normal 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 {}
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -8,4 +8,6 @@ message Ticket {
|
||||
int64 item = 2;
|
||||
int64 variation = 3;
|
||||
int64 subevent = 4;
|
||||
optional int64 validFromUnixTime = 5;
|
||||
optional int64 validUntilUnixTime = 6;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""))
|
||||
},
|
||||
|
||||
@@ -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``
|
||||
|
||||
Reference in New Issue
Block a user