Skip to content

Commit

Permalink
Fix #163 -- Form to change orders
Browse files Browse the repository at this point in the history
  • Loading branch information
raphaelm committed Aug 31, 2016
1 parent 5659cb1 commit 3d162bb
Show file tree
Hide file tree
Showing 15 changed files with 751 additions and 16 deletions.
168 changes: 166 additions & 2 deletions src/pretix/base/services/orders.py
@@ -1,6 +1,7 @@
from collections import Counter, namedtuple
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List
from typing import List, Optional

from django.conf import settings
from django.db import transaction
Expand All @@ -12,7 +13,8 @@
LazyDate, LazyLocaleException, LazyNumber, language,
)
from pretix.base.models import (
CartPosition, Event, EventLock, Order, OrderPosition, Quota, User,
CartPosition, Event, EventLock, Item, ItemVariation, Order, OrderPosition,
Quota, User,
)
from pretix.base.models.orders import InvoiceAddress
from pretix.base.payment import BasePaymentProvider
Expand Down Expand Up @@ -364,6 +366,168 @@ def expire_orders(sender, **kwargs):
o.save()


class OrderChangeManager:
error_messages = {
'free_to_paid': _('You cannot change a free order to a paid order.'),
'product_without_variation': _('You need to select a variation of the product.'),
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
'product_invalid': _('The selected product is not active or has no price set.'),
'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
'not_pending': _('Only pending orders can be changed.'),
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
'no quota is available.'),
}
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
CancelOperation = namedtuple('CancelOperation', ('position',))

def __init__(self, order: Order, user):
self.order = order
self.user = user
self._totaldiff = 0
self._quotadiff = Counter()
self._operations = []

def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
if not price:
raise OrderError(self.error_messages['product_invalid'])
self._totaldiff = price - position.price
self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all())
self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
self._operations.append(self.ItemOperation(position, item, variation, price))

def change_price(self, position: OrderPosition, price: Decimal):
self._totaldiff = price - position.price
self._operations.append(self.PriceOperation(position, price))

def cancel(self, position: OrderPosition):
self._totaldiff = -position.price
self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
self._operations.append(self.CancelOperation(position))

def _check_quotas(self):
for quota, diff in self._quotadiff.items():
if diff <= 0:
continue
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or avail[1] < diff:
raise OrderError(self.error_messages['quota'].format(name=quota.name))

def _check_free_to_paid(self):
if self.order.total == Decimal('0.00') and self._totaldiff > 0:
raise OrderError(self.error_messages['free_to_paid'])

def _check_paid_to_free(self):
if self.order.total == 0:
try:
mark_order_paid(self.order, 'free', send_mail=False)
except Quota.QuotaExceededException:
raise OrderError(self.error_messages['paid_to_free_exceeded'])

def _perform_operations(self):
for op in self._operations:
if isinstance(op, self.ItemOperation):
self.order.log_action('pretix.event.order.changed.item', user=self.user, data={
'position': op.position.pk,
'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None,
'new_item': op.item.pk,
'new_variation': op.variation.pk if op.variation else None,
'old_price': op.position.price,
'new_price': op.price
})
op.position.item = op.item
op.position.variation = op.variation
op.position.price = op.price
op.position._calculate_tax()
op.position.save()
elif isinstance(op, self.PriceOperation):
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
'position': op.position.pk,
'old_price': op.position.price,
'new_price': op.price
})
op.position.price = op.price
op.position._calculate_tax()
op.position.save()
elif isinstance(op, self.CancelOperation):
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
'position': op.position.pk,
'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None,
'old_price': op.position.price,
})
op.position.delete()

def _recalculate_total_and_payment_fee(self):
self.order.total = sum([p.price for p in self.order.positions.all()])
if self.order.total == 0:
payment_fee = Decimal('0.00')
else:
payment_fee = self._get_payment_provider().calculate_fee(self.order.total)
self.order.payment_fee = payment_fee
self.order.total += payment_fee
self.order._calculate_tax()
self.order.save()

def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
generate_invoice(self.order)

def _check_complete_cancel(self):
cancels = len([o for o in self._operations if isinstance(o, self.CancelOperation)])
if cancels == self.order.positions.count():
raise OrderError(self.error_messages['complete_cancel'])

def _notify_user(self):
with language(self.order.locale):
mail(
self.order.email, _('Your order has been changed: %(code)s') % {'code': self.order.code},
self.order.event.settings.mail_text_order_changed,
{
'event': self.order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
'order': self.order.code,
'secret': self.order.secret
}),
},
self.order.event, locale=self.order.locale
)

def commit(self):
if not self._operations:
# Do nothing
return
with transaction.atomic():
with self.order.event.lock():
if self.order.status != Order.STATUS_PENDING:
raise OrderError(self.error_messages['not_pending'])
self._check_free_to_paid()
self._check_quotas()
self._check_complete_cancel()
self._perform_operations()
self._recalculate_total_and_payment_fee()
self._reissue_invoice()
self._check_paid_to_free()
self._notify_user()

