From 1456c457fc0a6273480ef3933cfcbf37b4fc1cfa Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 18 Sep 2014 15:07:39 +0000 Subject: [PATCH] Refactoring payment process --- orchestra/admin/decorators.py | 41 +++-- orchestra/admin/utils.py | 4 +- orchestra/apps/accounts/admin.py | 6 +- orchestra/apps/bills/actions.py | 2 +- orchestra/apps/bills/admin.py | 58 ++++--- orchestra/apps/bills/models.py | 56 ++++--- orchestra/apps/domains/admin.py | 4 +- orchestra/apps/issues/forms.py | 3 +- orchestra/apps/mails/admin.py | 6 +- orchestra/apps/orders/billing.py | 4 +- orchestra/apps/orders/forms.py | 6 +- orchestra/apps/orders/models.py | 2 - .../orders/tests/functional_tests/tests.py | 17 ++- orchestra/apps/payments/actions.py | 69 ++++++++- orchestra/apps/payments/admin.py | 142 ++++++++++-------- orchestra/apps/payments/models.py | 72 +++++++-- orchestra/apps/services/handlers.py | 8 +- orchestra/apps/services/models.py | 10 +- orchestra/apps/services/tests/test_handler.py | 19 ++- orchestra/apps/users/roles/admin.py | 7 +- orchestra/apps/webapps/admin.py | 3 +- orchestra/apps/websites/admin.py | 4 +- orchestra/conf/base_settings.py | 4 + .../admin/orchestra/generic_confirmation.html | 7 +- orchestra/templatetags/utils.py | 4 +- 25 files changed, 375 insertions(+), 183 deletions(-) diff --git a/orchestra/admin/decorators.py b/orchestra/admin/decorators.py index c7e62039..37bda702 100644 --- a/orchestra/admin/decorators.py +++ b/orchestra/admin/decorators.py @@ -2,9 +2,12 @@ from functools import wraps, partial from django.contrib import messages from django.contrib.admin import helpers -from django.template.response import TemplateResponse +from django.template.response import TemplateResponse from django.utils.decorators import available_attrs from django.utils.encoding import force_text +from django.utils.html import format_html +from django.utils.text import capfirst +from django.utils.translation import ugettext_lazy as _ def admin_field(method): @@ -24,6 +27,17 @@ def admin_field(method): return admin_field_wrapper +def format_display_objects(modeladmin, request, queryset): + from .utils import change_url + opts = modeladmin.model._meta + objects = [] + for obj in queryset: + objects.append(format_html('{0}: {2}', + capfirst(opts.verbose_name), change_url(obj), obj) + ) + return objects + + def action_with_confirmation(action_name=None, extra_context={}, template='admin/orchestra/generic_confirmation.html'): """ @@ -31,11 +45,12 @@ def action_with_confirmation(action_name=None, extra_context={}, If custom template is provided the form must contain: """ + def decorator(func, extra_context=extra_context, template=template, action_name=action_name): @wraps(func, assigned=available_attrs(func)) - def inner(modeladmin, request, queryset, action_name=action_name): + def inner(modeladmin, request, queryset, action_name=action_name, extra_context=extra_context): # The user has already confirmed the action. - if request.POST.get('post') == "generic_confirmation": + if request.POST.get('post') == 'generic_confirmation': stay = func(modeladmin, request, queryset) if not stay: return @@ -51,19 +66,23 @@ def action_with_confirmation(action_name=None, extra_context={}, if not action_name: action_name = func.__name__ context = { - "title": "Are you sure?", - "content_message": "Are you sure you want to %s the selected %s?" % - (action_name, objects_name), - "action_name": action_name.capitalize(), - "action_value": action_value, - "display_objects": queryset, + 'title': _("Are you sure?"), + 'content_message': _("Are you sure you want to {action} the selected {item}?").format( + action=action_name, item=objects_name), + 'action_name': action_name.capitalize(), + 'action_value': action_value, 'queryset': queryset, - "opts": opts, - "app_label": app_label, + 'opts': opts, + 'app_label': app_label, 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, } + if callable(extra_context): + extra_context = extra_context(modeladmin, request, queryset) context.update(extra_context) + if 'display_objects' not in context: + # Compute it only when necessary + context['display_objects'] = format_display_objects(modeladmin, request, queryset) # Display the confirmation page return TemplateResponse(request, template, diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py index 0aa40bf2..2807a5b0 100644 --- a/orchestra/admin/utils.py +++ b/orchestra/admin/utils.py @@ -91,7 +91,7 @@ def action_to_view(action, modeladmin): return action_view -def admin_change_url(obj): +def change_url(obj): opts = obj._meta view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name) return reverse(view_name, args=(obj.pk,)) @@ -106,7 +106,7 @@ def admin_link(*args, **kwargs): obj = get_field_value(instance, kwargs['field']) if not getattr(obj, 'pk', None): return '---' - url = admin_change_url(obj) + url = change_url(obj) extra = '' if kwargs['popup']: extra = 'onclick="return showAddAnotherPopup(this);"' diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py index cc4d760d..ea5eadf3 100644 --- a/orchestra/apps/accounts/admin.py +++ b/orchestra/apps/accounts/admin.py @@ -9,7 +9,8 @@ from django.utils.six.moves.urllib.parse import parse_qsl from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin -from orchestra.admin.utils import wrap_admin_view, admin_link, set_url_query +from orchestra.admin.utils import (wrap_admin_view, admin_link, set_url_query, + change_url) from orchestra.core import services, accounts from .filters import HasMainUserListFilter @@ -136,8 +137,7 @@ class AccountAdminMixin(object): def account_link(self, instance): account = instance.account if instance.pk else self.account - url = reverse('admin:accounts_account_change', args=(account.pk,)) - pk = account.pk + url = change_url(account) return '%s' % (url, str(account)) account_link.short_description = _("account") account_link.allow_tags = True diff --git a/orchestra/apps/bills/actions.py b/orchestra/apps/bills/actions.py index 4b004872..1f157831 100644 --- a/orchestra/apps/bills/actions.py +++ b/orchestra/apps/bills/actions.py @@ -42,7 +42,7 @@ view_bill.url_name = 'view' def close_bills(modeladmin, request, queryset): - queryset = queryset.filter(status=queryset.model.OPEN) + queryset = queryset.filter(is_open=True) if not queryset: messages.warning(request, _("Selected bills should be in open state")) return diff --git a/orchestra/apps/bills/admin.py b/orchestra/apps/bills/admin.py index 13ea7da9..41ad9216 100644 --- a/orchestra/apps/bills/admin.py +++ b/orchestra/apps/bills/admin.py @@ -1,9 +1,7 @@ from django import forms from django.contrib import admin -#from django.contrib.admin.utils import unquote from django.core.urlresolvers import reverse from django.db import models -#from django.shortcuts import redirect from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin @@ -17,23 +15,30 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForm BillLine) +PAYMENT_STATE_COLORS = { + Bill.PAID: 'green', + Bill.PENDING: 'darkorange', + Bill.BAD_DEBT: 'red', +} + + class BillLineInline(admin.TabularInline): model = BillLine - fields = ('description', 'rate', 'amount', 'tax', 'total', 'get_total') + fields = ('description', 'rate', 'quantity', 'tax', 'subtotal', 'get_total') readonly_fields = ('get_total',) def get_readonly_fields(self, request, obj=None): - if obj and obj.status != Bill.OPEN: + if obj and not obj.is_open: return self.fields return super(BillLineInline, self).get_readonly_fields(request, obj=obj) def has_add_permission(self, request): - if request.__bill__ and request.__bill__.status != Bill.OPEN: + if request.__bill__ and not request.__bill__.is_open: return False return super(BillLineInline, self).has_add_permission(request) def has_delete_permission(self, request, obj=None): - if obj and obj.status != Bill.OPEN: + if obj and not obj.is_open: return False return super(BillLineInline, self).has_delete_permission(request, obj=obj) @@ -48,15 +53,15 @@ class BillLineInline(admin.TabularInline): class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): list_display = ( - 'number', 'status', 'type_link', 'account_link', 'created_on_display', - 'num_lines', 'display_total' + 'number', 'is_open', 'type_link', 'account_link', 'created_on_display', + 'num_lines', 'display_total', 'display_payment_state' ) - list_filter = (BillTypeListFilter, 'status',) - add_fields = ('account', 'type', 'status', 'due_on', 'comments') + list_filter = (BillTypeListFilter, 'is_open',) + add_fields = ('account', 'type', 'is_open', 'due_on', 'comments') fieldsets = ( (None, { 'fields': ('number', 'display_total', 'account_link', 'type', - 'status', 'due_on', 'comments'), + 'is_open', 'display_payment_state', 'is_sent', 'due_on', 'comments'), }), (_("Raw"), { 'classes': ('collapse',), @@ -65,8 +70,8 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): ) actions = [download_bills, close_bills, send_bills] change_view_actions = [view_bill, download_bills, send_bills, close_bills] - change_readonly_fields = ('account_link', 'type', 'status') - readonly_fields = ('number', 'display_total') + change_readonly_fields = ('account_link', 'type', 'is_open') + readonly_fields = ('number', 'display_total', 'display_payment_state') inlines = [BillLineInline] created_on_display = admin_date('created_on') @@ -90,29 +95,36 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): type_link.short_description = _("type") type_link.admin_order_field = 'type' + def display_payment_state(self, bill): + topts = bill.transactions.model._meta + url = reverse('admin:%s_%s_changelist' % (topts.app_label, topts.module_name)) + url += '?bill=%i' % bill.pk + state = bill.get_payment_state_display().upper() + color = PAYMENT_STATE_COLORS.get(bill.payment_state, 'grey') + return '{name}'.format( + url=url, color=color, name=state) + display_payment_state.allow_tags = True + display_payment_state.short_description = _("Payment") + def get_readonly_fields(self, request, obj=None): fields = super(BillAdmin, self).get_readonly_fields(request, obj=obj) - if obj and obj.status != Bill.OPEN: + if obj and not obj.is_open: fields += self.add_fields return fields def get_fieldsets(self, request, obj=None): fieldsets = super(BillAdmin, self).get_fieldsets(request, obj=obj) - if obj and obj.status == obj.OPEN: + if obj and obj.is_open: fieldsets = (fieldsets[0],) return fieldsets def get_change_view_actions(self, obj=None): actions = super(BillAdmin, self).get_change_view_actions() - discard = [] + exclude = [] if obj: - if obj.status != Bill.OPEN: - discard = ['close_bills'] - if obj.status != Bill.CLOSED: - discard = ['send_bills'] - if not discard: - return actions - return [action for action in actions if action.__name__ not in discard] + if not obj.is_open: + exclude.append('close_bills') + return [action for action in actions if action.__name__ not in exclude] def get_inline_instances(self, request, obj=None): # Make parent object available for inline.has_add_permission() diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index 47012c3d..6020ffa7 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -4,6 +4,7 @@ from dateutil.relativedelta import relativedelta from django.db import models from django.template import loader, Context from django.utils import timezone +from django.utils.encoding import force_text from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ @@ -25,16 +26,13 @@ class BillManager(models.Manager): class Bill(models.Model): - OPEN = 'OPEN' - CLOSED = 'CLOSED' - SENT = 'SENT' + OPEN = '' PAID = 'PAID' + PENDING = 'PENDING' BAD_DEBT = 'BAD_DEBT' - STATUSES = ( - (OPEN, _("Open")), - (CLOSED, _("Closed")), - (SENT, _("Sent")), + PAYMENT_STATES = ( (PAID, _("Paid")), + (PENDING, _("Pending")), (BAD_DEBT, _("Bad debt")), ) @@ -51,10 +49,10 @@ class Bill(models.Model): account = models.ForeignKey('accounts.Account', verbose_name=_("account"), related_name='%(class)s') type = models.CharField(_("type"), max_length=16, choices=TYPES) - status = models.CharField(_("status"), max_length=16, choices=STATUSES, - default=OPEN) created_on = models.DateTimeField(_("created on"), auto_now_add=True) closed_on = models.DateTimeField(_("closed on"), blank=True, null=True) + is_open = models.BooleanField(_("is open"), default=True) + is_sent = models.BooleanField(_("is sent"), default=False) due_on = models.DateField(_("due on"), null=True, blank=True) last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True) total = models.DecimalField(max_digits=12, decimal_places=2, default=0) @@ -74,6 +72,21 @@ class Bill(models.Model): def buyer(self): return self.account.invoicecontact + @cached_property + def payment_state(self): + if self.is_open: + return self.OPEN + secured = self.transactions.secured().amount() + if secured >= self.total: + return self.PAID + elif self.transactions.exclude_rejected().exists(): + return self.PENDING + return self.BAD_DEBT + + def get_payment_state_display(self): + value = self.payment_state + return force_text(dict(self.PAYMENT_STATES).get(value, value)) + @classmethod def get_class_type(cls): return cls.__name__.upper() @@ -87,7 +100,7 @@ class Bill(models.Model): if bill_type == 'BILL': raise TypeError("get_new_number() can not be used on a Bill class") prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type) - if self.status == self.OPEN: + if self.is_open: prefix = 'O{}'.format(prefix) bills = cls.objects.filter(number__regex=r'^%s[1-9]+' % prefix) last_number = bills.order_by('-number').values_list('number', flat=True).first() @@ -110,7 +123,7 @@ class Bill(models.Model): return now + relativedelta(months=1) def close(self, payment=False): - assert self.status == self.OPEN, "Bill not in Open state" + assert self.is_open, "Bill not in Open state" if payment is False: payment = self.account.paymentsources.get_default() if not self.due_on: @@ -119,7 +132,8 @@ class Bill(models.Model): self.html = self.render(payment=payment) self.transactions.create(bill=self, source=payment, amount=self.total) self.closed_on = timezone.now() - self.status = self.CLOSED + self.is_open = False + self.is_sent = False self.save() def send(self): @@ -134,7 +148,7 @@ class Bill(models.Model): ('%s.pdf' % self.number, html_to_pdf(self.html), 'application/pdf') ] ) - self.status = self.SENT + self.is_sent = True self.save() def render(self, payment=False): @@ -166,7 +180,7 @@ class Bill(models.Model): def save(self, *args, **kwargs): if not self.type: self.type = self.get_type() - if not self.number or (self.number.startswith('O') and self.status != self.OPEN): + if not self.number or (self.number.startswith('O') and not self.is_open): self.set_number() super(Bill, self).save(*args, **kwargs) @@ -217,8 +231,8 @@ class BillLine(models.Model): description = models.CharField(_("description"), max_length=256) rate = models.DecimalField(_("rate"), blank=True, null=True, max_digits=12, decimal_places=2) - amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2) - total = models.DecimalField(_("total"), max_digits=12, decimal_places=2) + quantity = models.DecimalField(_("quantity"), max_digits=12, decimal_places=2) + subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2) tax = models.PositiveIntegerField(_("tax")) # TODO # order_id = models.ForeignKey('orders.Order', null=True, blank=True, @@ -236,15 +250,15 @@ class BillLine(models.Model): def get_total(self): """ Computes subline discounts """ - subtotal = self.total + total = self.subtotal for subline in self.sublines.all(): - subtotal += subline.total - return subtotal + total += subline.total + return total def save(self, *args, **kwargs): # TODO cost of this shit super(BillLine, self).save(*args, **kwargs) - if self.bill.status == self.bill.OPEN: + if self.bill.is_open: self.bill.total = self.bill.get_total() self.bill.save() @@ -260,7 +274,7 @@ class BillSubline(models.Model): def save(self, *args, **kwargs): # TODO cost of this shit super(BillSubline, self).save(*args, **kwargs) - if self.line.bill.status == self.line.bill.OPEN: + if self.line.bill.is_open: self.line.bill.total = self.line.bill.get_total() self.line.bill.save() diff --git a/orchestra/apps/domains/admin.py b/orchestra/apps/domains/admin.py index 6691d7e9..e7b6ce57 100644 --- a/orchestra/apps/domains/admin.py +++ b/orchestra/apps/domains/admin.py @@ -8,7 +8,7 @@ from django.template.response import TemplateResponse from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin -from orchestra.admin.utils import wrap_admin_view, admin_link +from orchestra.admin.utils import wrap_admin_view, admin_link, change_url from orchestra.apps.accounts.admin import AccountAdminMixin from orchestra.utils import apps @@ -79,7 +79,7 @@ class DomainAdmin(ChangeListDefaultFilter, AccountAdminMixin, ExtendedModelAdmin if webs: links = [] for web in webs: - url = reverse('admin:websites_website_change', args=(web.pk,)) + url = change_url(web) links.append('%s' % (url, web.name)) return '
'.join(links) return _("No website") diff --git a/orchestra/apps/issues/forms.py b/orchestra/apps/issues/forms.py index d8770238..5d26db9c 100644 --- a/orchestra/apps/issues/forms.py +++ b/orchestra/apps/issues/forms.py @@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from markdown import markdown +from orchestra.admin.utils import change_url from orchestra.apps.users.models import User from orchestra.forms.widgets import ReadOnlyWidget @@ -41,7 +42,7 @@ class MessageInlineForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(MessageInlineForm, self).__init__(*args, **kwargs) - admin_link = reverse('admin:users_user_change', args=(self.user.pk,)) + admin_link = change_url(self.user) self.fields['created_on'].widget = ReadOnlyWidget('') def clean_content(self): diff --git a/orchestra/apps/mails/admin.py b/orchestra/apps/mails/admin.py index 7100cf2a..280e02fe 100644 --- a/orchestra/apps/mails/admin.py +++ b/orchestra/apps/mails/admin.py @@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin -from orchestra.admin.utils import insertattr, admin_link +from orchestra.admin.utils import insertattr, admin_link, change_url from orchestra.apps.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin from orchestra.apps.domains.forms import DomainIterator @@ -57,7 +57,7 @@ class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin): def display_addresses(self, mailbox): addresses = [] for addr in mailbox.addresses.all(): - url = reverse('admin:mails_address_change', args=(addr.pk,)) + url = change_url(addr) addresses.append('%s' % (url, addr.email)) return '
'.join(addresses) display_addresses.short_description = _("Addresses") @@ -106,7 +106,7 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): def display_mailboxes(self, address): boxes = [] for mailbox in address.mailboxes.all(): - url = reverse('admin:mails_mailbox_change', args=(mailbox.pk,)) + url = change_url(mailbox) boxes.append('%s' % (url, mailbox.name)) return '
'.join(boxes) display_mailboxes.short_description = _("Mailboxes") diff --git a/orchestra/apps/orders/billing.py b/orchestra/apps/orders/billing.py index 21d3acb2..cb52235b 100644 --- a/orchestra/apps/orders/billing.py +++ b/orchestra/apps/orders/billing.py @@ -33,8 +33,8 @@ class BillsBackend(object): # Create bill line billine = bill.lines.create( rate=service.nominal_price, - amount=line.size, - total=line.subtotal, + quantity=line.size, + subtotal=line.subtotal, tax=service.tax, description=self.get_line_description(line), ) diff --git a/orchestra/apps/orders/forms.py b/orchestra/apps/orders/forms.py index a26465cb..1e573d4a 100644 --- a/orchestra/apps/orders/forms.py +++ b/orchestra/apps/orders/forms.py @@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from orchestra.admin.forms import AdminFormMixin -from orchestra.admin.utils import admin_change_url +from orchestra.admin.utils import change_url from .models import Order @@ -32,8 +32,8 @@ def selected_related_choices(queryset): verbose = '{description} ' verbose += '{account}' verbose = verbose.format( - order_url=admin_change_url(order), description=order.description, - account_url=admin_change_url(order.account), account=str(order.account) + order_url=change_url(order), description=order.description, + account_url=change_url(order.account), account=str(order.account) ) yield (order.pk, mark_safe(verbose)) diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index e21b9d96..386e85b8 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -1,6 +1,5 @@ import sys -from django.core.exceptions import ValidationError from django.db import models from django.db.migrations.recorder import MigrationRecorder from django.db.models import F, Q @@ -10,7 +9,6 @@ from django.dispatch import receiver from django.contrib.admin.models import LogEntry from django.contrib.contenttypes import generic from django.contrib.contenttypes.models import ContentType -from django.core.validators import ValidationError from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ diff --git a/orchestra/apps/orders/tests/functional_tests/tests.py b/orchestra/apps/orders/tests/functional_tests/tests.py index 555f50c9..b06c71a5 100644 --- a/orchestra/apps/orders/tests/functional_tests/tests.py +++ b/orchestra/apps/orders/tests/functional_tests/tests.py @@ -13,7 +13,7 @@ from orchestra.apps.users.models import User from orchestra.utils.tests import BaseTestCase, random_ascii -class ServiceTests(BaseTestCase): +class BillingTests(BaseTestCase): DEPENDENCIES = ( 'orchestra.apps.services', 'orchestra.apps.users', @@ -91,3 +91,18 @@ class ServiceTests(BaseTestCase): error = decimal.Decimal(0.05) self.assertGreater(10*size+error*(10*size), bills[0].get_total()) self.assertLess(10*size-error*(10*size), bills[0].get_total()) + + def test_ftp_account_with_compensation(self): + account = self.create_account() + service = self.create_ftp_service() + user = self.create_ftp(account=account) + bp = timezone.now().date() + relativedelta.relativedelta(years=2) + bills = service.orders.bill(billing_point=bp, fixed_point=True) + user.delete() + user = self.create_ftp(account=account) + bp = timezone.now().date() + relativedelta.relativedelta(years=1) + bills = service.orders.bill(billing_point=bp, fixed_point=True) + for line in bills[0].lines.all(): + print line + print line.sublines.all() + # TODO asserts diff --git a/orchestra/apps/payments/actions.py b/orchestra/apps/payments/actions.py index b07cc566..4896a5f4 100644 --- a/orchestra/apps/payments/actions.py +++ b/orchestra/apps/payments/actions.py @@ -1,9 +1,14 @@ +from functools import partial + from django.contrib import messages from django.db import transaction from django.shortcuts import render +from django.utils.safestring import mark_safe +from django.utils.text import capfirst from django.utils.translation import ugettext_lazy as _ from orchestra.admin.decorators import action_with_confirmation +from orchestra.admin.utils import change_url from .methods import PaymentMethod from .models import Transaction @@ -38,8 +43,7 @@ def process_transactions(modeladmin, request, queryset): @transaction.atomic @action_with_confirmation() -def mark_as_executed(modeladmin, request, queryset): - """ Mark a tickets as unread """ +def mark_as_executed(modeladmin, request, queryset, extra_context={}): for transaction in queryset: transaction.mark_as_executed() modeladmin.log_change(request, transaction, 'Executed') @@ -52,7 +56,6 @@ mark_as_executed.verbose_name = _("Mark as executed") @transaction.atomic @action_with_confirmation() def mark_as_secured(modeladmin, request, queryset): - """ Mark a tickets as unread """ for transaction in queryset: transaction.mark_as_secured() modeladmin.log_change(request, transaction, 'Secured') @@ -65,7 +68,6 @@ mark_as_secured.verbose_name = _("Mark as secured") @transaction.atomic @action_with_confirmation() def mark_as_rejected(modeladmin, request, queryset): - """ Mark a tickets as unread """ for transaction in queryset: transaction.mark_as_rejected() modeladmin.log_change(request, transaction, 'Rejected') @@ -73,3 +75,62 @@ def mark_as_rejected(modeladmin, request, queryset): modeladmin.message_user(request, msg) mark_as_rejected.url_name = 'reject' mark_as_rejected.verbose_name = _("Mark as rejected") + + +def _format_display_objects(modeladmin, request, queryset, related): + objects = [] + opts = modeladmin.model._meta + for obj in queryset: + objects.append( + mark_safe('{0}: {2}'.format( + capfirst(opts.verbose_name), change_url(obj), obj)) + ) + subobjects = [] + attr, verb = related + for related in getattr(obj.transactions, attr)(): + subobjects.append( + mark_safe('{0}: {2} will be marked as {3}'.format( + capfirst(subobj.get_type().lower()), change_url(subobj), subobj, verb)) + ) + objects.append(subobjects) + return {'display_objects': objects} + +_format_executed = partial(_format_display_objects, related=('all', 'executed')) +_format_abort = partial(_format_display_objects, related=('processing', 'aborted')) +_format_commit = partial(_format_display_objects, related=('all', 'secured')) + + +@transaction.atomic +@action_with_confirmation(extra_context=_format_executed) +def mark_process_as_executed(modeladmin, request, queryset): + for process in queryset: + process.mark_as_executed() + modeladmin.log_change(request, process, 'Executed') + msg = _("%s selected processes have been marked as executed.") % queryset.count() + modeladmin.message_user(request, msg) +mark_process_as_executed.url_name = 'executed' +mark_process_as_executed.verbose_name = _("Mark as executed") + + +@transaction.atomic +@action_with_confirmation(extra_context=_format_abort) +def abort(modeladmin, request, queryset): + for process in queryset: + process.abort() + modeladmin.log_change(request, process, 'Aborted') + msg = _("%s selected processes have been aborted.") % queryset.count() + modeladmin.message_user(request, msg) +abort.url_name = 'abort' +abort.verbose_name = _("Abort") + + +@transaction.atomic +@action_with_confirmation(extra_context=_format_commit) +def commit(modeladmin, request, queryset): + for transaction in queryset: + transaction.mark_as_rejected() + modeladmin.log_change(request, transaction, 'Rejected') + msg = _("%s selected transactions have been marked as rejected.") % queryset.count() + modeladmin.message_user(request, msg) +commit.url_name = 'commit' +commit.verbose_name = _("Commit") diff --git a/orchestra/apps/payments/admin.py b/orchestra/apps/payments/admin.py index 1416f33a..490c9d46 100644 --- a/orchestra/apps/payments/admin.py +++ b/orchestra/apps/payments/admin.py @@ -16,76 +16,13 @@ from .models import PaymentSource, Transaction, TransactionProcess STATE_COLORS = { Transaction.WAITTING_PROCESSING: 'darkorange', - Transaction.WAITTING_CONFIRMATION: 'magenta', + Transaction.WAITTING_EXECUTION: 'magenta', Transaction.EXECUTED: 'olive', Transaction.SECURED: 'green', Transaction.REJECTED: 'red', } -class TransactionInline(admin.TabularInline): - model = Transaction - can_delete = False - extra = 0 - fields = ( - 'transaction_link', 'bill_link', 'source_link', 'display_state', - 'amount', 'currency' - ) - readonly_fields = fields - - transaction_link = admin_link('__unicode__', short_description=_("ID")) - bill_link = admin_link('bill') - source_link = admin_link('source') - display_state = admin_colored('state', colors=STATE_COLORS) - - class Media: - css = { - 'all': ('orchestra/css/hide-inline-id.css',) - } - - def has_add_permission(self, *args, **kwargs): - return False - - -class TransactionAdmin(ChangeViewActionsMixin, AccountAdminMixin, admin.ModelAdmin): - list_display = ( - 'id', 'bill_link', 'account_link', 'source_link', 'display_state', - 'amount', 'process_link' - ) - list_filter = ('source__method', 'state') - actions = ( - actions.process_transactions, actions.mark_as_executed, - actions.mark_as_secured, actions.mark_as_rejected - ) - change_view_actions = actions - filter_by_account_fields = ['source'] - readonly_fields = ('bill_link', 'display_state', 'process_link', 'account_link') - - bill_link = admin_link('bill') - source_link = admin_link('source') - process_link = admin_link('process', short_description=_("proc")) - account_link = admin_link('bill__account') - display_state = admin_colored('state', colors=STATE_COLORS) - - def get_queryset(self, request): - qs = super(TransactionAdmin, self).get_queryset(request) - return qs.select_related('source', 'bill__account__user') - - def get_change_view_actions(self, obj=None): - actions = super(TransactionAdmin, self).get_change_view_actions() - discard = [] - if obj: - if obj.state == Transaction.EXECUTED: - discard = ['mark_as_executed'] - elif obj.state == Transaction.REJECTED: - discard = ['mark_as_rejected'] - elif obj.state == Transaction.SECURED: - discard = ['mark_as_secured'] - if not discard: - return actions - return [action for action in actions if action.__name__ not in discard] - - class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin): list_display = ('label', 'method', 'number', 'account_link', 'is_active') list_filter = ('method', 'is_active') @@ -138,11 +75,74 @@ class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin): obj.save() -class TransactionProcessAdmin(admin.ModelAdmin): +class TransactionInline(admin.TabularInline): + model = Transaction + can_delete = False + extra = 0 + fields = ( + 'transaction_link', 'bill_link', 'source_link', 'display_state', + 'amount', 'currency' + ) + readonly_fields = fields + + transaction_link = admin_link('__unicode__', short_description=_("ID")) + bill_link = admin_link('bill') + source_link = admin_link('source') + display_state = admin_colored('state', colors=STATE_COLORS) + + class Media: + css = { + 'all': ('orchestra/css/hide-inline-id.css',) + } + + def has_add_permission(self, *args, **kwargs): + return False + + +class TransactionAdmin(ChangeViewActionsMixin, AccountAdminMixin, admin.ModelAdmin): + list_display = ( + 'id', 'bill_link', 'account_link', 'source_link', 'display_state', + 'amount', 'process_link' + ) + list_filter = ('source__method', 'state') + actions = ( + actions.process_transactions, actions.mark_as_executed, + actions.mark_as_secured, actions.mark_as_rejected + ) + change_view_actions = actions + filter_by_account_fields = ['source'] + readonly_fields = ('bill_link', 'display_state', 'process_link', 'account_link') + + bill_link = admin_link('bill') + source_link = admin_link('source') + process_link = admin_link('process', short_description=_("proc")) + account_link = admin_link('bill__account') + display_state = admin_colored('state', colors=STATE_COLORS) + + def get_queryset(self, request): + qs = super(TransactionAdmin, self).get_queryset(request) + return qs.select_related('source', 'bill__account__user') + + def get_change_view_actions(self, obj=None): + actions = super(TransactionAdmin, self).get_change_view_actions() + exclude = [] + if obj: + if obj.state == Transaction.EXECUTED: + exclude.append('mark_as_executed') + elif obj.state == Transaction.REJECTED: + exclude.append('mark_as_rejected') + elif obj.state == Transaction.SECURED: + exclude.append('mark_as_secured') + return [action for action in actions if action.__name__ not in exclude] + + +class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin): list_display = ('id', 'file_url', 'display_transactions', 'created_at') fields = ('data', 'file_url', 'display_transactions', 'created_at') readonly_fields = ('file_url', 'display_transactions', 'created_at') inlines = [TransactionInline] + actions = (actions.mark_process_as_executed, actions.abort, actions.commit) + change_view_actions = actions def file_url(self, process): if process.file: @@ -169,6 +169,18 @@ class TransactionProcessAdmin(admin.ModelAdmin): return '%s' % (url, '
'.join(lines)) display_transactions.short_description = _("Transactions") display_transactions.allow_tags = True + + def get_change_view_actions(self, obj=None): + actions = super(TransactionProcessAdmin, self).get_change_view_actions() + exclude = [] + if obj: + if obj.state == TransactionProcess.EXECUTED: + exclude.append('mark_process_as_executed') + elif obj.state == TransactionProcess.COMMITED: + exclude = ['mark_process_as_executed', 'abort', 'commit'] + elif obj.state == TransactionProcess.ABORTED: + exclude = ['mark_process_as_executed', 'abort', 'commit'] + return [action for action in actions if action.__name__ not in exclude] admin.site.register(PaymentSource, PaymentSourceAdmin) diff --git a/orchestra/apps/payments/models.py b/orchestra/apps/payments/models.py index b1726ffe..77816028 100644 --- a/orchestra/apps/payments/models.py +++ b/orchestra/apps/payments/models.py @@ -59,27 +59,36 @@ class TransactionQuerySet(models.QuerySet): source = kwargs.get('source') if source is None or not hasattr(source.method_class, 'process'): # Manual payments don't need processing - kwargs['state']=self.model.WAITTING_CONFIRMATION + kwargs['state']=self.model.WAITTING_EXECUTION return super(TransactionQuerySet, self).create(**kwargs) + + def secured(self): + return self.filter(state=Transaction.SECURED) + + def exclude_rejected(self): + return self.exclude(state=Transaction.REJECTED) + + def amount(self): + return self.aggregate(models.Sum('amount')).values()[0] + + def processing(self): + return self.filter(state__in=[Transaction.EXECUTED, Transaction.WAITTING_EXECUTION]) -# TODO lock transaction in waiting confirmation class Transaction(models.Model): WAITTING_PROCESSING = 'WAITTING_PROCESSING' # CREATED - WAITTING_CONFIRMATION = 'WAITTING_CONFIRMATION' # PROCESSED + WAITTING_EXECUTION = 'WAITTING_EXECUTION' # PROCESSED EXECUTED = 'EXECUTED' SECURED = 'SECURED' REJECTED = 'REJECTED' STATES = ( (WAITTING_PROCESSING, _("Waitting processing")), - (WAITTING_CONFIRMATION, _("Waitting confirmation")), + (WAITTING_EXECUTION, _("Waitting execution")), (EXECUTED, _("Executed")), (SECURED, _("Secured")), (REJECTED, _("Rejected")), ) - objects = TransactionQuerySet.as_manager() - bill = models.ForeignKey('bills.bill', verbose_name=_("bill"), related_name='transactions') source = models.ForeignKey(PaymentSource, null=True, blank=True, @@ -93,6 +102,8 @@ class Transaction(models.Model): created_on = models.DateTimeField(auto_now_add=True) modified_on = models.DateTimeField(auto_now=True) + objects = TransactionQuerySet.as_manager() + def __unicode__(self): return "Transaction {}".format(self.id) @@ -100,19 +111,26 @@ class Transaction(models.Model): def account(self): return self.bill.account + def clean(self): + if not self.pk: + amount = self.bill.transactions.exclude(state=self.REJECTED).amount() + if amount >= self.bill.total: + raise ValidationError(_("New transactions can not be allocated for this bill")) + + def mark_as_processed(self): + self.state = self.WAITTING_EXECUTION + self.save() + def mark_as_executed(self): self.state = self.EXECUTED self.save() def mark_as_secured(self): self.state = self.SECURED - # TODO think carefully about bill feedback - self.bill.mark_as_paid() self.save() def mark_as_rejected(self): self.state = self.REJECTED - # TODO bill feedback self.save() @@ -120,15 +138,49 @@ class TransactionProcess(models.Model): """ Stores arbitrary data generated by payment methods while processing transactions """ + CREATED = 'CREATED' + EXECUTED = 'EXECUTED' + ABORTED = 'ABORTED' + COMMITED = 'COMMITED' + STATES = ( + (CREATED, _("Created")), + (EXECUTED, _("Executed")), + (ABORTED, _("Aborted")), + (COMMITED, _("Commited")), + ) + data = JSONField(_("data"), blank=True) file = models.FileField(_("file"), blank=True) - created_at = models.DateTimeField(_("created at"), auto_now_add=True) + state = models.CharField(_("state"), max_length=16, choices=STATES, default=CREATED) + created_at = models.DateTimeField(_("created"), auto_now_add=True) + updated_at = models.DateTimeField(_("updated"), auto_now=True) class Meta: verbose_name_plural = _("Transaction processes") def __unicode__(self): return str(self.id) + + def mark_as_executed(self): + assert self.state == self.CREATED + self.state = self.EXECUTED + for transaction in self.transactions.all(): + transaction.mark_as_executed() + self.save() + + def abort(self): + assert self.state in [self.CREATED, self.EXCECUTED] + self.state = self.ABORTED + for transaction in self.transaction.all(): + transaction.mark_as_aborted() + self.save() + + def commit(self): + assert self.state in [self.CREATED, self.EXECUTED] + self.state = self.COMMITED + for transaction in self.transactions.processing(): + transaction.mark_as_secured() + self.save() accounts.register(PaymentSource) diff --git a/orchestra/apps/services/handlers.py b/orchestra/apps/services/handlers.py index 80bd12cc..1f968b18 100644 --- a/orchestra/apps/services/handlers.py +++ b/orchestra/apps/services/handlers.py @@ -164,7 +164,7 @@ class ServiceHandler(plugins.Plugin): for order in givers: if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: interval = helpers.Interval(order.cancelled_on, order.billed_until, order) - compensations.append[interval] + compensations.append(interval) for order in receivers: if not order.billed_until or order.billed_until < order.new_billed_until: # receiver @@ -277,9 +277,10 @@ class ServiceHandler(plugins.Plugin): ini = min(ini, cini) end = max(end, bp) related_orders = account.orders.filter(service=self.service) - if self.on_cancel == self.COMPENSATE: + if self.on_cancel == self.DISCOUNT: # Get orders pending for compensation - givers = related_orders.filter_givers(ini, end) + givers = list(related_orders.filter_givers(ini, end)) + print givers givers.sort(cmp=helpers.cmp_billed_until_or_registered_on) orders.sort(cmp=helpers.cmp_billed_until_or_registered_on) self.compensate(givers, orders, commit=commit) @@ -341,6 +342,7 @@ class ServiceHandler(plugins.Plugin): return lines def generate_bill_lines(self, orders, account, **options): + # TODO filter out orders with cancelled_on < billed_until ? if not self.metric: lines = self.bill_with_orders(orders, account, **options) else: diff --git a/orchestra/apps/services/models.py b/orchestra/apps/services/models.py index 30d64a0f..ed8da1d2 100644 --- a/orchestra/apps/services/models.py +++ b/orchestra/apps/services/models.py @@ -1,6 +1,5 @@ import sys -from django.core.exceptions import ValidationError from django.db import models from django.db.models import F, Q from django.db.models.signals import pre_delete, post_delete, post_save @@ -90,7 +89,6 @@ class Service(models.Model): NOTHING = 'NOTHING' DISCOUNT = 'DISCOUNT' REFOUND = 'REFOUND' - COMPENSATE = 'COMPENSATE' PREPAY = 'PREPAY' POSTPAY = 'POSTPAY' STEP_PRICE = 'STEP_PRICE' @@ -174,7 +172,6 @@ class Service(models.Model): choices=( (NOTHING, _("Nothing")), (DISCOUNT, _("Discount")), - (COMPENSATE, _("Discount and compensate")), ), default=DISCOUNT) payment_style = models.CharField(_("payment style"), max_length=16, @@ -229,11 +226,10 @@ class Service(models.Model): def clean(self): content_type = self.handler.get_content_type() if self.content_type != content_type: - msg =_("Content type must be equal to '%s'.") % str(content_type) - raise ValidationError(msg) + ct = str(content_type) + raise ValidationError(_("Content type must be equal to '%s'.") % ct) if not self.match: - msg =_("Match should be provided") - raise ValidationError(msg) + raise ValidationError(_("Match should be provided")) try: obj = content_type.model_class().objects.all()[0] except IndexError: diff --git a/orchestra/apps/services/tests/test_handler.py b/orchestra/apps/services/tests/test_handler.py index 8988b90c..91009d37 100644 --- a/orchestra/apps/services/tests/test_handler.py +++ b/orchestra/apps/services/tests/test_handler.py @@ -136,7 +136,7 @@ class HandlerTests(BaseTestCase): self.assertIn([order4.billed_until, end, [order2, order3]], chunks) def test_sort_billed_until_or_registered_on(self): - now = timezone.now() + now = timezone.now().date() order = Order( billed_until=now+datetime.timedelta(days=200)) order1 = Order( @@ -158,7 +158,7 @@ class HandlerTests(BaseTestCase): self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on)) def test_compensation(self): - now = timezone.now() + now = timezone.now().date() order = Order( description='0', billed_until=now+datetime.timedelta(days=220), @@ -353,5 +353,16 @@ class HandlerTests(BaseTestCase): self.assertEqual(rate['price'], result.price) self.assertEqual(rate['quantity'], result.quantity) - def test_compensations(self): - pass + def test_generate_bill_lines_with_compensation(self): + service = self.create_ftp_service() + account = self.create_account() + now = timezone.now().date() + order = Order( + cancelled_on=now, + billed_until=now+relativedelta.relativedelta(years=2) + ) + order1 = Order() + orders = [order, order1] + lines = service.handler.generate_bill_lines(orders, account, commit=False) + print lines + print len(lines) diff --git a/orchestra/apps/users/roles/admin.py b/orchestra/apps/users/roles/admin.py index 735e4d72..4d3711c5 100644 --- a/orchestra/apps/users/roles/admin.py +++ b/orchestra/apps/users/roles/admin.py @@ -10,7 +10,7 @@ from django.utils.encoding import force_text from django.utils.html import escape from django.utils.translation import ugettext, ugettext_lazy as _ -from orchestra.admin.utils import get_modeladmin +from orchestra.admin.utils import get_modeladmin, change_url from .forms import role_form_factory from ..models import User @@ -71,9 +71,8 @@ class RoleAdmin(object): modeladmin.log_change(request, request.user, "%s saved" % self.name.capitalize()) msg = _('The role %(name)s for user "%(obj)s" was %(action)s successfully.') % context modeladmin.message_user(request, msg, messages.SUCCESS) - url = 'admin:%s_%s_change' % (opts.app_label, opts.module_name) if not "_continue" in request.POST: - return redirect(url, object_id) + return redirect(change_url(user)) exists = True if exists: @@ -117,7 +116,7 @@ class RoleAdmin(object): obj_display = force_text(obj) modeladmin.log_deletion(request, obj, obj_display) modeladmin.delete_model(request, obj) - post_url = reverse('admin:users_user_change', args=(user.pk,)) + post_url = change_url(user) preserved_filters = modeladmin.get_preserved_filters(request) post_url = add_preserved_filters( {'preserved_filters': preserved_filters, 'opts': opts}, post_url diff --git a/orchestra/apps/webapps/admin.py b/orchestra/apps/webapps/admin.py index 00aca017..ae37b49c 100644 --- a/orchestra/apps/webapps/admin.py +++ b/orchestra/apps/webapps/admin.py @@ -4,6 +4,7 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin +from orchestra.admin.utils import change_url from orchestra.apps.accounts.admin import SelectAccountAdminMixin from .models import WebApp, WebAppOption @@ -37,7 +38,7 @@ class WebAppAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): websites = [] for content in webapp.content_set.all().select_related('website'): website = content.website - url = reverse('admin:websites_website_change', args=(website.pk,)) + url = change_url(website) name = "%s on %s" % (website.name, content.path) websites.append('%s' % (url, name)) return '
'.join(websites) diff --git a/orchestra/apps/websites/admin.py b/orchestra/apps/websites/admin.py index b67ac4ad..7bd474a1 100644 --- a/orchestra/apps/websites/admin.py +++ b/orchestra/apps/websites/admin.py @@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin -from orchestra.admin.utils import admin_link +from orchestra.admin.utils import admin_link, change_url from orchestra.apps.accounts.admin import AccountAdminMixin, SelectAccountAdminMixin from orchestra.apps.accounts.widgets import account_related_field_widget_factory @@ -72,7 +72,7 @@ class WebsiteAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): webapps = [] for content in website.content_set.all().select_related('webapp'): webapp = content.webapp - url = reverse('admin:webapps_webapp_change', args=(webapp.pk,)) + url = change_url(webapp) name = "%s on %s" % (webapp.get_type_display(), content.path) webapps.append('%s' % (url, name)) return '
'.join(webapps) diff --git a/orchestra/conf/base_settings.py b/orchestra/conf/base_settings.py index 18fd6d69..3531af96 100644 --- a/orchestra/conf/base_settings.py +++ b/orchestra/conf/base_settings.py @@ -97,6 +97,7 @@ INSTALLED_APPS = ( 'rest_framework', 'rest_framework.authtoken', 'passlib.ext.django', + 'django_nose', # Django.contrib 'django.contrib.auth', @@ -248,3 +249,6 @@ PASSLIB_CONFIG = ( "superuser__django_pbkdf2_sha256__default_rounds = 15000\n" "superuser__sha512_crypt__default_rounds = 120000\n" ) + + +TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' diff --git a/orchestra/templates/admin/orchestra/generic_confirmation.html b/orchestra/templates/admin/orchestra/generic_confirmation.html index 5ba2fa12..acc04729 100644 --- a/orchestra/templates/admin/orchestra/generic_confirmation.html +++ b/orchestra/templates/admin/orchestra/generic_confirmation.html @@ -27,11 +27,7 @@

{{ content_message | safe }}

- +
    {{ display_objects | unordered_list }}
{% csrf_token %} {% if form %}
@@ -53,7 +49,6 @@ {% if formset %} {{ formset.as_admin }} {% endif %} -
{% for obj in queryset %} diff --git a/orchestra/templatetags/utils.py b/orchestra/templatetags/utils.py index e71196fd..372f4943 100644 --- a/orchestra/templatetags/utils.py +++ b/orchestra/templatetags/utils.py @@ -3,7 +3,7 @@ from django.core.urlresolvers import reverse, NoReverseMatch from django.forms import CheckboxInput from orchestra import get_version -from orchestra.admin.utils import admin_change_url +from orchestra.admin.utils import change_url register = template.Library() @@ -49,4 +49,4 @@ def is_checkbox(field): @register.filter def admin_link(obj): - return admin_change_url(obj) + return change_url(obj)