Refactor cancelling positions and orders in the data model (#1088)

- [x] Data model
- [x] display in order view in backend
- [x] review all usages of OrderPositions.objects
- [x] review all usages of order.positions
- [x] review all other model usages
- [x] review plugins
- [x] plugins backwards-compatible API?
- [x] decide on way forward for REST API
- [x] need to cancel fees
- [x] tests
- [ ] plugins
  - [ ] gdpr
  - [ ] reports
- [x] docs
This commit is contained in:
Raphael Michel
2019-01-10 16:52:34 +01:00
committed by GitHub
parent 588955901c
commit 8abfbba9d0
41 changed files with 579 additions and 351 deletions

View File

@@ -26,7 +26,6 @@ status string Order status, o
* ``p`` paid * ``p`` paid
* ``e`` expired * ``e`` expired
* ``c`` canceled * ``c`` canceled
* ``r`` refunded
secret string The secret contained in the link sent to the customer secret string The secret contained in the link sent to the customer
email string The customer email address email string The customer email address
locale string The locale used for communication with this customer 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 └ vat_id_validated string ``True``, if the VAT ID has been validated against the
EU VAT service and validation was successful. This only EU VAT service and validation was successful. This only
happens in rare cases. happens in rare cases.
positions list of objects List of order positions (see below) positions list of objects List of non-canceled order positions (see below)
fees list of objects List of fees included in the order total (i.e. fees list of objects List of non-canceled fees included in the order total
payment fees) (i.e. payment fees)
├ fee_type string Type of fee (currently ``payment``, ``passbook``, ├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``other``) ``other``)
├ value money (string) Fee amount ├ value money (string) Fee amount
@@ -874,7 +873,7 @@ Order state operations
{ {
"code": "ABC12", "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 The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
``pseudonymization_id``. ``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/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Returns a list of all order positions within a given event. Returns a list of all order positions within a given event.

View File

@@ -324,7 +324,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
'secret', 'addon_to', 'subevent', 'answers') 'secret', 'addon_to', 'subevent', 'answers')
def validate_secret(self, secret): 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( raise ValidationError(
'You cannot assign a position secret that already exists.' 'You cannot assign a position secret that already exists.'
) )

View File

@@ -770,7 +770,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
raise PermissionDenied('The invoice file is no longer stored on the server.') raise PermissionDenied('The invoice file is no longer stored on the server.')
else: else:
c = generate_cancellation(inv) 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) inv = generate_invoice(inv.order)
else: else:
inv = c inv = c

View File

@@ -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
)
]

View File

@@ -355,7 +355,8 @@ class Event(EventMixin, LoggedModel):
if not really: if not really:
raise TypeError("Pass really=True as a parameter.") 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() OrderFee.objects.filter(order__event=self).delete()
OrderPayment.objects.filter(order__event=self).delete() OrderPayment.objects.filter(order__event=self).delete()
OrderRefund.objects.filter(order__event=self).delete() OrderRefund.objects.filter(order__event=self).delete()

View File

@@ -442,7 +442,7 @@ class Item(LoggedModel):
def allow_delete(self): def allow_delete(self):
from pretix.base.models.orders import OrderPosition 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 @cached_property
def has_variations(self): def has_variations(self):

View File

@@ -70,7 +70,6 @@ class Order(LockModel, LoggedModel):
* ``STATUS_PAID`` * ``STATUS_PAID``
* ``STATUS_EXPIRED`` * ``STATUS_EXPIRED``
* ``STATUS_CANCELED`` * ``STATUS_CANCELED``
* ``STATUS_REFUNDED``
:param event: The event this order belongs to :param event: The event this order belongs to
:type event: Event :type event: Event
@@ -102,13 +101,12 @@ class Order(LockModel, LoggedModel):
STATUS_PAID = "p" STATUS_PAID = "p"
STATUS_EXPIRED = "e" STATUS_EXPIRED = "e"
STATUS_CANCELED = "c" STATUS_CANCELED = "c"
STATUS_REFUNDED = "r" STATUS_REFUNDED = "c" # deprecated
STATUS_CHOICE = ( STATUS_CHOICE = (
(STATUS_PENDING, _("pending")), (STATUS_PENDING, _("pending")),
(STATUS_PAID, _("paid")), (STATUS_PAID, _("paid")),
(STATUS_EXPIRED, _("expired")), (STATUS_EXPIRED, _("expired")),
(STATUS_CANCELED, _("canceled")), (STATUS_CANCELED, _("canceled")),
(STATUS_REFUNDED, _("refunded"))
) )
code = models.CharField( code = models.CharField(
@@ -186,6 +184,28 @@ class Order(LockModel, LoggedModel):
def __str__(self): def __str__(self):
return self.full_code 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 @cached_property
def meta_info_data(self): def meta_info_data(self):
try: try:
@@ -207,7 +227,7 @@ class Order(LockModel, LoggedModel):
@property @property
def pending_sum(self): def pending_sum(self):
total = self.total total = self.total
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED): if self.status == Order.STATUS_CANCELED:
total = Decimal('0.00') total = Decimal('0.00')
payment_sum = self.payments.filter( payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) 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), pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
).annotate( ).annotate(
is_overpaid=Case( 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')), 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')), then=Value('1')),
default=Value('0'), default=Value('0'),
output_field=models.IntegerField() output_field=models.IntegerField()
@@ -336,8 +356,7 @@ class Order(LockModel, LoggedModel):
def cancel_allowed(self): def cancel_allowed(self):
return ( return (
self.status == Order.STATUS_PENDING self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.positions.exists()
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
) )
@staticmethod @staticmethod
@@ -399,7 +418,10 @@ class Order(LockModel, LoggedModel):
""" """
positions = self.positions.all().select_related('item') positions = self.positions.all().select_related('item')
cancelable = all([op.item.allow_cancel for op in positions]) 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 @property
def is_expired_by_time(self): def is_expired_by_time(self):
@@ -1254,11 +1276,19 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class ActivePositionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
class OrderFee(models.Model): class OrderFee(models.Model):
""" """
An OrderFee object represents a fee that is added to the order total independently of 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 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 :param value: Gross price of this fee
:type value: Decimal :type value: Decimal
:param order: Order this fee is charged with :param order: Order this fee is charged with
@@ -1275,16 +1305,20 @@ class OrderFee(models.Model):
:type tax_rule: TaxRule :type tax_rule: TaxRule
:param tax_value: The tax amount included in the price :param tax_value: The tax amount included in the price
:type tax_value: Decimal :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_PAYMENT = "payment"
FEE_TYPE_SHIPPING = "shipping" FEE_TYPE_SHIPPING = "shipping"
FEE_TYPE_SERVICE = "service" FEE_TYPE_SERVICE = "service"
FEE_TYPE_CANCELLATION = "cancellation"
FEE_TYPE_OTHER = "other" FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard" FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = ( FEE_TYPES = (
(FEE_TYPE_PAYMENT, _("Payment fee")), (FEE_TYPE_PAYMENT, _("Payment fee")),
(FEE_TYPE_SHIPPING, _("Shipping fee")), (FEE_TYPE_SHIPPING, _("Shipping fee")),
(FEE_TYPE_SERVICE, _("Service fee")), (FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
(FEE_TYPE_OTHER, _("Other fees")), (FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")), (FEE_TYPE_GIFTCARD, _("Gift card")),
) )
@@ -1296,7 +1330,7 @@ class OrderFee(models.Model):
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
verbose_name=_("Order"), verbose_name=_("Order"),
related_name='fees', related_name='all_fees',
on_delete=models.PROTECT on_delete=models.PROTECT
) )
fee_type = models.CharField( fee_type = models.CharField(
@@ -1317,6 +1351,10 @@ class OrderFee(models.Model):
max_digits=10, decimal_places=2, max_digits=10, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
canceled = models.BooleanField(default=False)
all = models.Manager()
objects = ActivePositionManager()
@property @property
def net_value(self): def net_value(self):
@@ -1371,6 +1409,9 @@ class OrderPosition(AbstractPosition):
of a specified type (or variation). This has all properties of of a specified type (or variation). This has all properties of
AbstractPosition. 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 :param order: The order this position is a part of
:type order: Order :type order: Order
:param positionid: A local ID of this position, counted for each order individually :param positionid: A local ID of this position, counted for each order individually
@@ -1383,6 +1424,8 @@ class OrderPosition(AbstractPosition):
:type tax_value: Decimal :type tax_value: Decimal
:param secret: The secret used for ticket QR codes :param secret: The secret used for ticket QR codes
:type secret: str :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 :param pseudonymization_id: The QR code content for lead scanning
:type pseudonymization_id: str :type pseudonymization_id: str
""" """
@@ -1390,7 +1433,7 @@ class OrderPosition(AbstractPosition):
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
verbose_name=_("Order"), verbose_name=_("Order"),
related_name='positions', related_name='all_positions',
on_delete=models.PROTECT on_delete=models.PROTECT
) )
tax_rate = models.DecimalField( tax_rate = models.DecimalField(
@@ -1412,6 +1455,10 @@ class OrderPosition(AbstractPosition):
unique=True, unique=True,
db_index=True db_index=True
) )
canceled = models.BooleanField(default=False)
all = models.Manager()
objects = ActivePositionManager()
class Meta: class Meta:
verbose_name = _("Order position") verbose_name = _("Order position")
@@ -1492,7 +1539,7 @@ class OrderPosition(AbstractPosition):
self._calculate_tax() self._calculate_tax()
self.order.touch() self.order.touch()
if self.pk is None: 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() self.secret = generate_position_secret()
if not self.pseudonymization_id: if not self.pseudonymization_id:
@@ -1508,7 +1555,7 @@ class OrderPosition(AbstractPosition):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True: while True:
code = get_random_string(length=10, allowed_chars=charset) 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 self.pseudonymization_id = code
return return

