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

@@ -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.'
)

View File

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

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:
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()

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -24,7 +24,6 @@
<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="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 name="item" class="form-control">
<option value="">{% trans "All products" %}</option>

View File

@@ -10,6 +10,12 @@
<p>{% blocktrans trimmed %}
Do you really want to cancel this order? You cannot revert this action.
{% 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="">
{% csrf_token %}

View File

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

View File

@@ -50,11 +50,6 @@
{% trans "Cancel order" %}
</a>
{% 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 %}
<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 %}">
{% csrf_token %}
<button class="btn btn-default btn-xs"
{% if order.status != "r" and order.status != "c" %}
{% if order.status != "c" %}
data-toggle="tooltip"
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
{% endif %}>
{% if order.status == "r" or order.status == "c" %}
{% if order.status == "c" %}
{% trans "Generate cancellation" %}
{% else %}
{% trans "Cancel and reissue" %}
@@ -234,7 +229,7 @@
</div>
<div class="panel-body">
{% 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">
{% if line.addon_to %}
<span class="addon-signifier">+</span>
@@ -260,24 +255,26 @@
<br/>
<span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
{% endif %}
<div class="position-buttons">
{% 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 %}
<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 %}"
method="post" data-asynctask data-asynctask-download
class="form-inline helper-display-inline">
{% csrf_token %}
<button type="submit"
class="btn btn-xs btn-default">
<span class="fa {{ b.icon }}"></span> {{ b.text }}
</button>
</form>
{% endfor %}
{% if not line.canceled %}
<div class="position-buttons">
{% 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 %}
<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 %}"
method="post" data-asynctask data-asynctask-download
class="form-inline helper-display-inline">
{% csrf_token %}
<button type="submit"
class="btn btn-xs btn-default">
<span class="fa {{ b.icon }}"></span> {{ b.text }}
</button>
</form>
{% endfor %}
{% endif %}
{% endif %}
{% endif %}
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
</div>
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
</div>
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.admission and event.settings.attendee_names_asked %}
@@ -486,6 +483,11 @@
{% endfor %}
</tbody>
</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>

View File

@@ -7,11 +7,13 @@
<span class="label label-warning {{ class }}">{% trans "Pending" %}</span>
{% endif %}
{% 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 #}
<span class="label label-danger {{ class }}">{% trans "Expired" %}</span>
{% elif order.status == "c" %}
<span class="label label-danger {{ class }}">{% trans "Canceled" %}</span>
{% elif order.status == "r" %}
<span class="label label-danger {{ class }}">{% trans "Refunded" %}</span>
{% endif %}

View File

@@ -142,7 +142,7 @@
{% endif %}
{{ o.total|money:request.event.currency }}
</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>
</tr>
{% endfor %}

View File

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

View File

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

View File

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

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.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(

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'):
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.')

View File

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

View File

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

View File

@@ -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 = {}

View File

@@ -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'):

View File

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

View File

@@ -7,11 +7,13 @@
<span class="label label-warning {{ class }}">{% trans "Payment pending" %}</span>
{% endif %}
{% 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" %}
<span class="label label-danger {{ class }}">{% trans "Expired" %}</span>
{% elif order.status == "c" %}
<span class="label label-danger {{ class }}">{% trans "Canceled" %}</span>
{% elif order.status == "r" %}
<span class="label label-danger {{ class }}">{% trans "Refunded" %}</span>
{% endif %}

View File

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