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