View File

@@ -97,7 +97,7 @@ class TaxRule(LoggedModel):
return ( return (
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists() 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 not self.event.items.filter(tax_rule=self).exists()
and self.event.settings.tax_rate_default != self and self.event.settings.tax_rate_default != self
) )

View File

@@ -78,7 +78,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
dt = datetime or now() dt = datetime or now()
# Fetch order position with related objects # Fetch order position with related objects
op = OrderPosition.objects.select_related( op = OrderPosition.all.select_related(
'item', 'variation', 'order', 'addon_to' 'item', 'variation', 'order', 'addon_to'
).prefetch_related( ).prefetch_related(
'item__questions', 'item__questions',
@@ -90,6 +90,12 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'answers' 'answers'
).get(pk=op.pk) ).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()} answers = {a.question: a for a in op.answers.all()}
require_answers = [] require_answers = []
for q in op.item.checkin_questions: for q in op.item.checkin_questions:

View File

@@ -234,7 +234,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
if trigger_pdf: if trigger_pdf:
invoice_pdf(invoice.pk) 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) generate_cancellation(invoice, trigger_pdf)
return invoice return invoice

View File

@@ -124,25 +124,12 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic @transaction.atomic
def mark_order_refunded(order, user=None, auth=None, api_token=None): def mark_order_refunded(order, user=None, auth=None, api_token=None):
""" oautha = auth.pk if isinstance(auth, OAuthApplication) else None
Mark this order as refunded. This sets the payment status and returns the order object. device = auth.pk if isinstance(auth, Device) else None
:param order: The order to change api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
:param user: The user that performed the change return _cancel_order(
""" order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
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
@transaction.atomic @transaction.atomic
@@ -1043,7 +1030,10 @@ class OrderChangeManager:
'addon_to': opa.addon_to_id, 'addon_to': opa.addon_to_id,
'old_price': opa.price, '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={ self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
'position': op.position.pk, 'position': op.position.pk,
'positionid': op.position.positionid, 'positionid': op.position.positionid,
@@ -1052,7 +1042,10 @@ class OrderChangeManager:
'old_price': op.position.price, 'old_price': op.position.price,
'addon_to': None, '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): elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create( pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to, item=op.item, variation=op.variation, addon_to=op.addon_to,
@@ -1117,7 +1110,7 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass 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: if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider() pp = self._get_payment_provider()
if pp: if pp:
@@ -1180,7 +1173,7 @@ class OrderChangeManager:
return payment_sum - refund_sum return payment_sum - refund_sum
def _recalculate_total_and_payment_fee(self): 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') payment_fee = Decimal('0.00')
if self.open_payment: if self.open_payment:
current_fee = Decimal('0.00') current_fee = Decimal('0.00')

View File

