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

@@ -74,6 +74,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlistpos-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:order-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
@@ -108,6 +109,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:checkinlist-failed_checkins'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
@@ -142,6 +144,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlistpos-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
@@ -208,6 +211,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:stripeterminal.paymentintent'),
('PUT', 'plugins:pretix_posbackend:file.upload'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:event.settings'),
('GET', 'plugins:pretix_seating:event.event'),
('GET', 'plugins:pretix_seating:event.event.subevent'),

View File

@@ -60,8 +60,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
@@ -84,8 +84,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):

View File

@@ -52,7 +52,8 @@ from pretix.base.models import (
SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
RevokedTicketSecret,
)
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
@@ -300,7 +301,9 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
class OrderDownloadsField(serializers.Field):
def to_representation(self, instance: Order):
if instance.status != Order.STATUS_PAID:
if instance.status != Order.STATUS_PENDING or instance.require_approval or not instance.event.settings.ticket_download_pending:
if instance.status != Order.STATUS_PENDING or instance.require_approval or (
not instance.valid_if_pending and not instance.event.settings.ticket_download_pending
):
return []
request = self.context['request']
@@ -324,7 +327,9 @@ class OrderDownloadsField(serializers.Field):
class PositionDownloadsField(serializers.Field):
def to_representation(self, instance: OrderPosition):
if instance.order.status != Order.STATUS_PAID:
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or (
not instance.order.valid_if_pending and not instance.order.event.settings.ticket_download_pending
):
return []
if not instance.generate_ticket:
return []
@@ -437,11 +442,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
'valid_from', 'valid_until', 'blocked')
read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
'seat', 'canceled', 'discount',
'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked'
)
def __init__(self, *args, **kwargs):
@@ -463,7 +469,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition):
return instance.order.checkin_attention or instance.item.checkin_attention
return instance.require_checkin_attention
class AttendeeNameField(serializers.Field):
@@ -508,7 +514,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
'order__status')
'order__status', 'valid_from', 'valid_until', 'blocked')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -622,7 +628,7 @@ class OrderSerializer(I18nAwareModelSerializer):
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer'
'url', 'customer', 'valid_if_pending'
)
read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
@@ -677,7 +683,8 @@ class OrderSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = ['comment', 'custom_followup_at', 'checkin_attention', 'email', 'locale', 'phone']
update_fields = ['comment', 'custom_followup_at', 'checkin_attention', 'email', 'locale', 'phone',
'valid_if_pending']
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
@@ -783,7 +790,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -941,7 +948,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
model = Order
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval')
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval',
'valid_if_pending')
def validate_payment_provider(self, pp):
if pp is None:
@@ -1532,3 +1540,10 @@ class RevokedTicketSecretSerializer(I18nAwareModelSerializer):
class Meta:
model = RevokedTicketSecret
fields = ('id', 'secret', 'created')
class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
class Meta:
model = BlockedTicketSecret
fields = ('id', 'secret', 'updated', 'blocked')

View File

@@ -24,6 +24,7 @@ import os
import pycountry
from django.core.files import File
from django.core.validators import RegexValidator
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -53,7 +54,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
model = OrderPosition
fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat')
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'valid_from', 'valid_until')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -89,6 +90,8 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
addon_to=validated_data.get('addon_to'),
subevent=validated_data.get('subevent'),
seat=validated_data.get('seat'),
valid_from=validated_data.get('valid_from'),
valid_until=validated_data.get('valid_until'),
)
if self.context.get('commit', True):
ocm.commit()
@@ -198,7 +201,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderPosition
fields = (
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule',
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until'
)
def __init__(self, *args, **kwargs):
@@ -264,6 +267,8 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
price = validated_data.get('price', instance.price)
seat = validated_data.get('seat', current_seat)
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
valid_from = validated_data.get('valid_from', instance.valid_from)
valid_until = validated_data.get('valid_until', instance.valid_until)
change_item = None
if item != instance.item or variation != instance.variation:
@@ -290,6 +295,12 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
if tax_rule != instance.tax_rule:
ocm.change_tax_rule(instance, tax_rule)
if valid_from != instance.valid_from:
ocm.change_valid_from(instance, valid_from)
if valid_until != instance.valid_until:
ocm.change_valid_until(instance, valid_until)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
@@ -423,3 +434,7 @@ class OrderChangeOperationSerializer(serializers.Serializer):
seen_positions.add(d['fee'])
return data
class BlockNameSerializer(serializers.Serializer):
name = serializers.CharField(validators=[RegexValidator('^(admin|api:[a-zA-Z0-9._]+)$')])

