diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 1d60ea533..311fd84cc 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -26,7 +26,6 @@ status string Order status, o * ``p`` – paid * ``e`` – expired * ``c`` – canceled - * ``r`` – refunded secret string The secret contained in the link sent to the customer email string The customer email address locale string The locale used for communication with this customer @@ -58,9 +57,9 @@ invoice_address object Invoice address └ vat_id_validated string ``True``, if the VAT ID has been validated against the EU VAT service and validation was successful. This only happens in rare cases. -positions list of objects List of order positions (see below) -fees list of objects List of fees included in the order total (i.e. - payment fees) +positions list of objects List of non-canceled order positions (see below) +fees list of objects List of non-canceled fees included in the order total + (i.e. payment fees) ├ fee_type string Type of fee (currently ``payment``, ``passbook``, ``other``) ├ value money (string) Fee amount @@ -874,7 +873,7 @@ Order state operations { "code": "ABC12", - "status": "r", + "status": "c", ... } @@ -1066,6 +1065,8 @@ List of all order positions The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and ``pseudonymization_id``. +.. note:: Individually canceled order positions are currently not visible via the API at all. + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/ Returns a list of all order positions within a given event. diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 8596c346a..e254700dc 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -324,7 +324,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): 'secret', 'addon_to', 'subevent', 'answers') def validate_secret(self, secret): - if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists(): + if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): raise ValidationError( 'You cannot assign a position secret that already exists.' ) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index edf0030ab..2e6fd1adb 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -770,7 +770,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): raise PermissionDenied('The invoice file is no longer stored on the server.') else: c = generate_cancellation(inv) - if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED): + if inv.order.status != Order.STATUS_CANCELED: inv = generate_invoice(inv.order) else: inv = c diff --git a/src/pretix/base/migrations/0104_auto_20181114_1526.py b/src/pretix/base/migrations/0104_auto_20181114_1526.py new file mode 100644 index 000000000..3ee88e844 --- /dev/null +++ b/src/pretix/base/migrations/0104_auto_20181114_1526.py @@ -0,0 +1,63 @@ +# Generated by Django 2.1.1 on 2018-11-14 15:26 + +import django.db.models.deletion +import django.db.models.manager +import jsonfallback.fields +from django.db import migrations, models + + +def change_refunded_to_canceled(apps, schema_editor): + Order = apps.get_model('pretixbase', 'Order') + Order.objects.filter(status='r').update(status='c', total=0) + Order.objects.filter(status='c').update(total=0) + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0103_auto_20181121_1224'), + ] + + operations = [ + migrations.AlterModelManagers( + name='orderposition', + managers=[ + ('all', django.db.models.manager.Manager()), + ], + ), + migrations.AddField( + model_name='orderposition', + name='canceled', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='orderfee', + name='fee_type', + field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('cancellation', 'Cancellation fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100), + ), + migrations.AlterField( + model_name='orderposition', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_positions', + to='pretixbase.Order', verbose_name='Order'), + ), + migrations.AlterModelManagers( + name='orderfee', + managers=[ + ('all', django.db.models.manager.Manager()), + ], + ), + migrations.AddField( + model_name='orderfee', + name='canceled', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='orderfee', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_fees', to='pretixbase.Order', verbose_name='Order'), + ), + migrations.RunPython( + change_refunded_to_canceled, migrations.RunPython.noop + ) + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index ca57d4532..3792d63ab 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -355,7 +355,8 @@ class Event(EventMixin, LoggedModel): if not really: raise TypeError("Pass really=True as a parameter.") - OrderPosition.objects.filter(order__event=self).delete() + OrderPosition.all.filter(order__event=self, addon_to__isnull=False).delete() + OrderPosition.all.filter(order__event=self).delete() OrderFee.objects.filter(order__event=self).delete() OrderPayment.objects.filter(order__event=self).delete() OrderRefund.objects.filter(order__event=self).delete() diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 1d9d6d0c8..ee25aec24 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -442,7 +442,7 @@ class Item(LoggedModel): def allow_delete(self): from pretix.base.models.orders import OrderPosition - return not OrderPosition.objects.filter(item=self).exists() + return not OrderPosition.all.filter(item=self).exists() @cached_property def has_variations(self): diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 89bb4c26e..9c2d6ec79 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -70,7 +70,6 @@ class Order(LockModel, LoggedModel): * ``STATUS_PAID`` * ``STATUS_EXPIRED`` * ``STATUS_CANCELED`` - * ``STATUS_REFUNDED`` :param event: The event this order belongs to :type event: Event @@ -102,13 +101,12 @@ class Order(LockModel, LoggedModel): STATUS_PAID = "p" STATUS_EXPIRED = "e" STATUS_CANCELED = "c" - STATUS_REFUNDED = "r" + STATUS_REFUNDED = "c" # deprecated STATUS_CHOICE = ( (STATUS_PENDING, _("pending")), (STATUS_PAID, _("paid")), (STATUS_EXPIRED, _("expired")), (STATUS_CANCELED, _("canceled")), - (STATUS_REFUNDED, _("refunded")) ) code = models.CharField( @@ -186,6 +184,28 @@ class Order(LockModel, LoggedModel): def __str__(self): return self.full_code + @property + def fees(self): + """ + Related manager for all non-canceled fees. Use ``all_fees`` instead if you want + canceled positions as well. + """ + return self.all_fees(manager='objects') + + @cached_property + def count_positions(self): + if hasattr(self, 'pcnt'): + return self.pcnt or 0 + return self.positions.count() + + @property + def positions(self): + """ + Related manager for all non-canceled positions. Use ``all_positions`` instead if you want + canceled positions as well. + """ + return self.all_positions(manager='objects') + @cached_property def meta_info_data(self): try: @@ -207,7 +227,7 @@ class Order(LockModel, LoggedModel): @property def pending_sum(self): total = self.total - if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED): + if self.status == Order.STATUS_CANCELED: total = Decimal('0.00') payment_sum = self.payments.filter( state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) @@ -248,9 +268,9 @@ class Order(LockModel, LoggedModel): pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0), ).annotate( is_overpaid=Case( - When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0), + When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0), then=Value('1')), - When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0), + When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0), then=Value('1')), default=Value('0'), output_field=models.IntegerField() @@ -336,8 +356,7 @@ class Order(LockModel, LoggedModel): def cancel_allowed(self): return ( - self.status == Order.STATUS_PENDING - or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00')) + self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.positions.exists() ) @staticmethod @@ -399,7 +418,10 @@ class Order(LockModel, LoggedModel): """ positions = self.positions.all().select_related('item') cancelable = all([op.item.allow_cancel for op in positions]) - return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable + return ( + self.status == Order.STATUS_PENDING + or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00')) + ) and self.event.settings.cancel_allow_user and cancelable @property def is_expired_by_time(self): @@ -1254,11 +1276,19 @@ class OrderRefund(models.Model): super().save(*args, **kwargs) +class ActivePositionManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(canceled=False) + + class OrderFee(models.Model): """ An OrderFee object represents a fee that is added to the order total independently of the actual positions. This might for example be a payment or a shipping fee. + The default ``OrderFee.objects`` manager only contains fees that are not ``canceled``. If + you ant all objects, you need to use ``OrderFee.all`` instead. + :param value: Gross price of this fee :type value: Decimal :param order: Order this fee is charged with @@ -1275,16 +1305,20 @@ class OrderFee(models.Model): :type tax_rule: TaxRule :param tax_value: The tax amount included in the price :type tax_value: Decimal + :param canceled: True, if this position is canceled and should no longer be regarded + :type canceled: bool """ FEE_TYPE_PAYMENT = "payment" FEE_TYPE_SHIPPING = "shipping" FEE_TYPE_SERVICE = "service" + FEE_TYPE_CANCELLATION = "cancellation" FEE_TYPE_OTHER = "other" FEE_TYPE_GIFTCARD = "giftcard" FEE_TYPES = ( (FEE_TYPE_PAYMENT, _("Payment fee")), (FEE_TYPE_SHIPPING, _("Shipping fee")), (FEE_TYPE_SERVICE, _("Service fee")), + (FEE_TYPE_CANCELLATION, _("Cancellation fee")), (FEE_TYPE_OTHER, _("Other fees")), (FEE_TYPE_GIFTCARD, _("Gift card")), ) @@ -1296,7 +1330,7 @@ class OrderFee(models.Model): order = models.ForeignKey( Order, verbose_name=_("Order"), - related_name='fees', + related_name='all_fees', on_delete=models.PROTECT ) fee_type = models.CharField( @@ -1317,6 +1351,10 @@ class OrderFee(models.Model): max_digits=10, decimal_places=2, verbose_name=_('Tax value') ) + canceled = models.BooleanField(default=False) + + all = models.Manager() + objects = ActivePositionManager() @property def net_value(self): @@ -1371,6 +1409,9 @@ class OrderPosition(AbstractPosition): of a specified type (or variation). This has all properties of AbstractPosition. + The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If + you ant all objects, you need to use ``OrderPosition.all`` instead. + :param order: The order this position is a part of :type order: Order :param positionid: A local ID of this position, counted for each order individually @@ -1383,6 +1424,8 @@ class OrderPosition(AbstractPosition): :type tax_value: Decimal :param secret: The secret used for ticket QR codes :type secret: str + :param canceled: True, if this position is canceled and should no longer be regarded + :type canceled: bool :param pseudonymization_id: The QR code content for lead scanning :type pseudonymization_id: str """ @@ -1390,7 +1433,7 @@ class OrderPosition(AbstractPosition): order = models.ForeignKey( Order, verbose_name=_("Order"), - related_name='positions', + related_name='all_positions', on_delete=models.PROTECT ) tax_rate = models.DecimalField( @@ -1412,6 +1455,10 @@ class OrderPosition(AbstractPosition): unique=True, db_index=True ) + canceled = models.BooleanField(default=False) + + all = models.Manager() + objects = ActivePositionManager() class Meta: verbose_name = _("Order position") @@ -1492,7 +1539,7 @@ class OrderPosition(AbstractPosition): self._calculate_tax() self.order.touch() if self.pk is None: - while OrderPosition.objects.filter(secret=self.secret).exists(): + while OrderPosition.all.filter(secret=self.secret).exists(): self.secret = generate_position_secret() if not self.pseudonymization_id: @@ -1508,7 +1555,7 @@ class OrderPosition(AbstractPosition): charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') while True: code = get_random_string(length=10, allowed_chars=charset) - if not OrderPosition.objects.filter(pseudonymization_id=code).exists(): + if not OrderPosition.all.filter(pseudonymization_id=code).exists(): self.pseudonymization_id = code return diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index ca9a91db0..d2128f3b4 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -97,7 +97,7 @@ class TaxRule(LoggedModel): return ( not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists() - and not OrderPosition.objects.filter(tax_rule=self, order__event=self.event).exists() + and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists() and not self.event.items.filter(tax_rule=self).exists() and self.event.settings.tax_rate_default != self ) diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 06cb8cfae..aff452b1a 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -78,7 +78,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, dt = datetime or now() # Fetch order position with related objects - op = OrderPosition.objects.select_related( + op = OrderPosition.all.select_related( 'item', 'variation', 'order', 'addon_to' ).prefetch_related( 'item__questions', @@ -90,6 +90,12 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, 'answers' ).get(pk=op.pk) + if op.canceled: + raise CheckInError( + _('This order position has been canceled.'), + 'unpaid' + ) + answers = {a.question: a for a in op.answers.all()} require_answers = [] for q in op.item.checkin_questions: diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index cc7f31568..0817ee82e 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -234,7 +234,7 @@ def generate_invoice(order: Order, trigger_pdf=True): if trigger_pdf: invoice_pdf(invoice.pk) - if order.status in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED): + if order.status == Order.STATUS_CANCELED: generate_cancellation(invoice, trigger_pdf) return invoice diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index a3bbfb074..c8ff2d3c2 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -124,25 +124,12 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User @transaction.atomic def mark_order_refunded(order, user=None, auth=None, api_token=None): - """ - Mark this order as refunded. This sets the payment status and returns the order object. - :param order: The order to change - :param user: The user that performed the change - """ - if isinstance(order, int): - order = Order.objects.get(pk=order) - if isinstance(user, int): - user = User.objects.get(pk=user) - with order.event.lock(): - order.status = Order.STATUS_REFUNDED - order.save(update_fields=['status']) - - order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token) - i = order.invoices.filter(is_cancellation=False).last() - if i: - generate_cancellation(i) - - return order + oautha = auth.pk if isinstance(auth, OAuthApplication) else None + device = auth.pk if isinstance(auth, Device) else None + api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None) + return _cancel_order( + order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha + ) @transaction.atomic @@ -1043,7 +1030,10 @@ class OrderChangeManager: 'addon_to': opa.addon_to_id, 'old_price': opa.price, }) - opa.delete() + opa.canceled = True + if opa.voucher: + Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=F('redeemed') - 1) + opa.save(update_fields=['canceled']) self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ 'position': op.position.pk, 'positionid': op.position.positionid, @@ -1052,7 +1042,10 @@ class OrderChangeManager: 'old_price': op.position.price, 'addon_to': None, }) - op.position.delete() + op.position.canceled = True + if op.position.voucher: + Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=F('redeemed') - 1) + op.position.save(update_fields=['canceled']) elif isinstance(op, self.AddOperation): pos = OrderPosition.objects.create( item=op.item, variation=op.variation, addon_to=op.addon_to, @@ -1117,7 +1110,7 @@ class OrderChangeManager: except InvoiceAddress.DoesNotExist: pass - split_order.total = sum([p.price for p in split_positions]) + split_order.total = sum([p.price for p in split_positions if not p.canceled]) if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: pp = self._get_payment_provider() if pp: @@ -1180,7 +1173,7 @@ class OrderChangeManager: return payment_sum - refund_sum def _recalculate_total_and_payment_fee(self): - total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) + total = sum([p.price for p in self.order.positions.all() if not p.canceled]) + sum([f.value for f in self.order.fees.all()]) payment_fee = Decimal('0.00') if self.open_payment: current_fee = Decimal('0.00') diff --git a/src/pretix/base/services/stats.py b/src/pretix/base/services/stats.py index 9e411de35..0b5862e45 100644 --- a/src/pretix/base/services/stats.py +++ b/src/pretix/base/services/stats.py @@ -1,7 +1,7 @@ from decimal import Decimal from typing import Any, Dict, Iterable, List, Tuple -from django.db.models import Count, Sum +from django.db.models import Case, Count, F, Sum, Value, When from django.utils.translation import ugettext_lazy as _ from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition @@ -79,18 +79,22 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It 'variations' ).order_by('category__position', 'category_id', 'position', 'name') - qs = OrderPosition.objects + qs = OrderPosition.all if subevent: qs = qs.filter(subevent=subevent) counters = qs.filter( order__event=event + ).annotate( + status=Case( + When(canceled=True, then=Value('c')), + default=F('order__status') + ) ).values( - 'item', 'variation', 'order__status' + 'item', 'variation', 'status' ).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by() states = { 'canceled': Order.STATUS_CANCELED, - 'refunded': Order.STATUS_REFUNDED, 'paid': Order.STATUS_PAID, 'pending': Order.STATUS_PENDING, 'expired': Order.STATUS_EXPIRED, @@ -99,7 +103,7 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It for l, s in states.items(): num[l] = { (p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value']) - for p in counters if p['order__status'] == s + for p in counters if p['status'] == s } num['total'] = dictsum(num['pending'], num['paid']) @@ -149,16 +153,21 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It payment_items = [] if not subevent: - counters = OrderFee.objects.filter( + counters = OrderFee.all.filter( order__event=event + ).annotate( + status=Case( + When(canceled=True, then=Value('c')), + default=F('order__status') + ) ).values( - 'fee_type', 'internal_type', 'order__status' + 'fee_type', 'internal_type', 'status' ).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by() for l, s in states.items(): num[l] = { (o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value']) - for o in counters if o['order__status'] == s + for o in counters if o['status'] == s } num['total'] = dictsum(num['pending'], num['paid']) diff --git a/src/pretix/base/shredder.py b/src/pretix/base/shredder.py index 421016225..85575bfe9 100644 --- a/src/pretix/base/shredder.py +++ b/src/pretix/base/shredder.py @@ -133,12 +133,12 @@ class EmailAddressShredder(BaseDataShredder): }, indent=4) yield 'emails-by-attendee.json', 'application/json', json.dumps({ '{}-{}'.format(op.order.code, op.positionid): op.attendee_email - for op in OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False) + for op in OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False) }, indent=4) @transaction.atomic def shred_data(self): - OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None) + OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None) for o in self.event.orders.all(): o.email = None @@ -202,7 +202,7 @@ class AttendeeNameShredder(BaseDataShredder): def generate_files(self) -> List[Tuple[str, str, str]]: yield 'attendee-names.json', 'application/json', json.dumps({ '{}-{}'.format(op.order.code, op.positionid): op.attendee_name - for op in OrderPosition.objects.filter( + for op in OrderPosition.all.filter( order__event=self.event ).filter( Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False)) @@ -211,7 +211,7 @@ class AttendeeNameShredder(BaseDataShredder): @transaction.atomic def shred_data(self): - OrderPosition.objects.filter( + OrderPosition.all.filter( order__event=self.event ).filter( Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False)) @@ -267,7 +267,7 @@ class QuestionAnswerShredder(BaseDataShredder): def generate_files(self) -> List[Tuple[str, str, str]]: yield 'question-answers.json', 'application/json', json.dumps({ '{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data - for op in OrderPosition.objects.filter(order__event=self.event).prefetch_related('answers') + for op in OrderPosition.all.filter(order__event=self.event).prefetch_related('answers') }, indent=4) @transaction.atomic diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 0cbb721f7..b63fc7f6d 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -100,14 +100,13 @@ class OrderFilterForm(FilterForm): label=_('Order status'), choices=( ('', _('All orders')), - ('p', _('Paid')), - ('n', _('Pending')), + (Order.STATUS_PAID, _('Paid (or canceled with paid fee)')), + (Order.STATUS_PENDING, _('Pending')), ('o', _('Pending (overdue)')), - ('np', _('Pending or paid')), - ('e', _('Expired')), - ('ne', _('Pending or expired')), - ('c', _('Canceled')), - ('r', _('Refunded')), + (Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')), + (Order.STATUS_EXPIRED, _('Expired')), + (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')), + (Order.STATUS_CANCELED, _('Canceled')), ), required=False, ) @@ -201,14 +200,13 @@ class EventOrderFilterForm(OrderFilterForm): label=_('Order status'), choices=( ('', _('All orders')), - ('p', _('Paid')), - ('n', _('Pending')), + (Order.STATUS_PAID, _('Paid (or canceled with paid fee)')), + (Order.STATUS_PENDING, _('Pending')), ('o', _('Pending (overdue)')), - ('np', _('Pending or paid')), - ('e', _('Expired')), - ('ne', _('Pending or expired')), - ('c', _('Canceled')), - ('r', _('Refunded')), + (Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')), + (Order.STATUS_EXPIRED, _('Expired')), + (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')), + (Order.STATUS_CANCELED, _('Canceled')), ('pa', _('Approval pending')), ('overpaid', _('Overpaid')), ('underpaid', _('Underpaid')), @@ -246,10 +244,10 @@ class EventOrderFilterForm(OrderFilterForm): qs = super().filter_qs(qs) if fdata.get('item'): - qs = qs.filter(positions__item=fdata.get('item')) + qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False) if fdata.get('subevent'): - qs = qs.filter(positions__subevent=fdata.get('subevent')) + qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False) if fdata.get('question') and fdata.get('answer') is not None: q = fdata.get('question') @@ -278,8 +276,8 @@ class EventOrderFilterForm(OrderFilterForm): if fdata.get('status') == 'overpaid': qs = qs.filter( - Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0)) - | Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0)) + Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0)) + | Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0)) ) elif fdata.get('status') == 'pendingpaid': qs = qs.filter( diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 479fa74c2..b5040718c 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -367,8 +367,7 @@ class OrderRefundForm(forms.Form): required=False, widget=forms.RadioSelect, choices=( - ('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will ' - 'no longer work. This can not be reverted.')), + ('mark_refunded', _('Cancel the order. All tickets will no longer work. This can not be reverted.')), ('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another ' 'payment method.')), ('do_nothing', _('Do nothing and keep the order as it is.')), @@ -391,7 +390,7 @@ class OrderRefundForm(forms.Form): self.order = kwargs.pop('order') super().__init__(*args, **kwargs) change_decimal_field(self.fields['partial_amount'], self.order.event.currency) - if self.order.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED): + if self.order.status == Order.STATUS_CANCELED: del self.fields['action'] def clean_partial_amount(self): diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 2c5687ef5..7fb8fc778 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -63,7 +63,7 @@ def _display_order_changed(event: Event, logentry: LogEntry): old_item = str(event.items.get(pk=data['old_item'])) if data['old_variation']: old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation'])) - return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) removed.').format( + return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) canceled.').format( posid=data.get('positionid', '?'), old_item=old_item, old_price=money_filter(Decimal(data['old_price']), event.currency), diff --git a/src/pretix/control/templates/pretixcontrol/items/question.html b/src/pretix/control/templates/pretixcontrol/items/question.html index a27308f5f..d31575d23 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question.html +++ b/src/pretix/control/templates/pretixcontrol/items/question.html @@ -24,7 +24,6 @@ - - {% trans "Remove from order" %} + {% trans "Cancel position" %} {% if position.addons.exists %} {% trans "Removing this position will also remove all add-ons to this position." %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index bd03047a3..b336894ad 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -50,11 +50,6 @@ {% trans "Cancel order" %} {% endif %} - {% if order.payment_refund_sum > 0 %} - - {% trans "Create a refund" %} - - {% endif %} {% endif %} {% csrf_token %} - - {% endfor %} + {% if not line.canceled %} +
+ {% if not line.addon_to or request.event.settings.ticket_download_addons %} + {% if line.item.admission or request.event.settings.ticket_download_nonadm %} + {% for b in download_buttons %} +
+ {% csrf_token %} + +
+ {% endfor %} + {% endif %} {% endif %} - {% endif %} - {% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %} -
+ {% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %} + + {% endif %} {% if line.has_questions %}
{% if line.item.admission and event.settings.attendee_names_asked %} @@ -486,6 +483,11 @@ {% endfor %} + {% if order.payment_refund_sum > 0 %} + + {% trans "Create a refund" %} + + {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html b/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html index 007cb156a..40518dc20 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html +++ b/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html @@ -7,11 +7,13 @@ {% trans "Pending" %} {% endif %} {% elif order.status == "p" %} - {% trans "Paid" %} + {% if order.count_positions == 0 %} + {% trans "Canceled (paid fee)" %} + {% else %} + {% trans "Paid" %} + {% endif %} {% elif order.status == "e" %} {# expired #} {% trans "Expired" %} {% elif order.status == "c" %} {% trans "Canceled" %} -{% elif order.status == "r" %} - {% trans "Refunded" %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/index.html b/src/pretix/control/templates/pretixcontrol/orders/index.html index 8b6c04a60..edcf7ea6d 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/index.html +++ b/src/pretix/control/templates/pretixcontrol/orders/index.html @@ -142,7 +142,7 @@ {% endif %} {{ o.total|money:request.event.currency }} - {{ o.pcnt }} + {{ o.pcnt|default_if_none:"0" }} {% include "pretixcontrol/orders/fragment_order_status.html" with order=o %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/overview.html b/src/pretix/control/templates/pretixcontrol/orders/overview.html index 9732a0f3b..96e522740 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/overview.html +++ b/src/pretix/control/templates/pretixcontrol/orders/overview.html @@ -30,8 +30,7 @@ {% trans "Product" %} - {% trans "Canceled" %} - {% trans "Refunded" %} + {% trans "Canceled" %}¹ {% trans "Expired" %} {% trans "Purchased" %} @@ -39,7 +38,6 @@ - {% trans "Pending" %} {% trans "Paid" %} {% trans "Total" %} @@ -51,7 +49,6 @@ {{ tup.0 }} {{ tup.0.num.canceled|togglesum:request.event.currency }} - {{ tup.0.num.refunded|togglesum:request.event.currency }} {{ tup.0.num.expired|togglesum:request.event.currency }} {{ tup.0.num.pending|togglesum:request.event.currency }} {{ tup.0.num.paid|togglesum:request.event.currency }} @@ -66,11 +63,6 @@ {{ item.num.canceled|togglesum:request.event.currency }} - - - {{ item.num.refunded|togglesum:request.event.currency }} - - {{ item.num.expired|togglesum:request.event.currency }} @@ -95,7 +87,6 @@ {{ var }} {{ var.num.canceled|togglesum:request.event.currency }} - {{ var.num.refunded|togglesum:request.event.currency }} {{ var.num.expired|togglesum:request.event.currency }} {{ var.num.pending|togglesum:request.event.currency }} {{ var.num.paid|togglesum:request.event.currency }} @@ -110,7 +101,6 @@ {% trans "Total" %} {{ total.num.canceled|togglesum:request.event.currency }} - {{ total.num.refunded|togglesum:request.event.currency }} {{ total.num.expired|togglesum:request.event.currency }} {{ total.num.pending|togglesum:request.event.currency }} {{ total.num.paid|togglesum:request.event.currency }} @@ -119,4 +109,7 @@ +

+ ¹ {% trans "If you click links in this column, you will only find orders that are canceled completely, while the numbers also include single canceled positions within valid orders." %} +

{% endblock %} diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index ba77dae1d..3b648a487 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -272,8 +272,8 @@ def event_index(request, organizer, event): } ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter( - Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0)) - | Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0)) + Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0)) + | Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0)) ).exists() ctx['has_pending_orders_with_full_payment'] = Order.annotate_overpayments(request.event.orders).filter( Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) & Q(require_approval=False) diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 99326de69..d13e93037 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -11,7 +11,7 @@ from django.conf import settings from django.contrib import messages from django.core.files import File from django.db import transaction -from django.db.models import Count +from django.db.models import Count, IntegerField, OuterRef, Subquery from django.http import ( FileResponse, Http404, HttpResponseNotAllowed, JsonResponse, ) @@ -82,9 +82,12 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView): permission = 'can_view_orders' def get_queryset(self): + s = OrderPosition.objects.filter( + order=OuterRef('pk') + ).order_by().values('order').annotate(k=Count('id')).values('k') qs = Order.objects.filter( event=self.request.event - ).annotate(pcnt=Count('positions', distinct=True)).select_related('invoice_address') + ).annotate(pcnt=Subquery(s, output_field=IntegerField())).select_related('invoice_address') qs = Order.annotate_overpayments(qs) @@ -188,7 +191,7 @@ class OrderDetail(OrderView): return buttons def get_items(self): - queryset = self.object.positions.all() + queryset = self.object.all_positions cartpos = queryset.order_by( 'item', 'variation' @@ -565,7 +568,9 @@ class OrderRefundView(OrderView): def start_form(self): return OrderRefundForm( order=self.order, - data=self.request.POST if self.request.method == "POST" else None, + data=self.request.POST if self.request.method == "POST" else ( + self.request.GET if "start-action" in self.request.GET else None + ), prefix='start', initial={ 'partial_amount': self.order.payment_refund_sum, @@ -576,204 +581,209 @@ class OrderRefundView(OrderView): } ) - def post(self, *args, **kwargs): - if self.start_form.is_valid(): - payments = self.order.payments.filter( - state=OrderPayment.PAYMENT_STATE_CONFIRMED - ) - for p in payments: - p.full_refund_possible = p.payment_provider.payment_refund_supported(p) - p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p) - p.propose_refund = Decimal('0.00') - p.available_amount = p.amount - p.refunded_amount + def choose_form(self): + payments = self.order.payments.filter( + state=OrderPayment.PAYMENT_STATE_CONFIRMED + ) + for p in payments: + p.full_refund_possible = p.payment_provider.payment_refund_supported(p) + p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p) + p.propose_refund = Decimal('0.00') + p.available_amount = p.amount - p.refunded_amount - unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible) + unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible) - # Algorithm to choose which payments are to be refunded to create the least hassle - if self.start_form.cleaned_data.get('mode') == 'full': - to_refund = full_refund = self.order.payment_refund_sum + # Algorithm to choose which payments are to be refunded to create the least hassle + if self.start_form.cleaned_data.get('mode') == 'full': + to_refund = full_refund = self.order.payment_refund_sum + else: + to_refund = full_refund = self.start_form.cleaned_data.get('partial_amount') + + while to_refund and unused_payments: + bigger = sorted([p for p in unused_payments if p.available_amount > to_refund], + key=lambda p: p.available_amount) + same = [p for p in unused_payments if p.available_amount == to_refund] + smaller = sorted([p for p in unused_payments if p.available_amount < to_refund], + key=lambda p: p.available_amount, + reverse=True) + if same: + for payment in same: + if payment.full_refund_possible or payment.partial_refund_possible: + payment.propose_refund = payment.available_amount + to_refund -= payment.available_amount + unused_payments.remove(payment) + break + elif bigger: + for payment in bigger: + if payment.partial_refund_possible: + payment.propose_refund = to_refund + to_refund -= to_refund + unused_payments.remove(payment) + break + elif smaller: + for payment in smaller: + if payment.full_refund_possible or payment.partial_refund_possible: + payment.propose_refund = payment.available_amount + to_refund -= payment.available_amount + unused_payments.remove(payment) + break + + if 'perform' in self.request.POST: + refund_selected = Decimal('0.00') + refunds = [] + + is_valid = True + manual_value = self.request.POST.get('refund-manual', '0') or '0' + manual_value = formats.sanitize_separators(manual_value) + try: + manual_value = Decimal(manual_value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False else: - to_refund = full_refund = self.start_form.cleaned_data.get('partial_amount') + refund_selected += manual_value + if manual_value: + refunds.append(OrderRefund( + order=self.order, + payment=None, + source=OrderRefund.REFUND_SOURCE_ADMIN, + state=( + OrderRefund.REFUND_STATE_DONE + if self.request.POST.get('manual_state') == 'done' + else OrderRefund.REFUND_STATE_CREATED + ), + amount=manual_value, + provider='manual' + )) - while to_refund and unused_payments: - bigger = sorted([p for p in unused_payments if p.available_amount > to_refund], - key=lambda p: p.available_amount) - same = [p for p in unused_payments if p.available_amount == to_refund] - smaller = sorted([p for p in unused_payments if p.available_amount < to_refund], - key=lambda p: p.available_amount, - reverse=True) - if same: - for payment in same: - if payment.full_refund_possible or payment.partial_refund_possible: - payment.propose_refund = payment.available_amount - to_refund -= payment.available_amount - unused_payments.remove(payment) - break - elif bigger: - for payment in bigger: - if payment.partial_refund_possible: - payment.propose_refund = to_refund - to_refund -= to_refund - unused_payments.remove(payment) - break - elif smaller: - for payment in smaller: - if payment.full_refund_possible or payment.partial_refund_possible: - payment.propose_refund = payment.available_amount - to_refund -= payment.available_amount - unused_payments.remove(payment) - break - - if 'perform' in self.request.POST: - refund_selected = Decimal('0.00') - refunds = [] - - is_valid = True - manual_value = self.request.POST.get('refund-manual', '0') or '0' - manual_value = formats.sanitize_separators(manual_value) - try: - manual_value = Decimal(manual_value) - except (DecimalException, TypeError): - messages.error(self.request, _('You entered an invalid number.')) - is_valid = False - else: - refund_selected += manual_value - if manual_value: + offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0' + offsetting_value = formats.sanitize_separators(offsetting_value) + try: + offsetting_value = Decimal(offsetting_value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False + else: + if offsetting_value: + refund_selected += offsetting_value + try: + order = Order.objects.get(code=self.request.POST.get('order-offsetting'), + event__organizer=self.request.organizer) + except Order.DoesNotExist: + messages.error(self.request, _('You entered an order that could not be found.')) + is_valid = False + else: refunds.append(OrderRefund( order=self.order, payment=None, source=OrderRefund.REFUND_SOURCE_ADMIN, - state=( - OrderRefund.REFUND_STATE_DONE - if self.request.POST.get('manual_state') == 'done' - else OrderRefund.REFUND_STATE_CREATED - ), - amount=manual_value, - provider='manual' + state=OrderRefund.REFUND_STATE_DONE, + execution_date=now(), + amount=offsetting_value, + provider='offsetting', + info=json.dumps({ + 'orders': [order.code] + }) )) - offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0' - offsetting_value = formats.sanitize_separators(offsetting_value) + for p in payments: + value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0' + value = formats.sanitize_separators(value) try: - offsetting_value = Decimal(offsetting_value) + value = Decimal(value) except (DecimalException, TypeError): messages.error(self.request, _('You entered an invalid number.')) is_valid = False else: - if offsetting_value: - refund_selected += offsetting_value - try: - order = Order.objects.get(code=self.request.POST.get('order-offsetting'), - event__organizer=self.request.organizer) - except Order.DoesNotExist: - messages.error(self.request, _('You entered an order that could not be found.')) - is_valid = False - else: - refunds.append(OrderRefund( - order=self.order, - payment=None, - source=OrderRefund.REFUND_SOURCE_ADMIN, - state=OrderRefund.REFUND_STATE_DONE, - execution_date=now(), - amount=offsetting_value, - provider='offsetting', - info=json.dumps({ - 'orders': [order.code] - }) - )) - - for p in payments: - value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0' - value = formats.sanitize_separators(value) - try: - value = Decimal(value) - except (DecimalException, TypeError): - messages.error(self.request, _('You entered an invalid number.')) + if value == 0: + continue + elif value > p.available_amount: + messages.error(self.request, _('You can not refund more than the amount of a ' + 'payment that is not yet refunded.')) is_valid = False - else: - if value == 0: - continue - elif value > p.available_amount: - messages.error(self.request, _('You can not refund more than the amount of a ' - 'payment that is not yet refunded.')) - is_valid = False - break - elif value != p.amount and not p.partial_refund_possible: - messages.error(self.request, _('You selected a partial refund for a payment method that ' - 'only supports full refunds.')) - is_valid = False - break - elif (p.partial_refund_possible or p.full_refund_possible) and value > 0: - refund_selected += value - refunds.append(OrderRefund( - order=self.order, - payment=p, - source=OrderRefund.REFUND_SOURCE_ADMIN, - state=OrderRefund.REFUND_STATE_CREATED, - amount=value, - provider=p.provider - )) + break + elif value != p.amount and not p.partial_refund_possible: + messages.error(self.request, _('You selected a partial refund for a payment method that ' + 'only supports full refunds.')) + is_valid = False + break + elif (p.partial_refund_possible or p.full_refund_possible) and value > 0: + refund_selected += value + refunds.append(OrderRefund( + order=self.order, + payment=p, + source=OrderRefund.REFUND_SOURCE_ADMIN, + state=OrderRefund.REFUND_STATE_CREATED, + amount=value, + provider=p.provider + )) - any_success = False - if refund_selected == full_refund and is_valid: - for r in refunds: - r.save() - self.order.log_action('pretix.event.order.refund.created', { - 'local_id': r.local_id, - 'provider': r.provider, - }, user=self.request.user) - if r.payment or r.provider == "offsetting": - try: - r.payment_provider.execute_refund(r) - except PaymentException as e: - r.state = OrderRefund.REFUND_STATE_FAILED - r.save() - messages.error(self.request, _('One of the refunds failed to be processed. You should ' - 'retry to refund in a different way. The error message ' - 'was: {}').format(str(e))) - else: - any_success = True - if r.state == OrderRefund.REFUND_STATE_DONE: - messages.success(self.request, _('A refund of {} has been processed.').format( - money_filter(r.amount, self.request.event.currency) - )) - elif r.state == OrderRefund.REFUND_STATE_CREATED: - messages.info(self.request, _('A refund of {} has been saved, but not yet ' - 'fully executed. You can mark it as complete ' - 'below.').format( - money_filter(r.amount, self.request.event.currency) - )) + any_success = False + if refund_selected == full_refund and is_valid: + for r in refunds: + r.save() + self.order.log_action('pretix.event.order.refund.created', { + 'local_id': r.local_id, + 'provider': r.provider, + }, user=self.request.user) + if r.payment or r.provider == "offsetting": + try: + r.payment_provider.execute_refund(r) + except PaymentException as e: + r.state = OrderRefund.REFUND_STATE_FAILED + r.save() + messages.error(self.request, _('One of the refunds failed to be processed. You should ' + 'retry to refund in a different way. The error message ' + 'was: {}').format(str(e))) else: any_success = True + if r.state == OrderRefund.REFUND_STATE_DONE: + messages.success(self.request, _('A refund of {} has been processed.').format( + money_filter(r.amount, self.request.event.currency) + )) + elif r.state == OrderRefund.REFUND_STATE_CREATED: + messages.info(self.request, _('A refund of {} has been saved, but not yet ' + 'fully executed. You can mark it as complete ' + 'below.').format( + money_filter(r.amount, self.request.event.currency) + )) + else: + any_success = True - if any_success: - if self.start_form.cleaned_data.get('action') == 'mark_refunded': - mark_order_refunded(self.order, user=self.request.user) - elif self.start_form.cleaned_data.get('action') == 'mark_pending': - if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0): - self.order.status = Order.STATUS_PENDING - self.order.set_expires( - now(), - self.order.event.subevents.filter( - id__in=self.order.positions.values_list('subevent_id', flat=True)) - ) - self.order.save(update_fields=['status', 'expires']) + if any_success: + if self.start_form.cleaned_data.get('action') == 'mark_refunded': + mark_order_refunded(self.order, user=self.request.user) + elif self.start_form.cleaned_data.get('action') == 'mark_pending': + if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0): + self.order.status = Order.STATUS_PENDING + self.order.set_expires( + now(), + self.order.event.subevents.filter( + id__in=self.order.positions.values_list('subevent_id', flat=True)) + ) + self.order.save(update_fields=['status', 'expires']) - return redirect(self.get_order_url()) - else: - messages.error(self.request, _('The refunds you selected do not match the selected total refund ' - 'amount.')) + return redirect(self.get_order_url()) + else: + messages.error(self.request, _('The refunds you selected do not match the selected total refund ' + 'amount.')) - return render(self.request, 'pretixcontrol/order/refund_choose.html', { - 'payments': payments, - 'remainder': to_refund, - 'order': self.order, - 'partial_amount': self.request.POST.get('start-partial_amount'), - 'start_form': self.start_form - }) + return render(self.request, 'pretixcontrol/order/refund_choose.html', { + 'payments': payments, + 'remainder': to_refund, + 'order': self.order, + 'partial_amount': self.request.POST.get('start-partial_amount'), + 'start_form': self.start_form + }) + + def post(self, *args, **kwargs): + if self.start_form.is_valid(): + return self.choose_form() return self.get(*args, **kwargs) def get(self, *args, **kwargs): + if self.start_form.is_valid(): + return self.choose_form() return render(self.request, 'pretixcontrol/order/refund_start.html', { 'form': self.start_form, 'order': self.order, @@ -839,6 +849,19 @@ class OrderTransition(OrderView): messages.success(self.request, _('The payment has been created successfully.')) elif self.order.cancel_allowed() and to == 'c': cancel_order(self.order, user=self.request.user, send_mail=self.request.POST.get("send_email") == "on") + self.order.refresh_from_db() + + if self.order.pending_sum < 0: + messages.success(self.request, _('The order has been canceled. You can now select how you want to ' + 'transfer the money back to the user.')) + return redirect(reverse('control:event.order.refunds.start', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + 'code': self.order.code + }) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}'.format( + self.order.pending_sum * -1 + )) + messages.success(self.request, _('The order has been canceled.')) elif self.order.status == Order.STATUS_PENDING and to == 'e': mark_order_expired(self.order, user=self.request.user) @@ -972,7 +995,7 @@ class OrderInvoiceReissue(OrderView): messages.error(self.request, _('The invoice has been cleaned of personal data.')) else: c = generate_cancellation(inv) - if self.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED): + if self.order.status != Order.STATUS_CANCELED: inv = generate_invoice(self.order) else: inv = c @@ -1313,7 +1336,7 @@ class OrderContactChange(OrderView): if self.form.cleaned_data['regenerate_secrets']: changed = True self.order.secret = generate_secret() - for op in self.order.positions.all(): + for op in self.order.all_positions.all(): op.secret = generate_position_secret() op.save() CachedTicket.objects.filter(order_position__order=self.order).delete() diff --git a/src/pretix/control/views/search.py b/src/pretix/control/views/search.py index 17d380d5b..fbd91677f 100644 --- a/src/pretix/control/views/search.py +++ b/src/pretix/control/views/search.py @@ -1,8 +1,8 @@ -from django.db.models import Q +from django.db.models import Count, IntegerField, OuterRef, Q, Subquery from django.utils.functional import cached_property from django.views.generic import ListView -from pretix.base.models import Order +from pretix.base.models import Order, OrderPosition from pretix.control.forms.filter import OrderSearchFilterForm from pretix.control.views import LargeResultSetPaginator, PaginationMixin @@ -24,6 +24,12 @@ class OrderSearch(PaginationMixin, ListView): def get_queryset(self): qs = Order.objects.select_related('invoice_address') + + s = OrderPosition.objects.filter( + order=OuterRef('pk') + ).order_by().values('order').annotate(k=Count('id')).values('k') + qs = qs.annotate(pcnt=Subquery(s, output_field=IntegerField())) + if not self.request.user.has_active_staff_session(self.request.session.session_key): qs = qs.filter( Q(event__organizer_id__in=self.request.user.teams.filter( diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py index 250c34fe7..a96225057 100644 --- a/src/pretix/plugins/banktransfer/tasks.py +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -50,9 +50,6 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, or if trans.order.status == Order.STATUS_PAID and trans.order.pending_sum <= Decimal('0.00'): trans.state = BankTransaction.STATE_DUPLICATE - elif trans.order.status == Order.STATUS_REFUNDED: - trans.state = BankTransaction.STATE_ERROR - trans.message = ugettext_noop('The order has already been refunded.') elif trans.order.status == Order.STATUS_CANCELED: trans.state = BankTransaction.STATE_ERROR trans.message = ugettext_noop('The order has already been canceled.') diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index 4289d82e1..2446de5bd 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -54,11 +54,6 @@ class ActionView(View): 'status': 'error', 'message': _('The order is already marked as paid.') }) - elif trans.order.status == Order.STATUS_REFUNDED: - return JsonResponse({ - 'status': 'error', - 'message': _('The order has already been refunded.') - }) elif trans.order.status == Order.STATUS_CANCELED: return JsonResponse({ 'status': 'error', @@ -170,8 +165,8 @@ class ActionView(View): qs = self.order_qs().order_by('pk').annotate(inr=Concat('invoices__prefix', 'invoices__invoice_no')).filter( code | Q(email__icontains=u) - | Q(positions__attendee_name_cached__icontains=u) - | Q(positions__attendee_email__icontains=u) + | Q(all_positions__attendee_name_cached__icontains=u) + | Q(all_positions__attendee_email__icontains=u) | Q(invoice_address__name_cached__icontains=u) | Q(invoice_address__company__icontains=u) | Q(invoices__invoice_no=u) diff --git a/src/pretix/plugins/reports/exporters.py b/src/pretix/plugins/reports/exporters.py index 892c0b375..874c29f77 100644 --- a/src/pretix/plugins/reports/exporters.py +++ b/src/pretix/plugins/reports/exporters.py @@ -157,7 +157,7 @@ class OverviewReport(Report): headlinestyle.fontSize = 15 headlinestyle.fontName = 'OpenSansBd' colwidths = [ - a * doc.width for a in (.25, 0.05, .075, 0.05, .075, 0.05, .075, 0.05, .075, 0.05, .075, 0.05, .075) + a * doc.width for a in (.33, 0.05, .075, 0.05, .075, 0.05, .075, 0.05, .075, 0.05, .075) ] tstyledata = [ ('SPAN', (1, 0), (2, 0)), @@ -188,7 +188,7 @@ class OverviewReport(Report): story.append(Spacer(1, 5 * mm)) tdata = [ [ - _('Product'), _('Canceled'), '', _('Refunded'), '', _('Expired'), '', _('Purchased'), + _('Product'), _('Canceled'), '', _('Expired'), '', _('Purchased'), '', '', '', '', '' ], [ @@ -209,7 +209,6 @@ class OverviewReport(Report): places = settings.CURRENCY_PLACES.get(self.event.currency, 2) states = ( ('canceled', Order.STATUS_CANCELED), - ('refunded', Order.STATUS_REFUNDED), ('expired', Order.STATUS_EXPIRED), ('pending', Order.STATUS_PENDING), ('paid', Order.STATUS_PAID), diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index 586d38f1d..0aa328e7c 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -65,9 +65,11 @@ class SenderView(EventPermissionRequiredMixin, FormView): statusq |= Q(status=Order.STATUS_PENDING, expires__lt=now()) orders = qs.filter(statusq) if form.cleaned_data.get('item'): - orders = orders.filter(positions__item=form.cleaned_data.get('item')) + orders = orders.filter(all_positions__item=form.cleaned_data.get('item'), + all_positions__canceled=False) if form.cleaned_data.get('subevent'): - orders = orders.filter(positions__subevent__in=(form.cleaned_data.get('subevent'),)) + orders = orders.filter(all_positions__subevent__in=(form.cleaned_data.get('subevent'),), + all_positions__canceled=False) orders = orders.distinct() self.output = {} diff --git a/src/pretix/plugins/statistics/views.py b/src/pretix/plugins/statistics/views.py index e5c292297..26b0b1743 100644 --- a/src/pretix/plugins/statistics/views.py +++ b/src/pretix/plugins/statistics/views.py @@ -61,7 +61,7 @@ class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView) if not ctx['obd_data']: oqs = Order.objects.annotate(payment_date=Subquery(p_date, output_field=DateTimeField())) if subevent: - oqs = oqs.filter(positions__subevent_id=subevent).distinct() + oqs = oqs.filter(positions__subevent_id=subevent, positions__canceled=False).distinct() ordered_by_day = {} for o in oqs.filter(event=self.request.event).values('datetime'): diff --git a/src/pretix/plugins/ticketoutputpdf/forms.py b/src/pretix/plugins/ticketoutputpdf/forms.py index e85667aa9..ec65fc58d 100644 --- a/src/pretix/plugins/ticketoutputpdf/forms.py +++ b/src/pretix/plugins/ticketoutputpdf/forms.py @@ -45,5 +45,5 @@ class TicketLayoutItemForm(forms.ModelForm): order_position__item_id=self.instance.item, provider='pdf' ).delete() CachedCombinedTicket.objects.filter( - order__positions__item=self.instance.item + order__all_positions__item=self.instance.item ).delete() diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html b/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html index 2ee10a9ee..583983c4a 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html @@ -7,11 +7,13 @@ {% trans "Payment pending" %} {% endif %} {% elif order.status == "p" %} - {% trans "Paid" %} + {% if order.count_positions == 0 %} + {% trans "Canceled (paid fee)" %} + {% else %} + {% trans "Paid" %} + {% endif %} {% elif order.status == "e" %} {% trans "Expired" %} {% elif order.status == "c" %} {% trans "Canceled" %} -{% elif order.status == "r" %} - {% trans "Refunded" %} {% endif %} diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index c6a0c067c..ff2f37a0e 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -600,3 +600,7 @@ details summary { padding-left: 20px; } +.pos-canceled * { + color: $brand-danger; + text-decoration: line-through !important; +} diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index c157d5f46..8c20536c2 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -104,6 +104,16 @@ def order(event, item, taxrule, question): secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w", pseudonymization_id="ABCDEFGHKL", ) + OrderPosition.objects.create( + order=o, + item=item, + variation=None, + price=Decimal("23"), + attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, + secret="YBiYJrmF5ufiTLdV1iDf", + pseudonymization_id="JKLM", + canceled=True + ) op.answers.create(question=question, answer='S') return o @@ -509,7 +519,7 @@ def test_refund_process_mark_refunded(token_client, organizer, event, order): assert resp.status_code == 200 assert r.state == OrderRefund.REFUND_STATE_DONE order.refresh_from_db() - assert order.status == Order.STATUS_REFUNDED + assert order.status == Order.STATUS_CANCELED resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format( organizer.slug, event.slug, order.code @@ -692,6 +702,14 @@ def test_orderposition_detail(token_client, organizer, event, order, item, quest assert len(resp.data['downloads']) == 1 +@pytest.mark.django_db +def test_orderposition_detail_no_canceled(token_client, organizer, event, order, item, question): + op = order.all_positions.filter(canceled=True).first() + resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(organizer.slug, event.slug, + op.pk)) + assert resp.status_code == 404 + + @pytest.mark.django_db def test_orderposition_delete(token_client, organizer, event, order, item, question): op = order.positions.first() @@ -720,6 +738,7 @@ def test_orderposition_delete(token_client, organizer, event, order, item, quest )) assert resp.status_code == 204 assert order.positions.count() == 1 + assert order.all_positions.count() == 3 order.refresh_from_db() assert order.total == Decimal('23.25') @@ -952,8 +971,8 @@ def test_order_mark_canceled_pending_no_email(token_client, organizer, event, or @pytest.mark.django_db -def test_order_mark_canceled_paid(token_client, organizer, event, order): - order.status = Order.STATUS_PAID +def test_order_mark_canceled_expired(token_client, organizer, event, order): + order.status = Order.STATUS_EXPIRED order.save() resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format( @@ -962,7 +981,7 @@ def test_order_mark_canceled_paid(token_client, organizer, event, order): ) assert resp.status_code == 400 order.refresh_from_db() - assert order.status == Order.STATUS_PAID + assert order.status == Order.STATUS_EXPIRED @pytest.mark.django_db @@ -975,7 +994,7 @@ def test_order_mark_paid_refunded(token_client, organizer, event, order): ) ) assert resp.status_code == 200 - assert resp.data['status'] == Order.STATUS_REFUNDED + assert resp.data['status'] == Order.STATUS_CANCELED @pytest.mark.django_db @@ -2411,7 +2430,7 @@ def test_refund_create_mark_refunded(token_client, organizer, event, order): assert r.info_data == {"foo": "bar"} assert r.payment.local_id == 2 order.refresh_from_db() - assert order.status == Order.STATUS_REFUNDED + assert order.status == Order.STATUS_CANCELED @pytest.mark.django_db diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index 22aef5f5a..3758c5e5e 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -58,6 +58,14 @@ def env(): price=Decimal("42.00"), positionid=2, ) + OrderPosition.objects.create( + order=o, + item=t_shirt, + variation=variation, + price=Decimal("42.00"), + positionid=3, + canceled=True + ) gs = GlobalSettingsObject() gs.settings.ecb_rates_date = date.today() gs.settings.ecb_rates_dict = json.dumps({ diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 0dfd9c8cb..b69df4e44 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -740,7 +740,7 @@ class OrderTestCase(BaseQuotaTestCase): q = Question.objects.create(question='Foo', type=Question.TYPE_BOOLEAN, event=self.event) self.item1.questions.add(q) assert self.order.can_modify_answers - self.order.status = Order.STATUS_REFUNDED + self.order.status = Order.STATUS_CANCELED assert not self.order.can_modify_answers self.order.status = Order.STATUS_PAID assert self.order.can_modify_answers @@ -963,7 +963,7 @@ class OrderTestCase(BaseQuotaTestCase): assert o.has_external_refund def test_pending_order_pending_refund(self): - self.order.status = Order.STATUS_REFUNDED + self.order.status = Order.STATUS_CANCELED self.order.save() self.order.payments.create( amount=Decimal('46.00'), @@ -1023,6 +1023,14 @@ class OrderTestCase(BaseQuotaTestCase): assert not o.has_pending_refund assert not o.has_external_refund + def test_canceled_positions(self): + self.op1.canceled = True + self.op1.save() + assert OrderPosition.objects.count() == 1 + assert OrderPosition.all.count() == 2 + assert self.order.positions.count() == 1 + assert self.order.all_positions.count() == 2 + class ItemCategoryTest(TestCase): """ diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 6bf69fa0f..41a07a80a 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -616,6 +616,26 @@ class OrderChangeManagerTests(TestCase): self.order.refresh_from_db() assert self.order.positions.count() == 1 assert self.order.total == self.op2.price + self.op1.refresh_from_db() + assert self.op1.canceled + + def test_cancel_with_addon(self): + self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True) + self.ticket.addons.create(addon_category=self.shirt.category) + self.ocm.add_position(self.shirt, None, Decimal('13.00'), self.op1) + self.ocm.commit() + self.order.refresh_from_db() + self.ocm = OrderChangeManager(self.order, None) + assert self.order.positions.count() == 3 + + self.ocm.cancel(self.op1) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 1 + assert self.order.total == self.op2.price + self.op1.refresh_from_db() + assert self.op1.canceled + assert self.op1.addons.first().canceled def test_free_to_paid(self): self.order.status = Order.STATUS_PAID diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index ccbcbf973..71fb1bbd5 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -53,6 +53,14 @@ def env(): price=Decimal("14"), attendee_name_parts={'full_name': "Peter", "_scheme": "full"} ) + OrderPosition.objects.create( + order=o, + item=ticket, + variation=None, + price=Decimal("14"), + canceled=True, + attendee_name_parts={'full_name': "Lukas Gelöscht", "_scheme": "full"} + ) return event, user, o, ticket @@ -125,6 +133,7 @@ def test_order_detail(client, env): response = client.get('/control/event/dummy/dummy/orders/FOO/') assert 'Early-bird' in response.rendered_content assert 'Peter' in response.rendered_content + assert 'Lukas Gelöscht' in response.rendered_content @pytest.mark.django_db @@ -256,23 +265,15 @@ def test_order_deny(client, env): # (Old status, new status, success expected) (Order.STATUS_CANCELED, Order.STATUS_PAID, False), (Order.STATUS_CANCELED, Order.STATUS_PENDING, False), - (Order.STATUS_CANCELED, Order.STATUS_REFUNDED, False), (Order.STATUS_CANCELED, Order.STATUS_EXPIRED, False), (Order.STATUS_PAID, Order.STATUS_PENDING, False), - (Order.STATUS_PAID, Order.STATUS_CANCELED, False), - (Order.STATUS_PAID, Order.STATUS_REFUNDED, False), + (Order.STATUS_PAID, Order.STATUS_CANCELED, True), (Order.STATUS_PAID, Order.STATUS_EXPIRED, False), (Order.STATUS_PENDING, Order.STATUS_CANCELED, True), (Order.STATUS_PENDING, Order.STATUS_PAID, True), - (Order.STATUS_PENDING, Order.STATUS_REFUNDED, False), (Order.STATUS_PENDING, Order.STATUS_EXPIRED, True), - - (Order.STATUS_REFUNDED, Order.STATUS_CANCELED, False), - (Order.STATUS_REFUNDED, Order.STATUS_PAID, False), - (Order.STATUS_REFUNDED, Order.STATUS_PENDING, False), - (Order.STATUS_REFUNDED, Order.STATUS_EXPIRED, False), ]) def test_order_transition(client, env, process): o = Order.objects.get(id=env[2].id) @@ -784,6 +785,11 @@ class OrderChangeTests(SoupTest): order=self.order, item=self.ticket, variation=None, price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter", "_scheme": "full"} ) + self.op3 = OrderPosition.objects.create( + order=self.order, item=self.ticket, variation=None, + price=Decimal("23.00"), attendee_name_parts={'full_name': "Lukas", "_scheme": "full"}, + canceled=True + ) self.quota = self.event.quotas.create(name="All", size=100) self.quota.items.add(self.ticket) self.quota.items.add(self.shirt) @@ -793,6 +799,14 @@ class OrderChangeTests(SoupTest): t.limit_events.add(self.event) self.client.login(email='dummy@dummy.dummy', password='dummy') + def test_do_not_show_canceled(self): + r = self.client.get('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + )) + assert self.op1.secret[:5] in r.rendered_content + assert self.op2.secret[:5] in r.rendered_content + assert self.op3.secret[:5] not in r.rendered_content + def test_change_item_success(self): self.client.post('/control/event/{}/{}/orders/{}/change'.format( self.event.organizer.slug, self.event.slug, self.order.code @@ -1137,7 +1151,7 @@ def test_process_refund_mark_refunded(client, env): r.refresh_from_db() assert r.state == OrderRefund.REFUND_STATE_DONE env[2].refresh_from_db() - assert env[2].status == Order.STATUS_REFUNDED + assert env[2].status == Order.STATUS_CANCELED @pytest.mark.django_db @@ -1240,7 +1254,7 @@ def test_refund_paid_order_fully_mark_as_refunded(client, env): assert r.provider == "manual" assert r.state == OrderRefund.REFUND_STATE_DONE assert r.amount == Decimal('14.00') - assert env[2].status == Order.STATUS_REFUNDED + assert env[2].status == Order.STATUS_CANCELED @pytest.mark.django_db diff --git a/src/tests/plugins/banktransfer/test_actions.py b/src/tests/plugins/banktransfer/test_actions.py index 8d8644f8f..2259ac455 100644 --- a/src/tests/plugins/banktransfer/test_actions.py +++ b/src/tests/plugins/banktransfer/test_actions.py @@ -162,7 +162,7 @@ def test_retry_refunded(env, client): state=BankTransaction.STATE_ERROR, amount=23, date='unknown', order=env[3]) client.login(email='dummy@dummy.dummy', password='dummy') - env[3].status = Order.STATUS_REFUNDED + env[3].status = Order.STATUS_CANCELED env[3].save() r = json.loads(client.post('/control/event/{}/{}/banktransfer/action/'.format(env[0].organizer.slug, env[0].slug), { 'action_{}'.format(trans.pk): 'retry', @@ -171,7 +171,7 @@ def test_retry_refunded(env, client): trans.refresh_from_db() assert trans.state == BankTransaction.STATE_ERROR env[3].refresh_from_db() - assert env[3].status == Order.STATUS_REFUNDED + assert env[3].status == Order.STATUS_CANCELED @pytest.mark.django_db diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index c5598db84..d3de1646f 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -63,6 +63,14 @@ class OrdersTest(TestCase): price=Decimal("23"), attendee_name_parts={'full_name': "Peter"} ) + self.deleted_pos = OrderPosition.objects.create( + order=self.order, + item=self.ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Lukas"}, + canceled=True + ) self.not_my_order = Order.objects.create( status=Order.STATUS_PENDING, event=self.event, @@ -130,15 +138,17 @@ class OrdersTest(TestCase): doc = BeautifulSoup(response.rendered_content, "lxml") assert len(doc.select(".cart-row")) > 0 assert "pending" in doc.select(".label-warning")[0].text.lower() + assert "Peter" in response.rendered_content + assert "Lukas" not in response.rendered_content def test_orders_modify_invalid(self): - self.order.status = Order.STATUS_REFUNDED + self.order.status = Order.STATUS_CANCELED self.order.save() self.client.get( '/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) ) self.order = Order.objects.get(id=self.order.id) - assert self.order.status == Order.STATUS_REFUNDED + assert self.order.status == Order.STATUS_CANCELED def test_orders_modify_attendee_optional(self): self.event.settings.set('attendee_names_asked', True) @@ -169,6 +179,8 @@ class OrdersTest(TestCase): '/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)) doc = BeautifulSoup(response.rendered_content, "lxml") self.assertEqual(len(doc.select('input[name=%s-attendee_name_parts_0]' % self.ticket_pos.id)), 1) + assert "Peter" in response.rendered_content + assert "Lukas" not in response.rendered_content # Not all required fields filled out, expect failure response = self.client.post(