@@ -1,7 +1,7 @@
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, Iterable, List, Tuple 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 django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition 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' 'variations'
).order_by('category__position', 'category_id', 'position', 'name') ).order_by('category__position', 'category_id', 'position', 'name')
qs = OrderPosition.objects qs = OrderPosition.all
if subevent: if subevent:
qs = qs.filter(subevent=subevent) qs = qs.filter(subevent=subevent)
counters = qs.filter( counters = qs.filter(
order__event=event order__event=event
).annotate(
status=Case(
When(canceled=True, then=Value('c')),
default=F('order__status')
)
).values( ).values(
'item', 'variation', 'order__status' 'item', 'variation', 'status'
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by() ).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
states = { states = {
'canceled': Order.STATUS_CANCELED, 'canceled': Order.STATUS_CANCELED,
'refunded': Order.STATUS_REFUNDED,
'paid': Order.STATUS_PAID, 'paid': Order.STATUS_PAID,
'pending': Order.STATUS_PENDING, 'pending': Order.STATUS_PENDING,
'expired': Order.STATUS_EXPIRED, '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(): for l, s in states.items():
num[l] = { num[l] = {
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value']) (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']) 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 = [] payment_items = []
if not subevent: if not subevent:
counters = OrderFee.objects.filter( counters = OrderFee.all.filter(
order__event=event order__event=event
).annotate(
status=Case(
When(canceled=True, then=Value('c')),
default=F('order__status')
)
).values( ).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() ).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
for l, s in states.items(): for l, s in states.items():
num[l] = { num[l] = {
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value']) (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']) num['total'] = dictsum(num['pending'], num['paid'])

View File

@@ -133,12 +133,12 @@ class EmailAddressShredder(BaseDataShredder):
}, indent=4) }, indent=4)
yield 'emails-by-attendee.json', 'application/json', json.dumps({ yield 'emails-by-attendee.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): op.attendee_email '{}-{}'.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) }, indent=4)
@transaction.atomic @transaction.atomic
def shred_data(self): 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(): for o in self.event.orders.all():
o.email = None o.email = None
@@ -202,7 +202,7 @@ class AttendeeNameShredder(BaseDataShredder):
def generate_files(self) -> List[Tuple[str, str, str]]: def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'attendee-names.json', 'application/json', json.dumps({ yield 'attendee-names.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name '{}-{}'.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 order__event=self.event
).filter( ).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False)) Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
@@ -211,7 +211,7 @@ class AttendeeNameShredder(BaseDataShredder):
@transaction.atomic @transaction.atomic
def shred_data(self): def shred_data(self):
OrderPosition.objects.filter( OrderPosition.all.filter(
order__event=self.event order__event=self.event
).filter( ).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False)) 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]]: def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'question-answers.json', 'application/json', json.dumps({ yield 'question-answers.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data '{}-{}'.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) }, indent=4)
@transaction.atomic @transaction.atomic

View File

@@ -100,14 +100,13 @@ class OrderFilterForm(FilterForm):
label=_('Order status'), label=_('Order status'),
choices=( choices=(
('', _('All orders')), ('', _('All orders')),
('p', _('Paid')), (Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
('n', _('Pending')), (Order.STATUS_PENDING, _('Pending')),
('o', _('Pending (overdue)')), ('o', _('Pending (overdue)')),
('np', _('Pending or paid')), (Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
('e', _('Expired')), (Order.STATUS_EXPIRED, _('Expired')),
('ne', _('Pending or expired')), (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
('c', _('Canceled')), (Order.STATUS_CANCELED, _('Canceled')),
('r', _('Refunded')),
), ),
required=False, required=False,
) )
@@ -201,14 +200,13 @@ class EventOrderFilterForm(OrderFilterForm):
label=_('Order status'), label=_('Order status'),
choices=( choices=(
('', _('All orders')), ('', _('All orders')),
('p', _('Paid')), (Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
('n', _('Pending')), (Order.STATUS_PENDING, _('Pending')),
('o', _('Pending (overdue)')), ('o', _('Pending (overdue)')),
('np', _('Pending or paid')), (Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
('e', _('Expired')), (Order.STATUS_EXPIRED, _('Expired')),
('ne', _('Pending or expired')), (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
('c', _('Canceled')), (Order.STATUS_CANCELED, _('Canceled')),
('r', _('Refunded')),
('pa', _('Approval pending')), ('pa', _('Approval pending')),
('overpaid', _('Overpaid')), ('overpaid', _('Overpaid')),
('underpaid', _('Underpaid')), ('underpaid', _('Underpaid')),
@@ -246,10 +244,10 @@ class EventOrderFilterForm(OrderFilterForm):
qs = super().filter_qs(qs) qs = super().filter_qs(qs)
if fdata.get('item'): 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'): 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: if fdata.get('question') and fdata.get('answer') is not None:
q = fdata.get('question') q = fdata.get('question')
@@ -278,8 +276,8 @@ class EventOrderFilterForm(OrderFilterForm):
if fdata.get('status') == 'overpaid': if fdata.get('status') == 'overpaid':
qs = qs.filter( qs = qs.filter(
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0)) Q(~Q(status=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_rc__lt=0))
) )
elif fdata.get('status') == 'pendingpaid': elif fdata.get('status') == 'pendingpaid':
qs = qs.filter( qs = qs.filter(

View File

@@ -367,8 +367,7 @@ class OrderRefundForm(forms.Form):
required=False, required=False,
widget=forms.RadioSelect, widget=forms.RadioSelect,
choices=( choices=(
('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will ' ('mark_refunded', _('Cancel the order. All tickets will no longer work. This can not be reverted.')),
'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 ' ('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
'payment method.')), 'payment method.')),
('do_nothing', _('Do nothing and keep the order as it is.')), ('do_nothing', _('Do nothing and keep the order as it is.')),
@@ -391,7 +390,7 @@ class OrderRefundForm(forms.Form):
self.order = kwargs.pop('order') self.order = kwargs.pop('order')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
change_decimal_field(self.fields['partial_amount'], self.order.event.currency) 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'] del self.fields['action']
def clean_partial_amount(self): def clean_partial_amount(self):

View File

@@ -63,7 +63,7 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_item = str(event.items.get(pk=data['old_item'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=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', '?'), posid=data.get('positionid', '?'),
old_item=old_item, old_item=old_item,
old_price=money_filter(Decimal(data['old_price']), event.currency), old_price=money_filter(Decimal(data['old_price']), event.currency),

View File

@@ -24,7 +24,6 @@
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option> <option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option> <option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option> <option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
<option value="r" {% if request.GET.status == "r" %}selected="selected"{% endif %}>{% trans "Refunded" %}</option>
</select> </select>
<select name="item" class="form-control"> <select name="item" class="form-control">
<option value="">{% trans "All products" %}</option> <option value="">{% trans "All products" %}</option>

View File

@@ -10,6 +10,12 @@
<p>{% blocktrans trimmed %} <p>{% blocktrans trimmed %}
Do you really want to cancel this order? You cannot revert this action. Do you really want to cancel this order? You cannot revert this action.
{% endblocktrans %}</p> {% endblocktrans %}</p>
{% if order.payment_refund_sum > 0 %}
<p>{% blocktrans trimmed %}
This will <strong>not</strong> automatically transfer the money back, but you will be offered options to
refund the payment afterwards.
{% endblocktrans %}</p>
{% endif %}
<form method="post" href=""> <form method="post" href="">
{% csrf_token %} {% csrf_token %}

View File

@@ -145,7 +145,7 @@
<label> <label>
<input name="{{ position.form.prefix }}-operation" type="radio" value="cancel" <input name="{{ position.form.prefix }}-operation" type="radio" value="cancel"
{% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}> {% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}>
{% trans "Remove from order" %} {% trans "Cancel position" %}
{% if position.addons.exists %} {% if position.addons.exists %}
<em class="text-danger"> <em class="text-danger">
{% trans "Removing this position will also remove all add-ons to this position." %} {% trans "Removing this position will also remove all add-ons to this position." %}

View File

@@ -50,11 +50,6 @@
{% trans "Cancel order" %} {% trans "Cancel order" %}
</a> </a>
{% endif %} {% endif %}
{% if order.payment_refund_sum > 0 %}
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Create a refund" %}
</a>
{% endif %}
{% endif %} {% endif %}
<a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}" <a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}"
@@ -171,11 +166,11 @@
action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}"> action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-default btn-xs" <button class="btn btn-default btn-xs"
{% if order.status != "r" and order.status != "c" %} {% if order.status != "c" %}
data-toggle="tooltip" data-toggle="tooltip"
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}" title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
{% endif %}> {% endif %}>
{% if order.status == "r" or order.status == "c" %} {% if order.status == "c" %}
{% trans "Generate cancellation" %} {% trans "Generate cancellation" %}
{% else %} {% else %}
{% trans "Cancel and reissue" %} {% trans "Cancel and reissue" %}
@@ -234,7 +229,7 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% for line in items.positions %} {% for line in items.positions %}
<div class="row-fluid product-row"> <div class="row-fluid product-row {% if line.canceled %}pos-canceled{% endif %}">
<div class="col-md-9 col-xs-6"> <div class="col-md-9 col-xs-6">
{% if line.addon_to %} {% if line.addon_to %}
<span class="addon-signifier">+</span> <span class="addon-signifier">+</span>
@@ -260,24 +255,26 @@
<br/> <br/>
<span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }} <span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
{% endif %} {% endif %}
<div class="position-buttons"> {% if not line.canceled %}
{% if not line.addon_to or request.event.settings.ticket_download_addons %} <div class="position-buttons">
{% if line.item.admission or request.event.settings.ticket_download_nonadm %} {% if not line.addon_to or request.event.settings.ticket_download_addons %}
{% for b in download_buttons %} {% if line.item.admission or request.event.settings.ticket_download_nonadm %}
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}" {% for b in download_buttons %}
method="post" data-asynctask data-asynctask-download <form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
class="form-inline helper-display-inline"> method="post" data-asynctask data-asynctask-download
{% csrf_token %} class="form-inline helper-display-inline">
<button type="submit" {% csrf_token %}
class="btn btn-xs btn-default"> <button type="submit"
<span class="fa {{ b.icon }}"></span> {{ b.text }} class="btn btn-xs btn-default">
</button> <span class="fa {{ b.icon }}"></span> {{ b.text }}
</form> </button>
{% endfor %} </form>
{% endfor %}
{% endif %}
{% 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 %} </div>
</div> {% endif %}
{% if line.has_questions %} {% if line.has_questions %}
<dl> <dl>
{% if line.item.admission and event.settings.attendee_names_asked %} {% if line.item.admission and event.settings.attendee_names_asked %}
@@ -486,6 +483,11 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if order.payment_refund_sum > 0 %}
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Create a refund" %}
</a>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,11 +7,13 @@
<span class="label label-warning {{ class }}">{% trans "Pending" %}</span> <span class="label label-warning {{ class }}">{% trans "Pending" %}</span>
{% endif %} {% endif %}
{% elif order.status == "p" %} {% elif order.status == "p" %}
<span class="label label-success {{ class }}">{% trans "Paid" %}</span> {% if order.count_positions == 0 %}
<span class="label label-info {{ class }}">{% trans "Canceled (paid fee)" %}</span>
{% else %}
<span class="label label-success {{ class }}">{% trans "Paid" %}</span>
{% endif %}
{% elif order.status == "e" %} {# expired #} {% elif order.status == "e" %} {# expired #}
<span class="label label-danger {{ class }}">{% trans "Expired" %}</span> <span class="label label-danger {{ class }}">{% trans "Expired" %}</span>
{% elif order.status == "c" %} {% elif order.status == "c" %}
<span class="label label-danger {{ class }}">{% trans "Canceled" %}</span> <span class="label label-danger {{ class }}">{% trans "Canceled" %}</span>
{% elif order.status == "r" %}
<span class="label label-danger {{ class }}">{% trans "Refunded" %}</span>
{% endif %} {% endif %}

View File

@@ -142,7 +142,7 @@
{% endif %} {% endif %}
{{ o.total|money:request.event.currency }} {{ o.total|money:request.event.currency }}
</td> </td>
<td class="text-right">{{ o.pcnt }}</td> <td class="text-right">{{ o.pcnt|default_if_none:"0" }}</td>
<td class="text-right">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td> <td class="text-right">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -30,8 +30,7 @@
<thead> <thead>
<tr> <tr>
<th>{% trans "Product" %}</th> <th>{% trans "Product" %}</th>
<th>{% trans "Canceled" %}</th> <th>{% trans "Canceled" %}¹</th>
<th>{% trans "Refunded" %}</th>
<th>{% trans "Expired" %}</th> <th>{% trans "Expired" %}</th>
<th colspan="3">{% trans "Purchased" %}</th> <th colspan="3">{% trans "Purchased" %}</th>
</tr> </tr>
@@ -39,7 +38,6 @@
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th>{% trans "Pending" %}</th> <th>{% trans "Pending" %}</th>
<th>{% trans "Paid" %}</th> <th>{% trans "Paid" %}</th>
<th>{% trans "Total" %}</th> <th>{% trans "Total" %}</th>
@@ -51,7 +49,6 @@
<tr class="category"> <tr class="category">
<th>{{ tup.0 }}</th> <th>{{ tup.0 }}</th>
<th>{{ tup.0.num.canceled|togglesum:request.event.currency }}</th> <th>{{ tup.0.num.canceled|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.refunded|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.expired|togglesum:request.event.currency }}</th> <th>{{ tup.0.num.expired|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.pending|togglesum:request.event.currency }}</th> <th>{{ tup.0.num.pending|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.paid|togglesum:request.event.currency }}</th> <th>{{ tup.0.num.paid|togglesum:request.event.currency }}</th>
@@ -66,11 +63,6 @@
{{ item.num.canceled|togglesum:request.event.currency }} {{ item.num.canceled|togglesum:request.event.currency }}
</a> </a>
</td> </td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=r&amp;provider={{ item.provider }}">
{{ item.num.refunded|togglesum:request.event.currency }}
</a>
</td>
<td> <td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=e&amp;provider={{ item.provider }}"> <a href="{{ listurl }}?item={{ item.id }}&amp;status=e&amp;provider={{ item.provider }}">
{{ item.num.expired|togglesum:request.event.currency }} {{ item.num.expired|togglesum:request.event.currency }}
@@ -95,7 +87,6 @@
<tr class="variation {% if tup.0 %}categorized{% endif %}"> <tr class="variation {% if tup.0 %}categorized{% endif %}">
<td>{{ var }}</td> <td>{{ var }}</td>
<td>{{ var.num.canceled|togglesum:request.event.currency }}</td> <td>{{ var.num.canceled|togglesum:request.event.currency }}</td>
<td>{{ var.num.refunded|togglesum:request.event.currency }}</td>
<td>{{ var.num.expired|togglesum:request.event.currency }}</td> <td>{{ var.num.expired|togglesum:request.event.currency }}</td>
<td>{{ var.num.pending|togglesum:request.event.currency }}</td> <td>{{ var.num.pending|togglesum:request.event.currency }}</td>
<td>{{ var.num.paid|togglesum:request.event.currency }}</td> <td>{{ var.num.paid|togglesum:request.event.currency }}</td>
@@ -110,7 +101,6 @@
<tr class="total"> <tr class="total">
<th>{% trans "Total" %}</th> <th>{% trans "Total" %}</th>
<th>{{ total.num.canceled|togglesum:request.event.currency }}</th> <th>{{ total.num.canceled|togglesum:request.event.currency }}</th>
<th>{{ total.num.refunded|togglesum:request.event.currency }}</th>
<th>{{ total.num.expired|togglesum:request.event.currency }}</th> <th>{{ total.num.expired|togglesum:request.event.currency }}</th>
<th>{{ total.num.pending|togglesum:request.event.currency }}</th> <th>{{ total.num.pending|togglesum:request.event.currency }}</th>
<th>{{ total.num.paid|togglesum:request.event.currency }}</th> <th>{{ total.num.paid|togglesum:request.event.currency }}</th>
@@ -119,4 +109,7 @@
</tfoot> </tfoot>
</table> </table>
</div> </div>
<p class="help-block">
¹ {% 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." %}
</p>
{% endblock %} {% endblock %}

View File

@@ -272,8 +272,8 @@ def event_index(request, organizer, event):
} }
ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter( 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=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_rc__lt=0))
).exists() ).exists()
ctx['has_pending_orders_with_full_payment'] = Order.annotate_overpayments(request.event.orders).filter( 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) Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) & Q(require_approval=False)