def _get_payment_provider(self):
responses = register_payment_providers.send(self.order.event)
pprov = None
for rec, response in responses:
provider = response(self.order.event)
if provider.identifier == self.order.payment_provider:
return provider
if not pprov:
raise OrderError(error_messages['internal'])


if settings.HAS_CELERY:
from pretix.celery import app

Expand Down
12 changes: 12 additions & 0 deletions src/pretix/base/settings.py
Expand Up @@ -191,6 +191,18 @@
You can change your order details and view the status of your order at
{url}
Best regards,
Your {event} team"""))
},
'mail_text_order_changed': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
your order for {event} has been changed.
You can view the status of your order at
{url}
Best regards,
Your {event} team"""))
},
Expand Down
6 changes: 6 additions & 0 deletions src/pretix/control/forms/event.py
Expand Up @@ -329,6 +329,12 @@ class MailSettingsForm(SettingsForm):
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}")
)
mail_text_order_changed = I18nFormField(
label=_("Changed order"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}")
)
mail_text_resend_link = I18nFormField(
label=_("Resend link"),
required=False,
Expand Down
55 changes: 54 additions & 1 deletion src/pretix/control/forms/orders.py
@@ -1,8 +1,10 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _

from pretix.base.forms import I18nModelForm
from pretix.base.models import Order
from pretix.base.models import Item, Order


class ExtendForm(I18nModelForm):
Expand Down Expand Up @@ -35,3 +37,54 @@ class Meta:
'class': 'helper-width-100',
}),
}


class OrderPositionChangeForm(forms.Form):
itemvar = forms.ChoiceField()
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
label=_('New price')
)
operation = forms.ChoiceField(
required=False,
widget=forms.RadioSelect,
choices=(
('product', 'Change product'),
('price', 'Change price'),
('cancel', 'Remove product')
)
)

def __init__(self, *args, **kwargs):
instance = kwargs.pop('instance')
initial = kwargs.get('initial', {})
if instance:
try:
if instance.variation:
initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk)
elif instance.item:
initial['itemvar'] = str(instance.item.pk)
except Item.DoesNotExist:
pass

initial['price'] = instance.price

kwargs['initial'] = initial
super().__init__(*args, **kwargs)
choices = []
for i in instance.order.event.items.prefetch_related('variations').all():
pname = i.name
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all())
if variations:
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (pname, v.value)))
else:
choices.append((str(i.pk), pname))
self.fields['itemvar'].choices = choices

def clean(self):
if self.cleaned_data.get('operation') == 'price' and not self.cleaned_data.get('price', '') != '':
raise ValidationError(_('You need to enter a price if you want to change the product price.'))
1 change: 0 additions & 1 deletion src/pretix/control/forms/vouchers.py
Expand Up @@ -3,7 +3,6 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms import model_to_dict
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _

Expand Down
44 changes: 43 additions & 1 deletion src/pretix/control/logdisplay.py
@@ -1,11 +1,50 @@
import json
from decimal import Decimal

from django.dispatch import receiver
from django.utils import formats
from django.utils.translation import ugettext_lazy as _

from pretix.base.models import Event, ItemVariation, LogEntry
from pretix.base.signals import logentry_display


def _display_order_changed(event: Event, logentry: LogEntry):
data = json.loads(logentry.data)

text = _('The order has been changed:')
if logentry.action_type == 'pretix.event.order.changed.item':
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(event.itemvariations.get(pk=data['old_variation']))
new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']:
new_item += ' - ' + str(event.itemvariations.get(pk=data['new_variation']))
return text + ' ' + _('{old_item} ({old_price} {currency}) changed to {new_item} ({new_price} {currency}).').format(
old_item=old_item, new_item=new_item,
old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency
)
elif logentry.action_type == 'pretix.event.order.changed.price':
return text + ' ' + _('Price of a position changed from {old_price} {currency} to {new_price} {currency}.').format(
old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency
)
elif logentry.action_type == 'pretix.event.order.changed.cancel':
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 + ' ' + _('{old_item} ({old_price} {currency}) removed.').format(
old_item=old_item,
old_price=formats.localize(Decimal(data['old_price'])),
currency=event.currency
)


@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
plains = {
'pretix.event.order.modified': _('The order details have been modified.'),
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
Expand All @@ -23,3 +62,6 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
}
if logentry.action_type in plains:
return plains[logentry.action_type]

if logentry.action_type.startswith('pretix.event.order.changed'):
return _display_order_changed(sender, logentry)
1 change: 1 addition & 0 deletions src/pretix/control/templates/pretixcontrol/event/mail.html
Expand Up @@ -16,6 +16,7 @@
{% bootstrap_field form.mail_text_order_paid layout="horizontal" %}
{% bootstrap_field form.mail_text_order_free layout="horizontal" %}
{% bootstrap_field form.mail_text_resend_link layout="horizontal" %}
{% bootstrap_field form.mail_text_order_changed layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "SMTP settings" %}</legend>
Expand Down

0 comments on commit 3d162bb

Please sign in to comment.