View File

@@ -81,6 +81,7 @@ event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')
event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)

View File

@@ -273,7 +273,13 @@ with scopes_disabled():
def check_rules_qs(self, queryset, name, value):
if not self.checkinlist.rules:
return queryset
return queryset.filter(SQLLogic(self.checkinlist).apply(self.checkinlist.rules))
return queryset.filter(
SQLLogic(self.checkinlist).apply(self.checkinlist.rules)
).filter(
Q(valid_from__isnull=True) | Q(valid_from__lte=now()),
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
blocked__isnull=True,
)
def _handle_file_upload(data, user, auth):
@@ -325,7 +331,13 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
if checkinlist.subevent:
list_q &= Q(subevent=checkinlist.subevent)
if not ignore_status:
list_q &= Q(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if checkinlist.include_pending else [Order.STATUS_PAID])
if checkinlist.include_pending:
list_q &= Q(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
list_q &= Q(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
if not checkinlist.all_products and not ignore_products:
list_q &= Q(item__in=checkinlist.limit_products.values_list('id', flat=True))
lists_qs.append(list_q)
@@ -582,7 +594,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'status': 'error',
'reason': Checkin.REASON_AMBIGUOUS,
'reason_explanation': None,
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
@@ -627,7 +639,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
except RequiredQuestionsError as e:
return Response({
'status': 'incomplete',
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'questions': [
QuestionSerializer(q).data for q in e.questions
@@ -656,14 +668,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'status': 'error',
'reason': e.code,
'reason_explanation': e.reason,
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
else:
return Response({
'status': 'ok',
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=201)

View File

@@ -49,15 +49,15 @@ from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
OrderPaymentSerializer, OrderPositionSerializer,
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
PriceCalcSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
OrderPaymentCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
RevokedTicketSecretSerializer, SimulatedOrderSerializer,
)
from pretix.api.serializers.orderchange import (
OrderChangeOperationSerializer, OrderFeeChangeSerializer,
OrderPositionChangeSerializer,
BlockNameSerializer, OrderChangeOperationSerializer,
OrderFeeChangeSerializer, OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
@@ -70,7 +70,9 @@ from pretix.base.models import (
OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, TeamAPIToken,
generate_secret,
)
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
from pretix.base.models.orders import (
BlockedTicketSecret, QuestionAnswer, RevokedTicketSecret,
)
from pretix.base.payment import PaymentException
from pretix.base.pdf import get_images
from pretix.base.secrets import assign_ticket_secret
@@ -287,7 +289,7 @@ class OrderViewSet(viewsets.ModelViewSet):
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
if order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
if order.status == Order.STATUS_PENDING and not (order.valid_if_pending or request.event.settings.ticket_download_pending):
raise PermissionDenied("Downloads are not available for pending orders.")
ct = CachedCombinedTicket.objects.filter(
@@ -763,6 +765,16 @@ class OrderViewSet(viewsets.ModelViewSet):
}
)
if 'valid_if_pending' in self.request.data and serializer.instance.valid_if_pending != self.request.data.get('valid_if_pending'):
serializer.instance.log_action(
'pretix.event.order.valid_if_pending',
user=self.request.user,
auth=self.request.auth,
data={
'new_value': self.request.data.get('valid_if_pending')
}
)
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
serializer.instance.email_known_to_work = False
serializer.instance.log_action(
@@ -1183,7 +1195,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
if pos.order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
if pos.order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
if pos.order.status == Order.STATUS_PENDING and not (pos.order.valid_if_pending or request.event.settings.ticket_download_pending):
raise PermissionDenied("Downloads are not available for pending orders.")
if not pos.generate_ticket:
raise PermissionDenied("Downloads are not enabled for this product.")
@@ -1225,6 +1237,54 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def add_block(self, request, **kwargs):
serializer = BlockNameSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.add_block(instance, serializer.validated_data['name'])
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def remove_block(self, request, **kwargs):
serializer = BlockNameSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.remove_block(instance, serializer.validated_data['name'])
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
def perform_destroy(self, instance):
try:
ocm = OrderChangeManager(
@@ -1785,3 +1845,25 @@ class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return RevokedTicketSecret.objects.filter(event=self.request.event)
with scopes_disabled():
class BlockedSecretFilter(FilterSet):
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
class Meta:
model = BlockedTicketSecret
fields = ['blocked']
class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = BlockedTicketSecretSerializer
queryset = BlockedTicketSecret.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('-updated', '-pk')
filterset_class = BlockedSecretFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_queryset(self):
return BlockedTicketSecret.objects.filter(event=self.request.event)

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``

View File

@@ -1052,7 +1052,7 @@ class MailSettingsForm(SettingsForm):
"value is 0, the mail will never be sent.")
)
mail_text_order_expire_warning = I18nFormField(
label=_("Text"),
label=_("Text (if order will expire automatically)"),
required=False,
widget=I18nTextarea,
)
@@ -1061,6 +1061,11 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nTextInput,
)
mail_text_order_pending_warning = I18nFormField(
label=_("Text (if order will not expire automatically)"),
required=False,
widget=I18nTextarea,
)
mail_subject_order_pending_warning = I18nFormField(
label=_("Subject (if order will not expire automatically)"),
required=False,

View File

@@ -210,6 +210,7 @@ class OrderFilterForm(FilterForm):
('', _('All orders')),
(_('Valid orders'), (
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
(Order.STATUS_PAID + 'v', _('Paid or confirmed')),
(Order.STATUS_PENDING, _('Pending')),
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
)),
@@ -296,6 +297,8 @@ class OrderFilterForm(FilterForm):
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'ne':
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
elif s == 'pv':
qs = qs.filter(Q(status=Order.STATUS_PAID) | Q(status=Order.STATUS_PENDING, valid_if_pending=True))
elif s in ('p', 'n', 'e', 'c', 'r'):
qs = qs.filter(status=s)
elif s == 'overpaid':

View File

@@ -387,7 +387,6 @@ class ItemCreateForm(I18nModelForm):
'show_quota_left',
'hidden_if_available',
'require_bundling',
'checkin_attention',
'require_membership',
'grant_membership_type',
'grant_membership_duration_like_event',
@@ -800,6 +799,7 @@ class ItemVariationForm(I18nModelForm):
'require_membership',
'require_membership_hidden',
'require_membership_types',
'checkin_attention',
'available_from',
'available_until',
'sales_channels',

View File

@@ -68,12 +68,6 @@ from pretix.helpers.money import change_decimal_field
class ExtendForm(I18nModelForm):
quota_ignore = forms.BooleanField(
label=_('Overbook quota'),
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
'and you having sold more tickets than you planned!'),
required=False
)
expires = forms.DateField(
label=_("Expiration date"),
widget=forms.DateInput(attrs={
@@ -81,16 +75,35 @@ class ExtendForm(I18nModelForm):
'data-is-payment-date': 'true'
}),
)
valid_if_pending = forms.BooleanField(
label=_('Confirm order regardless of payment'),
help_text=_('If you check this box, this order will behave like a paid order for most purposes, even though it '
'is not yet paid. This means that the customer can already download and use tickets regardless '
'of your event settings, and the order might be treated as paid by some plugins. If you check '
'this, this order will not be marked as "expired" automatically if the payment deadline arrives, '
'since we expect that you want to collect the amount somehow and not auto-cancel the order.'),
required=False
)
quota_ignore = forms.BooleanField(
label=_('Overbook quota'),
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
'and you having sold more tickets than you planned!'),
required=False
)
class Meta:
model = Order
fields = []
def __init__(self, *args, **kwargs):
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('valid_if_pending', kwargs['instance'].valid_if_pending)
kwargs['initial'].setdefault('expires', kwargs['instance'].expires)
super().__init__(*args, **kwargs)
if self.instance.status == Order.STATUS_PENDING or self.instance._is_still_available(now(),
count_waitinglist=False)\
is True:
if (
self.instance.status == Order.STATUS_PENDING or
self.instance._is_still_available(now(), count_waitinglist=False) is True
):
del self.fields['quota_ignore']
def clean(self):
@@ -435,6 +448,20 @@ class OrderPositionChangeForm(forms.Form):
localize=True,
label=_('New price (gross)')
)
blocked = forms.BooleanField(
required=False,
label=_('Ticket is blocked')
)
valid_from = SplitDateTimeField(
required=False,
widget=SplitDateTimePickerWidget,
label=_('Validity start')
)
valid_until = SplitDateTimeField(
required=False,
widget=SplitDateTimePickerWidget,
label=_('Validity end')
)
used_membership = forms.ChoiceField(
required=False,
)
@@ -466,6 +493,9 @@ class OrderPositionChangeForm(forms.Form):
initial = kwargs.get('initial', {})
initial['price'] = instance.price
initial['blocked'] = instance.blocked and "admin" in instance.blocked
initial['valid_from'] = instance.valid_from
initial['valid_until'] = instance.valid_until
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
@@ -756,7 +786,8 @@ class EventCancelForm(forms.Form):
label=_('Automatically refund money if possible'),
initial=True,
required=False,
help_text=_('Only available for payment method that support automatic refunds.')
help_text=_('Only available for payment method that support automatic refunds. Tickets that have been blocked '
'(manually or by a plugin) are not auto-canceled and you will need to deal with them manually.')
)
manual_refund = forms.BooleanField(
label=_('Create refund in the manual refund to-do list'),

View File

@@ -52,7 +52,7 @@ from pretix.base.models import (
Checkin, CheckinList, Event, ItemVariation, LogEntry, OrderPosition,
TaxRule,
)
from pretix.base.signals import logentry_display
from pretix.base.signals import logentry_display, orderposition_blocked_display
from pretix.base.templatetags.money import money_filter
OVERVIEW_BANLIST = [
@@ -162,6 +162,25 @@ def _display_order_changed(event: Event, logentry: LogEntry):
return text + ' ' + _('A new secret has been generated for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.valid_from':
return text + ' ' + _('The validity start date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else ''
)
elif logentry.action_type == 'pretix.event.order.changed.valid_until':
return text + ' ' + _('The validity end date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
)
elif logentry.action_type == 'pretix.event.order.changed.add_block':
return text + ' ' + _('A block has been added for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.remove_block':
return text + ' ' + _('A block has been removed for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.split':
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
@@ -351,6 +370,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'),
'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'),
'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'),
'pretix.event.order.expired': _('The order has been marked as expired.'),
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'),
@@ -376,6 +397,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'),
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
'toggled.'),
'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'unpaid has been toggled.'),
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
'pretix.event.order.email.error': _('Sending of an email has failed.'),
@@ -647,3 +670,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type == 'pretix.control.auth.user.impersonate_stopped':
return str(_('You stopped impersonating {}.')).format(data['other_email'])
@receiver(signal=orderposition_blocked_display, dispatch_uid="pretixcontrol_orderposition_blocked_display")
def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, block_name, **kwargs):
if block_name == 'admin':
return _('Blocked manually')
elif block_name.startswith('api:'):
return _('Blocked because of an API integration')

View File

@@ -103,7 +103,7 @@
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_subject_order_changed,mail_text_order_changed" %}
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_subject_order_expire_warning,mail_subject_order_pending_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_subject_order_expire_warning,mail_text_order_expire_warning,mail_subject_order_pending_warning,mail_text_order_pending_warning" exclude="mail_days_order_expire_warning" %}
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_subject_waiting_list,mail_text_waiting_list" %}

View File

@@ -107,6 +107,7 @@
{% bootstrap_field form.require_membership_hidden layout="control" %}
</div>
{% endif %}
{% bootstrap_field form.checkin_attention layout="control" %}
</div>
</details>
{% endfor %}
@@ -204,6 +205,7 @@
{% bootstrap_field formset.empty_form.require_membership_hidden layout="control" %}
</div>
{% endif %}
{% bootstrap_field formset.empty_form.checkin_attention layout="control" %}
</div>
</details>
{% endescapescript %}

View File

@@ -18,6 +18,7 @@
<select name="status" class="form-control">
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
<option value="pv" {% if request.GET.status == "pv" %}selected="selected"{% endif %}>{% trans "Paid or confirmed" %}</option>
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option>
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>

View File

@@ -189,6 +189,55 @@
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Ticket block" %}</strong>
</div>
<div class="col-sm-5">
{% if "admin" in position.blocked %}
{% trans "Blocked" %}
{% elif position.blocked %}
{% trans "Blocked due to external constraints" %}
{% else %}
{% trans "Not blocked" %}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.blocked layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Validity time" %}</strong>
</div>
<div class="col-sm-5">
{% if position.valid_from %}
{% blocktrans trimmed with datetime=position.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime }}
{% endblocktrans %}
{% endif %}
{% if position.valid_until %}
{% if position.valid_from %}
<br />
{% endif %}
{% blocktrans trimmed with datetime=position.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ datetime }}
{% endblocktrans %}
{% endif %}
{% if not position.valid_from and not position.valid_until %}
{% trans "Unconstrained" %}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.valid_from layout='inline' %}
<div class="text-center">
{% trans "" %}
</div>
{% bootstrap_field position.form.valid_until layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Ticket secret" %}</strong>

View File

@@ -194,7 +194,10 @@
<dt>{% trans "Expiry date" %}</dt>
<dd>
{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}
{% if has_cancellation_fee and request.event.settings.payment_term_expire_automatically %}
{% if order.valid_if_pending %}
<span class="fa fa-warning text-danger" data-toggle="tooltip"
title="{% trans "This order will not expire automatically since it is already confirmed and can be used." %}"></span>
{% elif has_cancellation_fee and request.event.settings.payment_term_expire_automatically %}
<span class="fa fa-warning text-danger" data-toggle="tooltip"
title="{% trans "This order will not expire automatically as it has an open cancellation fee." %}"></span>
{% endif %}
@@ -390,6 +393,15 @@
{% endif %}
{% endfor %}
{% endif %}
{% if line.blocked %}
<br />
<strong class="text-danger">
<span class="fa fa-ban fa-fw text-danger"></span>
{% for k, block_reason in line.blocked_reasons.items %}
{{ block_reason }}
{% endfor %}
</strong>
{% endif %}
{% if line.seat %}
<br />
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668" class="svg-icon">
@@ -429,6 +441,26 @@
</a>
</span>
{% endif %}
{% if line.valid_from or line.valid_until %}
<div class="cart-icon-details">
<dd>
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
{% if line.valid_from and line.valid_until %}
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime_from }} until {{ datetime_until }}
{% endblocktrans %}
{% elif line.valid_from %}
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime }}
{% endblocktrans %}
{% elif line.valid_until %}
{% blocktrans trimmed with datetime=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ datetime }}
{% endblocktrans %}
{% endif %}
</dd>
</div>
{% endif %}
{% if not line.canceled %}
<div class="position-buttons">
{% if line.generate_ticket %}