View File

@@ -11,7 +11,7 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.files import File from django.core.files import File
from django.db import transaction from django.db import transaction
from django.db.models import Count from django.db.models import Count, IntegerField, OuterRef, Subquery
from django.http import ( from django.http import (
FileResponse, Http404, HttpResponseNotAllowed, JsonResponse, FileResponse, Http404, HttpResponseNotAllowed, JsonResponse,
) )
@@ -82,9 +82,12 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView):
permission = 'can_view_orders' permission = 'can_view_orders'
def get_queryset(self): 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( qs = Order.objects.filter(
event=self.request.event 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) qs = Order.annotate_overpayments(qs)
@@ -188,7 +191,7 @@ class OrderDetail(OrderView):
return buttons return buttons
def get_items(self): def get_items(self):
queryset = self.object.positions.all() queryset = self.object.all_positions
cartpos = queryset.order_by( cartpos = queryset.order_by(
'item', 'variation' 'item', 'variation'
@@ -565,7 +568,9 @@ class OrderRefundView(OrderView):
def start_form(self): def start_form(self):
return OrderRefundForm( return OrderRefundForm(
order=self.order, 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', prefix='start',
initial={ initial={
'partial_amount': self.order.payment_refund_sum, 'partial_amount': self.order.payment_refund_sum,
@@ -576,204 +581,209 @@ class OrderRefundView(OrderView):
} }
) )
def post(self, *args, **kwargs): def choose_form(self):
if self.start_form.is_valid(): payments = self.order.payments.filter(
payments = self.order.payments.filter( state=OrderPayment.PAYMENT_STATE_CONFIRMED
state=OrderPayment.PAYMENT_STATE_CONFIRMED )
) for p in payments:
for p in payments: p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
p.full_refund_possible = p.payment_provider.payment_refund_supported(p) p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p) p.propose_refund = Decimal('0.00')
p.propose_refund = Decimal('0.00') p.available_amount = p.amount - p.refunded_amount
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 # Algorithm to choose which payments are to be refunded to create the least hassle
if self.start_form.cleaned_data.get('mode') == 'full': if self.start_form.cleaned_data.get('mode') == 'full':
to_refund = full_refund = self.order.payment_refund_sum 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: 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: offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0'
bigger = sorted([p for p in unused_payments if p.available_amount > to_refund], offsetting_value = formats.sanitize_separators(offsetting_value)
key=lambda p: p.available_amount) try:
same = [p for p in unused_payments if p.available_amount == to_refund] offsetting_value = Decimal(offsetting_value)
smaller = sorted([p for p in unused_payments if p.available_amount < to_refund], except (DecimalException, TypeError):
key=lambda p: p.available_amount, messages.error(self.request, _('You entered an invalid number.'))
reverse=True) is_valid = False
if same: else:
for payment in same: if offsetting_value:
if payment.full_refund_possible or payment.partial_refund_possible: refund_selected += offsetting_value
payment.propose_refund = payment.available_amount try:
to_refund -= payment.available_amount order = Order.objects.get(code=self.request.POST.get('order-offsetting'),
unused_payments.remove(payment) event__organizer=self.request.organizer)
break except Order.DoesNotExist:
elif bigger: messages.error(self.request, _('You entered an order that could not be found.'))
for payment in bigger: is_valid = False
if payment.partial_refund_possible: else:
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:
refunds.append(OrderRefund( refunds.append(OrderRefund(
order=self.order, order=self.order,
payment=None, payment=None,
source=OrderRefund.REFUND_SOURCE_ADMIN, source=OrderRefund.REFUND_SOURCE_ADMIN,
state=( state=OrderRefund.REFUND_STATE_DONE,
OrderRefund.REFUND_STATE_DONE execution_date=now(),
if self.request.POST.get('manual_state') == 'done' amount=offsetting_value,
else OrderRefund.REFUND_STATE_CREATED provider='offsetting',
), info=json.dumps({
amount=manual_value, 'orders': [order.code]
provider='manual' })
)) ))
offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0' for p in payments:
offsetting_value = formats.sanitize_separators(offsetting_value) value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0'
value = formats.sanitize_separators(value)
try: try:
offsetting_value = Decimal(offsetting_value) value = Decimal(value)
except (DecimalException, TypeError): except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.')) messages.error(self.request, _('You entered an invalid number.'))
is_valid = False is_valid = False
else: else:
if offsetting_value: if value == 0:
refund_selected += offsetting_value continue
try: elif value > p.available_amount:
order = Order.objects.get(code=self.request.POST.get('order-offsetting'), messages.error(self.request, _('You can not refund more than the amount of a '
event__organizer=self.request.organizer) 'payment that is not yet refunded.'))
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.'))
is_valid = False is_valid = False
else: break
if value == 0: elif value != p.amount and not p.partial_refund_possible:
continue messages.error(self.request, _('You selected a partial refund for a payment method that '
elif value > p.available_amount: 'only supports full refunds.'))
messages.error(self.request, _('You can not refund more than the amount of a ' is_valid = False
'payment that is not yet refunded.')) break
is_valid = False elif (p.partial_refund_possible or p.full_refund_possible) and value > 0:
break refund_selected += value
elif value != p.amount and not p.partial_refund_possible: refunds.append(OrderRefund(
messages.error(self.request, _('You selected a partial refund for a payment method that ' order=self.order,
'only supports full refunds.')) payment=p,
is_valid = False source=OrderRefund.REFUND_SOURCE_ADMIN,
break state=OrderRefund.REFUND_STATE_CREATED,
elif (p.partial_refund_possible or p.full_refund_possible) and value > 0: amount=value,
refund_selected += value provider=p.provider
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 any_success = False
if refund_selected == full_refund and is_valid: if refund_selected == full_refund and is_valid:
for r in refunds: for r in refunds:
r.save() r.save()
self.order.log_action('pretix.event.order.refund.created', { self.order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id, 'local_id': r.local_id,
'provider': r.provider, 'provider': r.provider,
}, user=self.request.user) }, user=self.request.user)
if r.payment or r.provider == "offsetting": if r.payment or r.provider == "offsetting":
try: try:
r.payment_provider.execute_refund(r) r.payment_provider.execute_refund(r)
except PaymentException as e: except PaymentException as e:
r.state = OrderRefund.REFUND_STATE_FAILED r.state = OrderRefund.REFUND_STATE_FAILED
r.save() r.save()
messages.error(self.request, _('One of the refunds failed to be processed. You should ' messages.error(self.request, _('One of the refunds failed to be processed. You should '
'retry to refund in a different way. The error message ' 'retry to refund in a different way. The error message '
'was: {}').format(str(e))) '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: else:
any_success = True 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 any_success:
if self.start_form.cleaned_data.get('action') == 'mark_refunded': if self.start_form.cleaned_data.get('action') == 'mark_refunded':
mark_order_refunded(self.order, user=self.request.user) mark_order_refunded(self.order, user=self.request.user)
elif self.start_form.cleaned_data.get('action') == 'mark_pending': elif self.start_form.cleaned_data.get('action') == 'mark_pending':
if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0): if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0):
self.order.status = Order.STATUS_PENDING self.order.status = Order.STATUS_PENDING
self.order.set_expires( self.order.set_expires(
now(), now(),
self.order.event.subevents.filter( self.order.event.subevents.filter(
id__in=self.order.positions.values_list('subevent_id', flat=True)) id__in=self.order.positions.values_list('subevent_id', flat=True))
) )
self.order.save(update_fields=['status', 'expires']) self.order.save(update_fields=['status', 'expires'])
return redirect(self.get_order_url()) return redirect(self.get_order_url())
else: else:
messages.error(self.request, _('The refunds you selected do not match the selected total refund ' messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.')) 'amount.'))
return render(self.request, 'pretixcontrol/order/refund_choose.html', { return render(self.request, 'pretixcontrol/order/refund_choose.html', {
'payments': payments, 'payments': payments,
'remainder': to_refund, 'remainder': to_refund,
'order': self.order, 'order': self.order,
'partial_amount': self.request.POST.get('start-partial_amount'), 'partial_amount': self.request.POST.get('start-partial_amount'),
'start_form': self.start_form '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) return self.get(*args, **kwargs)
def get(self, *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', { return render(self.request, 'pretixcontrol/order/refund_start.html', {
'form': self.start_form, 'form': self.start_form,
'order': self.order, 'order': self.order,
@@ -839,6 +849,19 @@ class OrderTransition(OrderView):
messages.success(self.request, _('The payment has been created successfully.')) messages.success(self.request, _('The payment has been created successfully.'))
elif self.order.cancel_allowed() and to == 'c': 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") 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.')) messages.success(self.request, _('The order has been canceled.'))
elif self.order.status == Order.STATUS_PENDING and to == 'e': elif self.order.status == Order.STATUS_PENDING and to == 'e':
mark_order_expired(self.order, user=self.request.user) 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.')) messages.error(self.request, _('The invoice has been cleaned of personal data.'))
else: else:
c = generate_cancellation(inv) 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) inv = generate_invoice(self.order)
else: else:
inv = c inv = c
@@ -1313,7 +1336,7 @@ class OrderContactChange(OrderView):
if self.form.cleaned_data['regenerate_secrets']: if self.form.cleaned_data['regenerate_secrets']:
changed = True changed = True
self.order.secret = generate_secret() 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.secret = generate_position_secret()
op.save() op.save()
CachedTicket.objects.filter(order_position__order=self.order).delete() CachedTicket.objects.filter(order_position__order=self.order).delete()

View File

@@ -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.utils.functional import cached_property
from django.views.generic import ListView 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.forms.filter import OrderSearchFilterForm
from pretix.control.views import LargeResultSetPaginator, PaginationMixin from pretix.control.views import LargeResultSetPaginator, PaginationMixin
@@ -24,6 +24,12 @@ class OrderSearch(PaginationMixin, ListView):
def get_queryset(self): def get_queryset(self):
qs = Order.objects.select_related('invoice_address') 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): if not self.request.user.has_active_staff_session(self.request.session.session_key):
qs = qs.filter( qs = qs.filter(
Q(event__organizer_id__in=self.request.user.teams.filter( Q(event__organizer_id__in=self.request.user.teams.filter(

View File

@@ -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'): if trans.order.status == Order.STATUS_PAID and trans.order.pending_sum <= Decimal('0.00'):
trans.state = BankTransaction.STATE_DUPLICATE 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: elif trans.order.status == Order.STATUS_CANCELED:
trans.state = BankTransaction.STATE_ERROR trans.state = BankTransaction.STATE_ERROR
trans.message = ugettext_noop('The order has already been canceled.') trans.message = ugettext_noop('The order has already been canceled.')

View File

@@ -54,11 +54,6 @@ class ActionView(View):
'status': 'error', 'status': 'error',
'message': _('The order is already marked as paid.') '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: elif trans.order.status == Order.STATUS_CANCELED:
return JsonResponse({ return JsonResponse({
'status': 'error', '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( qs = self.order_qs().order_by('pk').annotate(inr=Concat('invoices__prefix', 'invoices__invoice_no')).filter(
code code
| Q(email__icontains=u) | Q(email__icontains=u)
| Q(positions__attendee_name_cached__icontains=u) | Q(all_positions__attendee_name_cached__icontains=u)
| Q(positions__attendee_email__icontains=u) | Q(all_positions__attendee_email__icontains=u)
| Q(invoice_address__name_cached__icontains=u) | Q(invoice_address__name_cached__icontains=u)
| Q(invoice_address__company__icontains=u) | Q(invoice_address__company__icontains=u)
| Q(invoices__invoice_no=u) | Q(invoices__invoice_no=u)

View File

@@ -157,7 +157,7 @@ class OverviewReport(Report):
headlinestyle.fontSize = 15 headlinestyle.fontSize = 15
headlinestyle.fontName = 'OpenSansBd' headlinestyle.fontName = 'OpenSansBd'
colwidths = [ 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 = [ tstyledata = [
('SPAN', (1, 0), (2, 0)), ('SPAN', (1, 0), (2, 0)),
@@ -188,7 +188,7 @@ class OverviewReport(Report):
story.append(Spacer(1, 5 * mm)) story.append(Spacer(1, 5 * mm))
tdata = [ 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) places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
states = ( states = (
('canceled', Order.STATUS_CANCELED), ('canceled', Order.STATUS_CANCELED),
('refunded', Order.STATUS_REFUNDED),
('expired', Order.STATUS_EXPIRED), ('expired', Order.STATUS_EXPIRED),
('pending', Order.STATUS_PENDING), ('pending', Order.STATUS_PENDING),
('paid', Order.STATUS_PAID), ('paid', Order.STATUS_PAID),

View File

@@ -65,9 +65,11 @@ class SenderView(EventPermissionRequiredMixin, FormView):
statusq |= Q(status=Order.STATUS_PENDING, expires__lt=now()) statusq |= Q(status=Order.STATUS_PENDING, expires__lt=now())
orders = qs.filter(statusq) orders = qs.filter(statusq)
if form.cleaned_data.get('item'): 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'): 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() orders = orders.distinct()
self.output = {} self.output = {}

View File

@@ -61,7 +61,7 @@ class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView)
if not ctx['obd_data']: if not ctx['obd_data']:
oqs = Order.objects.annotate(payment_date=Subquery(p_date, output_field=DateTimeField())) oqs = Order.objects.annotate(payment_date=Subquery(p_date, output_field=DateTimeField()))
if subevent: if subevent:
oqs = oqs.filter(positions__subevent_id=subevent).distinct() oqs = oqs.filter(positions__subevent_id=subevent, positions__canceled=False).distinct()
ordered_by_day = {} ordered_by_day = {}
for o in oqs.filter(event=self.request.event).values('datetime'): for o in oqs.filter(event=self.request.event).values('datetime'):

View File

@@ -45,5 +45,5 @@ class TicketLayoutItemForm(forms.ModelForm):
order_position__item_id=self.instance.item, provider='pdf' order_position__item_id=self.instance.item, provider='pdf'
).delete() ).delete()
CachedCombinedTicket.objects.filter( CachedCombinedTicket.objects.filter(
order__positions__item=self.instance.item order__all_positions__item=self.instance.item
).delete() ).delete()

View File

@@ -7,11 +7,13 @@
<span class="label label-warning {{ class }}">{% trans "Payment pending" %}</span> <span class="label label-warning {{ class }}">{% trans "Payment pending" %}</span>
{% endif %} {% endif %}
{% elif order.status == "p" %} {% elif order.status == "p" %}
<span class="label label-success {{ class }}">{% trans "Paid" %}</span> {% if order.count_positions == 0 %}
<span class="label label-info {{ class }}">{% trans "Canceled (paid fee)" %}</span>
{% else %}
<span class="label label-success {{ class }}">{% trans "Paid" %}</span>
{% endif %}
{% elif order.status == "e" %} {% elif order.status == "e" %}
<span class="label label-danger {{ class }}">{% trans "Expired" %}</span> <span class="label label-danger {{ class }}">{% trans "Expired" %}</span>
{% elif order.status == "c" %} {% elif order.status == "c" %}
<span class="label label-danger {{ class }}">{% trans "Canceled" %}</span> <span class="label label-danger {{ class }}">{% trans "Canceled" %}</span>
{% elif order.status == "r" %}
<span class="label label-danger {{ class }}">{% trans "Refunded" %}</span>
{% endif %} {% endif %}

View File

@@ -600,3 +600,7 @@ details summary {
padding-left: 20px; padding-left: 20px;
} }
.pos-canceled * {
color: $brand-danger;
text-decoration: line-through !important;
}

View File

@@ -104,6 +104,16 @@ def order(event, item, taxrule, question):
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w", secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
pseudonymization_id="ABCDEFGHKL", 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') op.answers.create(question=question, answer='S')
return o return o
@@ -509,7 +519,7 @@ def test_refund_process_mark_refunded(token_client, organizer, event, order):
assert resp.status_code == 200 assert resp.status_code == 200
assert r.state == OrderRefund.REFUND_STATE_DONE assert r.state == OrderRefund.REFUND_STATE_DONE
order.refresh_from_db() 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( resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
organizer.slug, event.slug, order.code 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 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 @pytest.mark.django_db
def test_orderposition_delete(token_client, organizer, event, order, item, question): def test_orderposition_delete(token_client, organizer, event, order, item, question):
op = order.positions.first() 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 resp.status_code == 204
assert order.positions.count() == 1 assert order.positions.count() == 1
assert order.all_positions.count() == 3
order.refresh_from_db() order.refresh_from_db()
assert order.total == Decimal('23.25') 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 @pytest.mark.django_db
def test_order_mark_canceled_paid(token_client, organizer, event, order): def test_order_mark_canceled_expired(token_client, organizer, event, order):
order.status = Order.STATUS_PAID order.status = Order.STATUS_EXPIRED
order.save() order.save()
resp = token_client.post( resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format( '/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 assert resp.status_code == 400
order.refresh_from_db() order.refresh_from_db()
assert order.status == Order.STATUS_PAID assert order.status == Order.STATUS_EXPIRED
@pytest.mark.django_db @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.status_code == 200
assert resp.data['status'] == Order.STATUS_REFUNDED assert resp.data['status'] == Order.STATUS_CANCELED
@pytest.mark.django_db @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.info_data == {"foo": "bar"}
assert r.payment.local_id == 2 assert r.payment.local_id == 2
order.refresh_from_db() order.refresh_from_db()
assert order.status == Order.STATUS_REFUNDED assert order.status == Order.STATUS_CANCELED
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -58,6 +58,14 @@ def env():
price=Decimal("42.00"), price=Decimal("42.00"),
positionid=2, positionid=2,
) )
OrderPosition.objects.create(
order=o,
item=t_shirt,
variation=variation,
price=Decimal("42.00"),
positionid=3,
canceled=True
)
gs = GlobalSettingsObject() gs = GlobalSettingsObject()
gs.settings.ecb_rates_date = date.today() gs.settings.ecb_rates_date = date.today()
gs.settings.ecb_rates_dict = json.dumps({ gs.settings.ecb_rates_dict = json.dumps({

View File

@@ -740,7 +740,7 @@ class OrderTestCase(BaseQuotaTestCase):
q = Question.objects.create(question='Foo', type=Question.TYPE_BOOLEAN, event=self.event) q = Question.objects.create(question='Foo', type=Question.TYPE_BOOLEAN, event=self.event)
self.item1.questions.add(q) self.item1.questions.add(q)
assert self.order.can_modify_answers 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 assert not self.order.can_modify_answers
self.order.status = Order.STATUS_PAID self.order.status = Order.STATUS_PAID
assert self.order.can_modify_answers assert self.order.can_modify_answers
@@ -963,7 +963,7 @@ class OrderTestCase(BaseQuotaTestCase):
assert o.has_external_refund assert o.has_external_refund
def test_pending_order_pending_refund(self): def test_pending_order_pending_refund(self):
self.order.status = Order.STATUS_REFUNDED self.order.status = Order.STATUS_CANCELED
self.order.save() self.order.save()
self.order.payments.create( self.order.payments.create(
amount=Decimal('46.00'), amount=Decimal('46.00'),
@@ -1023,6 +1023,14 @@ class OrderTestCase(BaseQuotaTestCase):
assert not o.has_pending_refund assert not o.has_pending_refund
assert not o.has_external_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): class ItemCategoryTest(TestCase):
""" """

View File

@@ -616,6 +616,26 @@ class OrderChangeManagerTests(TestCase):
self.order.refresh_from_db() self.order.refresh_from_db()
assert self.order.positions.count() == 1 assert self.order.positions.count() == 1
assert self.order.total == self.op2.price 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): def test_free_to_paid(self):
self.order.status = Order.STATUS_PAID self.order.status = Order.STATUS_PAID

View File

@@ -53,6 +53,14 @@ def env():
price=Decimal("14"), price=Decimal("14"),
attendee_name_parts={'full_name': "Peter", "_scheme": "full"} 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 return event, user, o, ticket
@@ -125,6 +133,7 @@ def test_order_detail(client, env):
response = client.get('/control/event/dummy/dummy/orders/FOO/') response = client.get('/control/event/dummy/dummy/orders/FOO/')
assert 'Early-bird' in response.rendered_content assert 'Early-bird' in response.rendered_content
assert 'Peter' in response.rendered_content assert 'Peter' in response.rendered_content
assert 'Lukas Gelöscht' in response.rendered_content
@pytest.mark.django_db @pytest.mark.django_db
@@ -256,23 +265,15 @@ def test_order_deny(client, env):
# (Old status, new status, success expected) # (Old status, new status, success expected)
(Order.STATUS_CANCELED, Order.STATUS_PAID, False), (Order.STATUS_CANCELED, Order.STATUS_PAID, False),
(Order.STATUS_CANCELED, Order.STATUS_PENDING, False), (Order.STATUS_CANCELED, Order.STATUS_PENDING, False),
(Order.STATUS_CANCELED, Order.STATUS_REFUNDED, False),
(Order.STATUS_CANCELED, Order.STATUS_EXPIRED, False), (Order.STATUS_CANCELED, Order.STATUS_EXPIRED, False),
(Order.STATUS_PAID, Order.STATUS_PENDING, False), (Order.STATUS_PAID, Order.STATUS_PENDING, False),
(Order.STATUS_PAID, Order.STATUS_CANCELED, False), (Order.STATUS_PAID, Order.STATUS_CANCELED, True),
(Order.STATUS_PAID, Order.STATUS_REFUNDED, False),
(Order.STATUS_PAID, Order.STATUS_EXPIRED, False), (Order.STATUS_PAID, Order.STATUS_EXPIRED, False),
(Order.STATUS_PENDING, Order.STATUS_CANCELED, True), (Order.STATUS_PENDING, Order.STATUS_CANCELED, True),
(Order.STATUS_PENDING, Order.STATUS_PAID, True), (Order.STATUS_PENDING, Order.STATUS_PAID, True),
(Order.STATUS_PENDING, Order.STATUS_REFUNDED, False),
(Order.STATUS_PENDING, Order.STATUS_EXPIRED, True), (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): def test_order_transition(client, env, process):
o = Order.objects.get(id=env[2].id) o = Order.objects.get(id=env[2].id)
@@ -784,6 +785,11 @@ class OrderChangeTests(SoupTest):
order=self.order, item=self.ticket, variation=None, order=self.order, item=self.ticket, variation=None,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter", "_scheme": "full"} 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 = self.event.quotas.create(name="All", size=100)
self.quota.items.add(self.ticket) self.quota.items.add(self.ticket)
self.quota.items.add(self.shirt) self.quota.items.add(self.shirt)
@@ -793,6 +799,14 @@ class OrderChangeTests(SoupTest):
t.limit_events.add(self.event) t.limit_events.add(self.event)
self.client.login(email='dummy@dummy.dummy', password='dummy') 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): def test_change_item_success(self):
self.client.post('/control/event/{}/{}/orders/{}/change'.format( self.client.post('/control/event/{}/{}/orders/{}/change'.format(
self.event.organizer.slug, self.event.slug, self.order.code 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() r.refresh_from_db()
assert r.state == OrderRefund.REFUND_STATE_DONE assert r.state == OrderRefund.REFUND_STATE_DONE
env[2].refresh_from_db() env[2].refresh_from_db()
assert env[2].status == Order.STATUS_REFUNDED assert env[2].status == Order.STATUS_CANCELED
@pytest.mark.django_db @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.provider == "manual"
assert r.state == OrderRefund.REFUND_STATE_DONE assert r.state == OrderRefund.REFUND_STATE_DONE
assert r.amount == Decimal('14.00') assert r.amount == Decimal('14.00')
assert env[2].status == Order.STATUS_REFUNDED assert env[2].status == Order.STATUS_CANCELED
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -162,7 +162,7 @@ def test_retry_refunded(env, client):
state=BankTransaction.STATE_ERROR, state=BankTransaction.STATE_ERROR,
amount=23, date='unknown', order=env[3]) amount=23, date='unknown', order=env[3])
client.login(email='dummy@dummy.dummy', password='dummy') client.login(email='dummy@dummy.dummy', password='dummy')
env[3].status = Order.STATUS_REFUNDED env[3].status = Order.STATUS_CANCELED
env[3].save() env[3].save()
r = json.loads(client.post('/control/event/{}/{}/banktransfer/action/'.format(env[0].organizer.slug, env[0].slug), { r = json.loads(client.post('/control/event/{}/{}/banktransfer/action/'.format(env[0].organizer.slug, env[0].slug), {
'action_{}'.format(trans.pk): 'retry', 'action_{}'.format(trans.pk): 'retry',
@@ -171,7 +171,7 @@ def test_retry_refunded(env, client):
trans.refresh_from_db() trans.refresh_from_db()
assert trans.state == BankTransaction.STATE_ERROR assert trans.state == BankTransaction.STATE_ERROR
env[3].refresh_from_db() env[3].refresh_from_db()
assert env[3].status == Order.STATUS_REFUNDED assert env[3].status == Order.STATUS_CANCELED
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -63,6 +63,14 @@ class OrdersTest(TestCase):
price=Decimal("23"), price=Decimal("23"),
attendee_name_parts={'full_name': "Peter"} 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( self.not_my_order = Order.objects.create(
status=Order.STATUS_PENDING, status=Order.STATUS_PENDING,
event=self.event, event=self.event,
@@ -130,15 +138,17 @@ class OrdersTest(TestCase):
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
assert len(doc.select(".cart-row")) > 0 assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".label-warning")[0].text.lower() 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): def test_orders_modify_invalid(self):
self.order.status = Order.STATUS_REFUNDED self.order.status = Order.STATUS_CANCELED
self.order.save() self.order.save()
self.client.get( self.client.get(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) '/%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) 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): def test_orders_modify_attendee_optional(self):
self.event.settings.set('attendee_names_asked', True) 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)) '/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret))
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select('input[name=%s-attendee_name_parts_0]' % self.ticket_pos.id)), 1) 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 # Not all required fields filled out, expect failure
response = self.client.post( response = self.client.post(