forked from CGM_Public/pretix_original
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:
@@ -26,7 +26,6 @@ status string Order status, o
|
||||
* ``p`` – paid
|
||||
* ``e`` – expired
|
||||
* ``c`` – canceled
|
||||
* ``r`` – refunded
|
||||
secret string The secret contained in the link sent to the customer
|
||||
email string The customer email address
|
||||
locale string The locale used for communication with this customer
|
||||
@@ -58,9 +57,9 @@ invoice_address object Invoice address
|
||||
└ vat_id_validated string ``True``, if the VAT ID has been validated against the
|
||||
EU VAT service and validation was successful. This only
|
||||
happens in rare cases.
|
||||
positions list of objects List of order positions (see below)
|
||||
fees list of objects List of fees included in the order total (i.e.
|
||||
payment fees)
|
||||
positions list of objects List of non-canceled order positions (see below)
|
||||
fees list of objects List of non-canceled fees included in the order total
|
||||
(i.e. payment fees)
|
||||
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
||||
``other``)
|
||||
├ value money (string) Fee amount
|
||||
@@ -874,7 +873,7 @@ Order state operations
|
||||
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "r",
|
||||
"status": "c",
|
||||
...
|
||||
}
|
||||
|
||||
@@ -1066,6 +1065,8 @@ List of all order positions
|
||||
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||
``pseudonymization_id``.
|
||||
|
||||
.. note:: Individually canceled order positions are currently not visible via the API at all.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||
|
||||
Returns a list of all order positions within a given event.
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
63
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal file
63
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal 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
|
||||
)
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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." %}
|
||||
|
||||
@@ -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 }} · {{ 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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }}&status=r&provider={{ item.provider }}">
|
||||
{{ item.num.refunded|togglesum:request.event.currency }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ listurl }}?item={{ item.id }}&status=e&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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -600,3 +600,7 @@ details summary {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.pos-canceled * {
|
||||
color: $brand-danger;
|
||||
text-decoration: line-through !important;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,16 @@ def order(event, item, taxrule, question):
|
||||
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
pseudonymization_id="ABCDEFGHKL",
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
item=item,
|
||||
variation=None,
|
||||
price=Decimal("23"),
|
||||
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
|
||||
secret="YBiYJrmF5ufiTLdV1iDf",
|
||||
pseudonymization_id="JKLM",
|
||||
canceled=True
|
||||
)
|
||||
op.answers.create(question=question, answer='S')
|
||||
return o
|
||||
|
||||
@@ -509,7 +519,7 @@ def test_refund_process_mark_refunded(token_client, organizer, event, order):
|
||||
assert resp.status_code == 200
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_REFUNDED
|
||||
assert order.status == Order.STATUS_CANCELED
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
@@ -692,6 +702,14 @@ def test_orderposition_detail(token_client, organizer, event, order, item, quest
|
||||
assert len(resp.data['downloads']) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_orderposition_detail_no_canceled(token_client, organizer, event, order, item, question):
|
||||
op = order.all_positions.filter(canceled=True).first()
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(organizer.slug, event.slug,
|
||||
op.pk))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_orderposition_delete(token_client, organizer, event, order, item, question):
|
||||
op = order.positions.first()
|
||||
@@ -720,6 +738,7 @@ def test_orderposition_delete(token_client, organizer, event, order, item, quest
|
||||
))
|
||||
assert resp.status_code == 204
|
||||
assert order.positions.count() == 1
|
||||
assert order.all_positions.count() == 3
|
||||
order.refresh_from_db()
|
||||
assert order.total == Decimal('23.25')
|
||||
|
||||
@@ -952,8 +971,8 @@ def test_order_mark_canceled_pending_no_email(token_client, organizer, event, or
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_mark_canceled_paid(token_client, organizer, event, order):
|
||||
order.status = Order.STATUS_PAID
|
||||
def test_order_mark_canceled_expired(token_client, organizer, event, order):
|
||||
order.status = Order.STATUS_EXPIRED
|
||||
order.save()
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format(
|
||||
@@ -962,7 +981,7 @@ def test_order_mark_canceled_paid(token_client, organizer, event, order):
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_PAID
|
||||
assert order.status == Order.STATUS_EXPIRED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -975,7 +994,7 @@ def test_order_mark_paid_refunded(token_client, organizer, event, order):
|
||||
)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data['status'] == Order.STATUS_REFUNDED
|
||||
assert resp.data['status'] == Order.STATUS_CANCELED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -2411,7 +2430,7 @@ def test_refund_create_mark_refunded(token_client, organizer, event, order):
|
||||
assert r.info_data == {"foo": "bar"}
|
||||
assert r.payment.local_id == 2
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_REFUNDED
|
||||
assert order.status == Order.STATUS_CANCELED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -58,6 +58,14 @@ def env():
|
||||
price=Decimal("42.00"),
|
||||
positionid=2,
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
item=t_shirt,
|
||||
variation=variation,
|
||||
price=Decimal("42.00"),
|
||||
positionid=3,
|
||||
canceled=True
|
||||
)
|
||||
gs = GlobalSettingsObject()
|
||||
gs.settings.ecb_rates_date = date.today()
|
||||
gs.settings.ecb_rates_dict = json.dumps({
|
||||
|
||||
@@ -740,7 +740,7 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
q = Question.objects.create(question='Foo', type=Question.TYPE_BOOLEAN, event=self.event)
|
||||
self.item1.questions.add(q)
|
||||
assert self.order.can_modify_answers
|
||||
self.order.status = Order.STATUS_REFUNDED
|
||||
self.order.status = Order.STATUS_CANCELED
|
||||
assert not self.order.can_modify_answers
|
||||
self.order.status = Order.STATUS_PAID
|
||||
assert self.order.can_modify_answers
|
||||
@@ -963,7 +963,7 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
assert o.has_external_refund
|
||||
|
||||
def test_pending_order_pending_refund(self):
|
||||
self.order.status = Order.STATUS_REFUNDED
|
||||
self.order.status = Order.STATUS_CANCELED
|
||||
self.order.save()
|
||||
self.order.payments.create(
|
||||
amount=Decimal('46.00'),
|
||||
@@ -1023,6 +1023,14 @@ class OrderTestCase(BaseQuotaTestCase):
|
||||
assert not o.has_pending_refund
|
||||
assert not o.has_external_refund
|
||||
|
||||
def test_canceled_positions(self):
|
||||
self.op1.canceled = True
|
||||
self.op1.save()
|
||||
assert OrderPosition.objects.count() == 1
|
||||
assert OrderPosition.all.count() == 2
|
||||
assert self.order.positions.count() == 1
|
||||
assert self.order.all_positions.count() == 2
|
||||
|
||||
|
||||
class ItemCategoryTest(TestCase):
|
||||
"""
|
||||
|
||||
@@ -616,6 +616,26 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.positions.count() == 1
|
||||
assert self.order.total == self.op2.price
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.canceled
|
||||
|
||||
def test_cancel_with_addon(self):
|
||||
self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True)
|
||||
self.ticket.addons.create(addon_category=self.shirt.category)
|
||||
self.ocm.add_position(self.shirt, None, Decimal('13.00'), self.op1)
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
assert self.order.positions.count() == 3
|
||||
|
||||
self.ocm.cancel(self.op1)
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.positions.count() == 1
|
||||
assert self.order.total == self.op2.price
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.canceled
|
||||
assert self.op1.addons.first().canceled
|
||||
|
||||
def test_free_to_paid(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
|
||||
@@ -53,6 +53,14 @@ def env():
|
||||
price=Decimal("14"),
|
||||
attendee_name_parts={'full_name': "Peter", "_scheme": "full"}
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
item=ticket,
|
||||
variation=None,
|
||||
price=Decimal("14"),
|
||||
canceled=True,
|
||||
attendee_name_parts={'full_name': "Lukas Gelöscht", "_scheme": "full"}
|
||||
)
|
||||
return event, user, o, ticket
|
||||
|
||||
|
||||
@@ -125,6 +133,7 @@ def test_order_detail(client, env):
|
||||
response = client.get('/control/event/dummy/dummy/orders/FOO/')
|
||||
assert 'Early-bird' in response.rendered_content
|
||||
assert 'Peter' in response.rendered_content
|
||||
assert 'Lukas Gelöscht' in response.rendered_content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -256,23 +265,15 @@ def test_order_deny(client, env):
|
||||
# (Old status, new status, success expected)
|
||||
(Order.STATUS_CANCELED, Order.STATUS_PAID, False),
|
||||
(Order.STATUS_CANCELED, Order.STATUS_PENDING, False),
|
||||
(Order.STATUS_CANCELED, Order.STATUS_REFUNDED, False),
|
||||
(Order.STATUS_CANCELED, Order.STATUS_EXPIRED, False),
|
||||
|
||||
(Order.STATUS_PAID, Order.STATUS_PENDING, False),
|
||||
(Order.STATUS_PAID, Order.STATUS_CANCELED, False),
|
||||
(Order.STATUS_PAID, Order.STATUS_REFUNDED, False),
|
||||
(Order.STATUS_PAID, Order.STATUS_CANCELED, True),
|
||||
(Order.STATUS_PAID, Order.STATUS_EXPIRED, False),
|
||||
|
||||
(Order.STATUS_PENDING, Order.STATUS_CANCELED, True),
|
||||
(Order.STATUS_PENDING, Order.STATUS_PAID, True),
|
||||
(Order.STATUS_PENDING, Order.STATUS_REFUNDED, False),
|
||||
(Order.STATUS_PENDING, Order.STATUS_EXPIRED, True),
|
||||
|
||||
(Order.STATUS_REFUNDED, Order.STATUS_CANCELED, False),
|
||||
(Order.STATUS_REFUNDED, Order.STATUS_PAID, False),
|
||||
(Order.STATUS_REFUNDED, Order.STATUS_PENDING, False),
|
||||
(Order.STATUS_REFUNDED, Order.STATUS_EXPIRED, False),
|
||||
])
|
||||
def test_order_transition(client, env, process):
|
||||
o = Order.objects.get(id=env[2].id)
|
||||
@@ -784,6 +785,11 @@ class OrderChangeTests(SoupTest):
|
||||
order=self.order, item=self.ticket, variation=None,
|
||||
price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter", "_scheme": "full"}
|
||||
)
|
||||
self.op3 = OrderPosition.objects.create(
|
||||
order=self.order, item=self.ticket, variation=None,
|
||||
price=Decimal("23.00"), attendee_name_parts={'full_name': "Lukas", "_scheme": "full"},
|
||||
canceled=True
|
||||
)
|
||||
self.quota = self.event.quotas.create(name="All", size=100)
|
||||
self.quota.items.add(self.ticket)
|
||||
self.quota.items.add(self.shirt)
|
||||
@@ -793,6 +799,14 @@ class OrderChangeTests(SoupTest):
|
||||
t.limit_events.add(self.event)
|
||||
self.client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
def test_do_not_show_canceled(self):
|
||||
r = self.client.get('/control/event/{}/{}/orders/{}/change'.format(
|
||||
self.event.organizer.slug, self.event.slug, self.order.code
|
||||
))
|
||||
assert self.op1.secret[:5] in r.rendered_content
|
||||
assert self.op2.secret[:5] in r.rendered_content
|
||||
assert self.op3.secret[:5] not in r.rendered_content
|
||||
|
||||
def test_change_item_success(self):
|
||||
self.client.post('/control/event/{}/{}/orders/{}/change'.format(
|
||||
self.event.organizer.slug, self.event.slug, self.order.code
|
||||
@@ -1137,7 +1151,7 @@ def test_process_refund_mark_refunded(client, env):
|
||||
r.refresh_from_db()
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
env[2].refresh_from_db()
|
||||
assert env[2].status == Order.STATUS_REFUNDED
|
||||
assert env[2].status == Order.STATUS_CANCELED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1240,7 +1254,7 @@ def test_refund_paid_order_fully_mark_as_refunded(client, env):
|
||||
assert r.provider == "manual"
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
assert r.amount == Decimal('14.00')
|
||||
assert env[2].status == Order.STATUS_REFUNDED
|
||||
assert env[2].status == Order.STATUS_CANCELED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -162,7 +162,7 @@ def test_retry_refunded(env, client):
|
||||
state=BankTransaction.STATE_ERROR,
|
||||
amount=23, date='unknown', order=env[3])
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
env[3].status = Order.STATUS_REFUNDED
|
||||
env[3].status = Order.STATUS_CANCELED
|
||||
env[3].save()
|
||||
r = json.loads(client.post('/control/event/{}/{}/banktransfer/action/'.format(env[0].organizer.slug, env[0].slug), {
|
||||
'action_{}'.format(trans.pk): 'retry',
|
||||
@@ -171,7 +171,7 @@ def test_retry_refunded(env, client):
|
||||
trans.refresh_from_db()
|
||||
assert trans.state == BankTransaction.STATE_ERROR
|
||||
env[3].refresh_from_db()
|
||||
assert env[3].status == Order.STATUS_REFUNDED
|
||||
assert env[3].status == Order.STATUS_CANCELED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -63,6 +63,14 @@ class OrdersTest(TestCase):
|
||||
price=Decimal("23"),
|
||||
attendee_name_parts={'full_name': "Peter"}
|
||||
)
|
||||
self.deleted_pos = OrderPosition.objects.create(
|
||||
order=self.order,
|
||||
item=self.ticket,
|
||||
variation=None,
|
||||
price=Decimal("23"),
|
||||
attendee_name_parts={'full_name': "Lukas"},
|
||||
canceled=True
|
||||
)
|
||||
self.not_my_order = Order.objects.create(
|
||||
status=Order.STATUS_PENDING,
|
||||
event=self.event,
|
||||
@@ -130,15 +138,17 @@ class OrdersTest(TestCase):
|
||||
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||
assert len(doc.select(".cart-row")) > 0
|
||||
assert "pending" in doc.select(".label-warning")[0].text.lower()
|
||||
assert "Peter" in response.rendered_content
|
||||
assert "Lukas" not in response.rendered_content
|
||||
|
||||
def test_orders_modify_invalid(self):
|
||||
self.order.status = Order.STATUS_REFUNDED
|
||||
self.order.status = Order.STATUS_CANCELED
|
||||
self.order.save()
|
||||
self.client.get(
|
||||
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
|
||||
)
|
||||
self.order = Order.objects.get(id=self.order.id)
|
||||
assert self.order.status == Order.STATUS_REFUNDED
|
||||
assert self.order.status == Order.STATUS_CANCELED
|
||||
|
||||
def test_orders_modify_attendee_optional(self):
|
||||
self.event.settings.set('attendee_names_asked', True)
|
||||
@@ -169,6 +179,8 @@ class OrdersTest(TestCase):
|
||||
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret))
|
||||
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||
self.assertEqual(len(doc.select('input[name=%s-attendee_name_parts_0]' % self.ticket_pos.id)), 1)
|
||||
assert "Peter" in response.rendered_content
|
||||
assert "Lukas" not in response.rendered_content
|
||||
|
||||
# Not all required fields filled out, expect failure
|
||||
response = self.client.post(
|
||||
|
||||
Reference in New Issue
Block a user