View File

@@ -6,6 +6,11 @@
<span class="fa fa-question-circle"></span>
{% trans "Approval pending" %}
</span>
{% elif order.valid_if_pending %}
<span data-toggle="tooltip" class="label label-info {{ class }}">
<span class="fa fa-money"></span>
{% trans "Pending (confirmed)" context "order state" %}
</span>
{% else %}
<span data-toggle="tooltip" title="{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}"
class="label label-warning {{ class }}">

View File

@@ -36,7 +36,7 @@ import dateutil.parser
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery
from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
@@ -85,9 +85,16 @@ class CheckInListQueryMixin:
m=Max('datetime')
).values('m')
if self.list.include_pending:
status_q = Q(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
status_q = Q(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
qs = OrderPosition.objects.filter(
status_q,
order__event=self.request.event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.list.include_pending else [Order.STATUS_PAID],
).annotate(
last_entry=Subquery(cqs),
last_exit=Subquery(cqs_exit),
@@ -199,7 +206,9 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request):
raise PermissionDenied()
for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
if op.order.status == Order.STATUS_PAID or (
(self.list.include_pending or op.order.valid_if_pending) and op.order.status == Order.STATUS_PENDING
):
Checkin.objects.filter(position=op, list=self.list).delete()
op.order.log_action('pretix.event.checkin.reverted', data={
'position': op.id,
@@ -213,7 +222,9 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
else:
t = Checkin.TYPE_EXIT if request.POST.get('checkout') == 'true' else Checkin.TYPE_ENTRY
for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
if op.order.status == Order.STATUS_PAID or (
(self.list.include_pending or op.order.valid_if_pending) and op.order.status == Order.STATUS_PENDING
):
lci = op.checkins.filter(list=self.list).first()
if self.list.allow_multiple_entries or t != Checkin.TYPE_ENTRY or (lci and lci.type != Checkin.TYPE_ENTRY):
ci = Checkin.objects.create(position=op, list=self.list, datetime=now(), type=t)

View File

@@ -629,6 +629,11 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
orderposition__order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
qs = qs.filter(
Q(orderposition__order__status=Order.STATUS_PAID) |
Q(orderposition__order__status=Order.STATUS_PENDING, orderposition__order__valid_if_pending=True)
)
elif s == 'ne':
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:

View File

@@ -1520,6 +1520,7 @@ class OrderExtend(OrderView):
self.order,
new_date=self.form.cleaned_data.get('expires'),
force=self.form.cleaned_data.get('quota_ignore', False),
valid_if_pending=self.form.cleaned_data.get('valid_if_pending', False),
user=self.request.user
)
messages.success(self.request, _('The payment term has been changed.'))
@@ -1773,6 +1774,17 @@ class OrderChange(OrderView):
if p.form.cleaned_data['tax_rule'] and p.form.cleaned_data['tax_rule'] != p.tax_rule:
ocm.change_tax_rule(p, p.form.cleaned_data['tax_rule'])
if p.form.cleaned_data["blocked"] and "admin" not in (p.blocked or []):
ocm.add_block(p, "admin")
elif not p.form.cleaned_data["blocked"] and "admin" in (p.blocked or []):
ocm.remove_block(p, "admin")
if p.form.cleaned_data['valid_from'] != p.valid_from:
ocm.change_valid_from(p, p.form.cleaned_data['valid_from'])
if p.form.cleaned_data['valid_until'] != p.valid_until:
ocm.change_valid_until(p, p.form.cleaned_data['valid_until'])
if p.form.cleaned_data.get('operation_split'):
ocm.split(p)

View File

@@ -340,7 +340,7 @@ class BadgeExporter(BaseExporter):
if form_data.get('include_pending'):
qs = qs.filter(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
qs = qs.filter(order__status__in=[Order.STATUS_PAID])
qs = qs.filter(Q(order__status=Order.STATUS_PAID) | Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True))
if form_data.get('date_from'):
dt = make_aware(datetime.combine(

View File

@@ -216,10 +216,13 @@ class CheckInListMixin(BaseExporter):
)
if form_data.get('attention_only'):
qs = qs.filter(Q(item__checkin_attention=True) | Q(order__checkin_attention=True))
qs = qs.filter(Q(item__checkin_attention=True) | Q(order__checkin_attention=True) | Q(variation__checkin_attention=True))
if not cl.include_pending:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = qs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
else:
qs = qs.filter(order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING))
@@ -363,12 +366,15 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
)
if op.seat:
item += '<br/>' + str(op.seat)
name = bleach.clean(str(name), tags=['br']).strip().replace('<br>', '<br/>')
if op.blocked:
name = '<font face="OpenSansBd">[' + _('Blocked') + ']</font> ' + name
row = [
'!!' if op.item.checkin_attention or op.order.checkin_attention else '',
CBFlowable(bool(op.last_checked_in)),
'!!' if op.require_checkin_attention else '',
CBFlowable(bool(op.last_checked_in)) if not op.blocked else '',
'' if op.order.status != Order.STATUS_PAID else '',
op.order.code,
Paragraph(bleach.clean(str(name), tags=['br']).strip().replace('<br>', '<br/>'), self.get_style()),
Paragraph(name, self.get_style()),
Paragraph(bleach.clean(str(item), tags=['br']).strip().replace('<br>', '<br/>'), self.get_style()),
]
acache = {}
@@ -401,6 +407,12 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
('TEXTCOLOR', (2, len(tdata)), (2, len(tdata)), '#ffffff'),
('ALIGN', (2, len(tdata)), (2, len(tdata)), 'CENTER'),
]
if op.blocked:
tstyledata += [
('BACKGROUND', (1, len(tdata)), (1, len(tdata)), '#990000'),
('TEXTCOLOR', (1, len(tdata)), (1, len(tdata)), '#ffffff'),
('ALIGN', (1, len(tdata)), (1, len(tdata)), 'CENTER'),
]
tdata.append(row)
table = Table(tdata, colWidths=colwidths, repeatRows=1)
@@ -440,7 +452,10 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
_('Product'), _('Price'), _('Checked in'), _('Checked out'), _('Automatically checked in')
]
if not cl.include_pending:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = qs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
else:
qs = qs.filter(order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING))
headers.append(_('Paid'))
@@ -470,6 +485,9 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
headers.append(_('Seat zone'))
headers.append(_('Seat row'))
headers.append(_('Seat number'))
headers.append(_('Blocked'))
headers.append(_('Valid from'))
headers.append(_('Valid until'))
headers += [
_('Address'),
_('ZIP code'),
@@ -565,7 +583,7 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
row.append(op.voucher.code if op.voucher else "")
row.append(op.order.datetime.astimezone(self.event.timezone).strftime('%Y-%m-%d'))
row.append(op.order.datetime.astimezone(self.event.timezone).strftime('%H:%M:%S'))
row.append(_('Yes') if op.order.checkin_attention or op.item.checkin_attention else _('No'))
row.append(_('Yes') if op.require_checkin_attention else _('No'))
row.append(op.order.comment or "")
if op.seat:
@@ -580,6 +598,9 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
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 '',
op.street or '',
op.zipcode or '',
op.city or '',

View File

@@ -243,7 +243,8 @@ class OrderMailForm(BaseMailForm):
self.fields['recipients'].choices = recp_choices
choices = [(e, l) for e, l in Order.STATUS_CHOICE if e != 'n']
choices.insert(0, ('na', _('payment pending (except unapproved)')))
choices.insert(0, ('valid_if_pending', _('payment pending but already confirmed')))
choices.insert(0, ('na', _('payment pending (except unapproved or already confirmed)')))
choices.insert(0, ('pa', _('approval pending')))
if not event.settings.get('payment_term_expire_automatically', as_type=bool):
choices.append(
@@ -257,10 +258,11 @@ class OrderMailForm(BaseMailForm):
choices=choices
)
if not self.initial.get('sendto'):
self.initial['sendto'] = ['p', 'na']
self.initial['sendto'] = ['p', 'na', 'valid_if_pending']
elif 'n' in self.initial['sendto']:
self.initial['sendto'].append('pa')
self.initial['sendto'].append('na')
self.initial['sendto'].append('valid_if_pending')
self.fields['items'].queryset = event.items.all()
if not self.initial.get('items'):

View File

@@ -22,7 +22,7 @@
from datetime import datetime, time, timedelta
from django.db import models
from django.db.models import Exists, OuterRef
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.timezone import make_aware
@@ -118,7 +118,13 @@ class ScheduledMail(models.Model):
Exists(OrderPosition.objects.filter(order=OuterRef('pk'), item_id__in=limit_products))
)
status = [Order.STATUS_PENDING, Order.STATUS_PAID] if self.rule.include_pending else [Order.STATUS_PAID]
if self.rule.include_pending:
status_q = Q(status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
status_q = Q(
Q(status=Order.STATUS_PAID) |
Q(status=Order.STATUS_PENDING, valid_if_pending=True)
)
if self.last_successful_order_id:
orders = orders.filter(
@@ -126,7 +132,7 @@ class ScheduledMail(models.Model):
)
orders = orders.filter(
status__in=status,
status_q,
require_approval=False,
).order_by('pk').select_related('invoice_address').prefetch_related('positions')

View File

@@ -264,7 +264,8 @@ class OrderSendView(BaseSenderView):
if 'status' not in _cache_store:
status = dict(Order.STATUS_CHOICE)
status['overdue'] = _('pending with payment overdue')
status['na'] = _('payment pending (except unapproved)')
status['valid_if_pending'] = _('payment pending but already confirmed')
status['na'] = _('payment pending (except unapproved or already confirmed)')
status['pa'] = _('approval pending')
status['r'] = status['c']
_cache_store['status'] = status
@@ -346,7 +347,9 @@ class OrderSendView(BaseSenderView):
if 'pa' in form.cleaned_data['sendto']:
statusq |= Q(status=Order.STATUS_PENDING, require_approval=True)
if 'na' in form.cleaned_data['sendto']:
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False)
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=False)
if 'valid_if_pending' in form.cleaned_data['sendto']:
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=True)
orders = qs.filter(statusq)
opq = OrderPosition.objects.filter(

View File

@@ -127,7 +127,7 @@ class AllTicketsPDF(BaseExporter):
if form_data.get('include_pending'):
qs = qs.filter(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
qs = qs.filter(order__status__in=[Order.STATUS_PAID])
qs = qs.filter(Q(order__status=Order.STATUS_PAID) | Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True))
if form_data.get('date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)

View File

@@ -58,6 +58,8 @@ window.vapp = new Vue({
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.blocked': gettext('Ticket blocked'),
'result.invalid_time': gettext('Ticket not valid at this time'),
'result.canceled': gettext('Order canceled'),
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
'status.checkin': gettext('Checked-in Tickets'),

View File

@@ -29,7 +29,7 @@
{{ line.variation }}
{% endif %}
</p>
{% if line.seat or line.voucher or line.subevent or line.used_membership%}
{% if line.seat or line.voucher or line.subevent or line.used_membership or line.blocked or line.valid_from or line.valid_until %}
<dl class="dl-inline">
{% elif event.settings.show_checkin_number_user and line.checkin_count %}
<dl class="dl-inline">
@@ -96,6 +96,36 @@
</dd>
</div>
{% endif %}
{% if line.valid_from or line.valid_until %}
<div class="cart-icon-details">
<dd>
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
{% if line.valid_from and line.valid_until %}
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime_from }} until {{ datetime_until }}
{% endblocktrans %}
{% elif line.valid_from %}
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime }}
{% endblocktrans %}
{% elif line.valid_until %}
{% blocktrans trimmed with datetime=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ datetime }}
{% endblocktrans %}
{% endif %}
</dd>
</div>
{% endif %}
{% if line.blocked %}
<div class="cart-icon-details">
<dd class="text-danger">
<span class="fa fa-ban fa-fw text-danger" aria-hidden="true"></span>
<strong>
{% trans "This ticket is blocked." %}
</strong>
</dd>
</div>
{% endif %}
{% if event.settings.show_checkin_number_user and line.checkin_count %}
<div class="cart-icon-details">
<dt class="sr-only">{% trans "Usage:" context "ticket_checkins" %}</dt>
@@ -111,7 +141,7 @@
</dd>
</div>
{% endif %}
{% if line.seat or line.voucher or line.subevent or line.used_membership%}
{% if line.seat or line.voucher or line.subevent or line.used_membership or line.blocked or line.valid_from or line.valid_until %}
</dl>
{% elif event.settings.show_checkin_number_user and line.checkin_count %}
</dl>

View File

@@ -7,11 +7,13 @@
{% trans "Confirmation pending" context "order state" %}
{% elif event.settings.payment_pending_hidden %}
{# intentionally left blank #}
{% elif order.valid_if_pending %}
{% trans "Confirmed" context "order state" %}
{% else %}
{% trans "Payment pending" %}
{% endif %}
{% if not event.settings.payment_pending_hidden %}
<i class="status-dot fa fa-circle text-warning" aria-hidden="true"></i>
<i class="status-dot fa fa-circle {% if order.valid_if_pending %}text-info{% else %}text-warning{% endif %}" aria-hidden="true"></i>
{% endif %}
{% elif order.status == "p" %}
{% if order.count_positions == 0 %}