Refactoring payment process

This commit is contained in:
Marc 2014-09-18 15:07:39 +00:00
parent 721bcd9002
commit 1456c457fc
25 changed files with 375 additions and 183 deletions

View file

@ -2,9 +2,12 @@ from functools import wraps, partial
from django.contrib import messages from django.contrib import messages
from django.contrib.admin import helpers 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.decorators import available_attrs
from django.utils.encoding import force_text 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): def admin_field(method):
@ -24,6 +27,17 @@ def admin_field(method):
return admin_field_wrapper 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}: <a href="{1}">{2}</a>',
capfirst(opts.verbose_name), change_url(obj), obj)
)
return objects
def action_with_confirmation(action_name=None, extra_context={}, def action_with_confirmation(action_name=None, extra_context={},
template='admin/orchestra/generic_confirmation.html'): 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: If custom template is provided the form must contain:
<input type="hidden" name="post" value="generic_confirmation" /> <input type="hidden" name="post" value="generic_confirmation" />
""" """
def decorator(func, extra_context=extra_context, template=template, action_name=action_name): def decorator(func, extra_context=extra_context, template=template, action_name=action_name):
@wraps(func, assigned=available_attrs(func)) @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. # 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) stay = func(modeladmin, request, queryset)
if not stay: if not stay:
return return
@ -51,19 +66,23 @@ def action_with_confirmation(action_name=None, extra_context={},
if not action_name: if not action_name:
action_name = func.__name__ action_name = func.__name__
context = { context = {
"title": "Are you sure?", 'title': _("Are you sure?"),
"content_message": "Are you sure you want to %s the selected %s?" % 'content_message': _("Are you sure you want to {action} the selected {item}?").format(
(action_name, objects_name), action=action_name, item=objects_name),
"action_name": action_name.capitalize(), 'action_name': action_name.capitalize(),
"action_value": action_value, 'action_value': action_value,
"display_objects": queryset,
'queryset': queryset, 'queryset': queryset,
"opts": opts, 'opts': opts,
"app_label": app_label, 'app_label': app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
} }
if callable(extra_context):
extra_context = extra_context(modeladmin, request, queryset)
context.update(extra_context) 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 # Display the confirmation page
return TemplateResponse(request, template, return TemplateResponse(request, template,

View file

@ -91,7 +91,7 @@ def action_to_view(action, modeladmin):
return action_view return action_view
def admin_change_url(obj): def change_url(obj):
opts = obj._meta opts = obj._meta
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name) view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
return reverse(view_name, args=(obj.pk,)) return reverse(view_name, args=(obj.pk,))
@ -106,7 +106,7 @@ def admin_link(*args, **kwargs):
obj = get_field_value(instance, kwargs['field']) obj = get_field_value(instance, kwargs['field'])
if not getattr(obj, 'pk', None): if not getattr(obj, 'pk', None):
return '---' return '---'
url = admin_change_url(obj) url = change_url(obj)
extra = '' extra = ''
if kwargs['popup']: if kwargs['popup']:
extra = 'onclick="return showAddAnotherPopup(this);"' extra = 'onclick="return showAddAnotherPopup(this);"'

View file

@ -9,7 +9,8 @@ from django.utils.six.moves.urllib.parse import parse_qsl
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin 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 orchestra.core import services, accounts
from .filters import HasMainUserListFilter from .filters import HasMainUserListFilter
@ -136,8 +137,7 @@ class AccountAdminMixin(object):
def account_link(self, instance): def account_link(self, instance):
account = instance.account if instance.pk else self.account account = instance.account if instance.pk else self.account
url = reverse('admin:accounts_account_change', args=(account.pk,)) url = change_url(account)
pk = account.pk
return '<a href="%s">%s</a>' % (url, str(account)) return '<a href="%s">%s</a>' % (url, str(account))
account_link.short_description = _("account") account_link.short_description = _("account")
account_link.allow_tags = True account_link.allow_tags = True

View file

@ -42,7 +42,7 @@ view_bill.url_name = 'view'
def close_bills(modeladmin, request, queryset): def close_bills(modeladmin, request, queryset):
queryset = queryset.filter(status=queryset.model.OPEN) queryset = queryset.filter(is_open=True)
if not queryset: if not queryset:
messages.warning(request, _("Selected bills should be in open state")) messages.warning(request, _("Selected bills should be in open state"))
return return

View file

@ -1,9 +1,7 @@
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
#from django.contrib.admin.utils import unquote
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
#from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin from orchestra.admin import ExtendedModelAdmin
@ -17,23 +15,30 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForm
BillLine) BillLine)
PAYMENT_STATE_COLORS = {
Bill.PAID: 'green',
Bill.PENDING: 'darkorange',
Bill.BAD_DEBT: 'red',
}
class BillLineInline(admin.TabularInline): class BillLineInline(admin.TabularInline):
model = BillLine model = BillLine
fields = ('description', 'rate', 'amount', 'tax', 'total', 'get_total') fields = ('description', 'rate', 'quantity', 'tax', 'subtotal', 'get_total')
readonly_fields = ('get_total',) readonly_fields = ('get_total',)
def get_readonly_fields(self, request, obj=None): 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 self.fields
return super(BillLineInline, self).get_readonly_fields(request, obj=obj) return super(BillLineInline, self).get_readonly_fields(request, obj=obj)
def has_add_permission(self, request): 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 False
return super(BillLineInline, self).has_add_permission(request) return super(BillLineInline, self).has_add_permission(request)
def has_delete_permission(self, request, obj=None): 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 False
return super(BillLineInline, self).has_delete_permission(request, obj=obj) return super(BillLineInline, self).has_delete_permission(request, obj=obj)
@ -48,15 +53,15 @@ class BillLineInline(admin.TabularInline):
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = ( list_display = (
'number', 'status', 'type_link', 'account_link', 'created_on_display', 'number', 'is_open', 'type_link', 'account_link', 'created_on_display',
'num_lines', 'display_total' 'num_lines', 'display_total', 'display_payment_state'
) )
list_filter = (BillTypeListFilter, 'status',) list_filter = (BillTypeListFilter, 'is_open',)
add_fields = ('account', 'type', 'status', 'due_on', 'comments') add_fields = ('account', 'type', 'is_open', 'due_on', 'comments')
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('number', 'display_total', 'account_link', 'type', 'fields': ('number', 'display_total', 'account_link', 'type',
'status', 'due_on', 'comments'), 'is_open', 'display_payment_state', 'is_sent', 'due_on', 'comments'),
}), }),
(_("Raw"), { (_("Raw"), {
'classes': ('collapse',), 'classes': ('collapse',),
@ -65,8 +70,8 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
) )
actions = [download_bills, close_bills, send_bills] actions = [download_bills, close_bills, send_bills]
change_view_actions = [view_bill, download_bills, send_bills, close_bills] change_view_actions = [view_bill, download_bills, send_bills, close_bills]
change_readonly_fields = ('account_link', 'type', 'status') change_readonly_fields = ('account_link', 'type', 'is_open')
readonly_fields = ('number', 'display_total') readonly_fields = ('number', 'display_total', 'display_payment_state')
inlines = [BillLineInline] inlines = [BillLineInline]
created_on_display = admin_date('created_on') created_on_display = admin_date('created_on')
@ -90,29 +95,36 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
type_link.short_description = _("type") type_link.short_description = _("type")
type_link.admin_order_field = '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 '<a href="{url}" style="color:{color}">{name}</a>'.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): def get_readonly_fields(self, request, obj=None):
fields = super(BillAdmin, self).get_readonly_fields(request, obj=obj) 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 fields += self.add_fields
return fields return fields
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super(BillAdmin, self).get_fieldsets(request, obj=obj) 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],) fieldsets = (fieldsets[0],)
return fieldsets return fieldsets
def get_change_view_actions(self, obj=None): def get_change_view_actions(self, obj=None):
actions = super(BillAdmin, self).get_change_view_actions() actions = super(BillAdmin, self).get_change_view_actions()
discard = [] exclude = []
if obj: if obj:
if obj.status != Bill.OPEN: if not obj.is_open:
discard = ['close_bills'] exclude.append('close_bills')
if obj.status != Bill.CLOSED: return [action for action in actions if action.__name__ not in exclude]
discard = ['send_bills']
if not discard:
return actions
return [action for action in actions if action.__name__ not in discard]
def get_inline_instances(self, request, obj=None): def get_inline_instances(self, request, obj=None):
# Make parent object available for inline.has_add_permission() # Make parent object available for inline.has_add_permission()

View file

@ -4,6 +4,7 @@ from dateutil.relativedelta import relativedelta
from django.db import models from django.db import models
from django.template import loader, Context from django.template import loader, Context
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import force_text
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -25,16 +26,13 @@ class BillManager(models.Manager):
class Bill(models.Model): class Bill(models.Model):
OPEN = 'OPEN' OPEN = ''
CLOSED = 'CLOSED'
SENT = 'SENT'
PAID = 'PAID' PAID = 'PAID'
PENDING = 'PENDING'
BAD_DEBT = 'BAD_DEBT' BAD_DEBT = 'BAD_DEBT'
STATUSES = ( PAYMENT_STATES = (
(OPEN, _("Open")),
(CLOSED, _("Closed")),
(SENT, _("Sent")),
(PAID, _("Paid")), (PAID, _("Paid")),
(PENDING, _("Pending")),
(BAD_DEBT, _("Bad debt")), (BAD_DEBT, _("Bad debt")),
) )
@ -51,10 +49,10 @@ class Bill(models.Model):
account = models.ForeignKey('accounts.Account', verbose_name=_("account"), account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='%(class)s') related_name='%(class)s')
type = models.CharField(_("type"), max_length=16, choices=TYPES) 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) created_on = models.DateTimeField(_("created on"), auto_now_add=True)
closed_on = models.DateTimeField(_("closed on"), blank=True, null=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) due_on = models.DateField(_("due on"), null=True, blank=True)
last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True) last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True)
total = models.DecimalField(max_digits=12, decimal_places=2, default=0) total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
@ -74,6 +72,21 @@ class Bill(models.Model):
def buyer(self): def buyer(self):
return self.account.invoicecontact 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 @classmethod
def get_class_type(cls): def get_class_type(cls):
return cls.__name__.upper() return cls.__name__.upper()
@ -87,7 +100,7 @@ class Bill(models.Model):
if bill_type == 'BILL': if bill_type == 'BILL':
raise TypeError("get_new_number() can not be used on a Bill class") raise TypeError("get_new_number() can not be used on a Bill class")
prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type) prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type)
if self.status == self.OPEN: if self.is_open:
prefix = 'O{}'.format(prefix) prefix = 'O{}'.format(prefix)
bills = cls.objects.filter(number__regex=r'^%s[1-9]+' % prefix) bills = cls.objects.filter(number__regex=r'^%s[1-9]+' % prefix)
last_number = bills.order_by('-number').values_list('number', flat=True).first() 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) return now + relativedelta(months=1)
def close(self, payment=False): 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: if payment is False:
payment = self.account.paymentsources.get_default() payment = self.account.paymentsources.get_default()
if not self.due_on: if not self.due_on:
@ -119,7 +132,8 @@ class Bill(models.Model):
self.html = self.render(payment=payment) self.html = self.render(payment=payment)
self.transactions.create(bill=self, source=payment, amount=self.total) self.transactions.create(bill=self, source=payment, amount=self.total)
self.closed_on = timezone.now() self.closed_on = timezone.now()
self.status = self.CLOSED self.is_open = False
self.is_sent = False
self.save() self.save()
def send(self): def send(self):
@ -134,7 +148,7 @@ class Bill(models.Model):
('%s.pdf' % self.number, html_to_pdf(self.html), 'application/pdf') ('%s.pdf' % self.number, html_to_pdf(self.html), 'application/pdf')
] ]
) )
self.status = self.SENT self.is_sent = True
self.save() self.save()
def render(self, payment=False): def render(self, payment=False):
@ -166,7 +180,7 @@ class Bill(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.type: if not self.type:
self.type = self.get_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() self.set_number()
super(Bill, self).save(*args, **kwargs) super(Bill, self).save(*args, **kwargs)
@ -217,8 +231,8 @@ class BillLine(models.Model):
description = models.CharField(_("description"), max_length=256) description = models.CharField(_("description"), max_length=256)
rate = models.DecimalField(_("rate"), blank=True, null=True, rate = models.DecimalField(_("rate"), blank=True, null=True,
max_digits=12, decimal_places=2) max_digits=12, decimal_places=2)
amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2) quantity = models.DecimalField(_("quantity"), max_digits=12, decimal_places=2)
total = models.DecimalField(_("total"), max_digits=12, decimal_places=2) subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
tax = models.PositiveIntegerField(_("tax")) tax = models.PositiveIntegerField(_("tax"))
# TODO # TODO
# order_id = models.ForeignKey('orders.Order', null=True, blank=True, # order_id = models.ForeignKey('orders.Order', null=True, blank=True,
@ -236,15 +250,15 @@ class BillLine(models.Model):
def get_total(self): def get_total(self):
""" Computes subline discounts """ """ Computes subline discounts """
subtotal = self.total total = self.subtotal
for subline in self.sublines.all(): for subline in self.sublines.all():
subtotal += subline.total total += subline.total
return subtotal return total
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# TODO cost of this shit # TODO cost of this shit
super(BillLine, self).save(*args, **kwargs) 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.total = self.bill.get_total()
self.bill.save() self.bill.save()
@ -260,7 +274,7 @@ class BillSubline(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# TODO cost of this shit # TODO cost of this shit
super(BillSubline, self).save(*args, **kwargs) 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.total = self.line.bill.get_total()
self.line.bill.save() self.line.bill.save()

View file

@ -8,7 +8,7 @@ from django.template.response import TemplateResponse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin 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.apps.accounts.admin import AccountAdminMixin
from orchestra.utils import apps from orchestra.utils import apps
@ -79,7 +79,7 @@ class DomainAdmin(ChangeListDefaultFilter, AccountAdminMixin, ExtendedModelAdmin
if webs: if webs:
links = [] links = []
for web in webs: for web in webs:
url = reverse('admin:websites_website_change', args=(web.pk,)) url = change_url(web)
links.append('<a href="%s">%s</a>' % (url, web.name)) links.append('<a href="%s">%s</a>' % (url, web.name))
return '<br>'.join(links) return '<br>'.join(links)
return _("No website") return _("No website")

View file

@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from markdown import markdown from markdown import markdown
from orchestra.admin.utils import change_url
from orchestra.apps.users.models import User from orchestra.apps.users.models import User
from orchestra.forms.widgets import ReadOnlyWidget from orchestra.forms.widgets import ReadOnlyWidget
@ -41,7 +42,7 @@ class MessageInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(MessageInlineForm, self).__init__(*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('') self.fields['created_on'].widget = ReadOnlyWidget('')
def clean_content(self): def clean_content(self):

View file

@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin 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.accounts.admin import SelectAccountAdminMixin, AccountAdminMixin
from orchestra.apps.domains.forms import DomainIterator from orchestra.apps.domains.forms import DomainIterator
@ -57,7 +57,7 @@ class MailboxAdmin(AccountAdminMixin, ExtendedModelAdmin):
def display_addresses(self, mailbox): def display_addresses(self, mailbox):
addresses = [] addresses = []
for addr in mailbox.addresses.all(): for addr in mailbox.addresses.all():
url = reverse('admin:mails_address_change', args=(addr.pk,)) url = change_url(addr)
addresses.append('<a href="%s">%s</a>' % (url, addr.email)) addresses.append('<a href="%s">%s</a>' % (url, addr.email))
return '<br>'.join(addresses) return '<br>'.join(addresses)
display_addresses.short_description = _("Addresses") display_addresses.short_description = _("Addresses")
@ -106,7 +106,7 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
def display_mailboxes(self, address): def display_mailboxes(self, address):
boxes = [] boxes = []
for mailbox in address.mailboxes.all(): for mailbox in address.mailboxes.all():
url = reverse('admin:mails_mailbox_change', args=(mailbox.pk,)) url = change_url(mailbox)
boxes.append('<a href="%s">%s</a>' % (url, mailbox.name)) boxes.append('<a href="%s">%s</a>' % (url, mailbox.name))
return '<br>'.join(boxes) return '<br>'.join(boxes)
display_mailboxes.short_description = _("Mailboxes") display_mailboxes.short_description = _("Mailboxes")

View file

@ -33,8 +33,8 @@ class BillsBackend(object):
# Create bill line # Create bill line
billine = bill.lines.create( billine = bill.lines.create(
rate=service.nominal_price, rate=service.nominal_price,
amount=line.size, quantity=line.size,
total=line.subtotal, subtotal=line.subtotal,
tax=service.tax, tax=service.tax,
description=self.get_line_description(line), description=self.get_line_description(line),
) )

View file

@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin.forms import AdminFormMixin 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 from .models import Order
@ -32,8 +32,8 @@ def selected_related_choices(queryset):
verbose = '<a href="{order_url}">{description}</a> ' verbose = '<a href="{order_url}">{description}</a> '
verbose += '<a class="account" href="{account_url}">{account}</a>' verbose += '<a class="account" href="{account_url}">{account}</a>'
verbose = verbose.format( verbose = verbose.format(
order_url=admin_change_url(order), description=order.description, order_url=change_url(order), description=order.description,
account_url=admin_change_url(order.account), account=str(order.account) account_url=change_url(order.account), account=str(order.account)
) )
yield (order.pk, mark_safe(verbose)) yield (order.pk, mark_safe(verbose))

View file

@ -1,6 +1,5 @@
import sys import sys
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.migrations.recorder import MigrationRecorder from django.db.migrations.recorder import MigrationRecorder
from django.db.models import F, Q 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.admin.models import LogEntry
from django.contrib.contenttypes import generic from django.contrib.contenttypes import generic
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _

View file

@ -13,7 +13,7 @@ from orchestra.apps.users.models import User
from orchestra.utils.tests import BaseTestCase, random_ascii from orchestra.utils.tests import BaseTestCase, random_ascii
class ServiceTests(BaseTestCase): class BillingTests(BaseTestCase):
DEPENDENCIES = ( DEPENDENCIES = (
'orchestra.apps.services', 'orchestra.apps.services',
'orchestra.apps.users', 'orchestra.apps.users',
@ -91,3 +91,18 @@ class ServiceTests(BaseTestCase):
error = decimal.Decimal(0.05) error = decimal.Decimal(0.05)
self.assertGreater(10*size+error*(10*size), bills[0].get_total()) self.assertGreater(10*size+error*(10*size), bills[0].get_total())
self.assertLess(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

View file

@ -1,9 +1,14 @@
from functools import partial
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.shortcuts import render 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 django.utils.translation import ugettext_lazy as _
from orchestra.admin.decorators import action_with_confirmation from orchestra.admin.decorators import action_with_confirmation
from orchestra.admin.utils import change_url
from .methods import PaymentMethod from .methods import PaymentMethod
from .models import Transaction from .models import Transaction
@ -38,8 +43,7 @@ def process_transactions(modeladmin, request, queryset):
@transaction.atomic @transaction.atomic
@action_with_confirmation() @action_with_confirmation()
def mark_as_executed(modeladmin, request, queryset): def mark_as_executed(modeladmin, request, queryset, extra_context={}):
""" Mark a tickets as unread """
for transaction in queryset: for transaction in queryset:
transaction.mark_as_executed() transaction.mark_as_executed()
modeladmin.log_change(request, transaction, 'Executed') modeladmin.log_change(request, transaction, 'Executed')
@ -52,7 +56,6 @@ mark_as_executed.verbose_name = _("Mark as executed")
@transaction.atomic @transaction.atomic
@action_with_confirmation() @action_with_confirmation()
def mark_as_secured(modeladmin, request, queryset): def mark_as_secured(modeladmin, request, queryset):
""" Mark a tickets as unread """
for transaction in queryset: for transaction in queryset:
transaction.mark_as_secured() transaction.mark_as_secured()
modeladmin.log_change(request, transaction, 'Secured') modeladmin.log_change(request, transaction, 'Secured')
@ -65,7 +68,6 @@ mark_as_secured.verbose_name = _("Mark as secured")
@transaction.atomic @transaction.atomic
@action_with_confirmation() @action_with_confirmation()
def mark_as_rejected(modeladmin, request, queryset): def mark_as_rejected(modeladmin, request, queryset):
""" Mark a tickets as unread """
for transaction in queryset: for transaction in queryset:
transaction.mark_as_rejected() transaction.mark_as_rejected()
modeladmin.log_change(request, transaction, 'Rejected') modeladmin.log_change(request, transaction, 'Rejected')
@ -73,3 +75,62 @@ def mark_as_rejected(modeladmin, request, queryset):
modeladmin.message_user(request, msg) modeladmin.message_user(request, msg)
mark_as_rejected.url_name = 'reject' mark_as_rejected.url_name = 'reject'
mark_as_rejected.verbose_name = _("Mark as rejected") 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}: <a href="{1}">{2}</a>'.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}: <a href="{1}">{2}</a> 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")

View file

@ -16,76 +16,13 @@ from .models import PaymentSource, Transaction, TransactionProcess
STATE_COLORS = { STATE_COLORS = {
Transaction.WAITTING_PROCESSING: 'darkorange', Transaction.WAITTING_PROCESSING: 'darkorange',
Transaction.WAITTING_CONFIRMATION: 'magenta', Transaction.WAITTING_EXECUTION: 'magenta',
Transaction.EXECUTED: 'olive', Transaction.EXECUTED: 'olive',
Transaction.SECURED: 'green', Transaction.SECURED: 'green',
Transaction.REJECTED: 'red', 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): class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ('label', 'method', 'number', 'account_link', 'is_active') list_display = ('label', 'method', 'number', 'account_link', 'is_active')
list_filter = ('method', 'is_active') list_filter = ('method', 'is_active')
@ -138,11 +75,74 @@ class PaymentSourceAdmin(AccountAdminMixin, admin.ModelAdmin):
obj.save() 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') list_display = ('id', 'file_url', 'display_transactions', 'created_at')
fields = ('data', 'file_url', 'display_transactions', 'created_at') fields = ('data', 'file_url', 'display_transactions', 'created_at')
readonly_fields = ('file_url', 'display_transactions', 'created_at') readonly_fields = ('file_url', 'display_transactions', 'created_at')
inlines = [TransactionInline] inlines = [TransactionInline]
actions = (actions.mark_process_as_executed, actions.abort, actions.commit)
change_view_actions = actions
def file_url(self, process): def file_url(self, process):
if process.file: if process.file:
@ -169,6 +169,18 @@ class TransactionProcessAdmin(admin.ModelAdmin):
return '<a href="%s">%s</a>' % (url, '<br>'.join(lines)) return '<a href="%s">%s</a>' % (url, '<br>'.join(lines))
display_transactions.short_description = _("Transactions") display_transactions.short_description = _("Transactions")
display_transactions.allow_tags = True 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) admin.site.register(PaymentSource, PaymentSourceAdmin)

View file

@ -59,27 +59,36 @@ class TransactionQuerySet(models.QuerySet):
source = kwargs.get('source') source = kwargs.get('source')
if source is None or not hasattr(source.method_class, 'process'): if source is None or not hasattr(source.method_class, 'process'):
# Manual payments don't need processing # Manual payments don't need processing
kwargs['state']=self.model.WAITTING_CONFIRMATION kwargs['state']=self.model.WAITTING_EXECUTION
return super(TransactionQuerySet, self).create(**kwargs) 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): class Transaction(models.Model):
WAITTING_PROCESSING = 'WAITTING_PROCESSING' # CREATED WAITTING_PROCESSING = 'WAITTING_PROCESSING' # CREATED
WAITTING_CONFIRMATION = 'WAITTING_CONFIRMATION' # PROCESSED WAITTING_EXECUTION = 'WAITTING_EXECUTION' # PROCESSED
EXECUTED = 'EXECUTED' EXECUTED = 'EXECUTED'
SECURED = 'SECURED' SECURED = 'SECURED'
REJECTED = 'REJECTED' REJECTED = 'REJECTED'
STATES = ( STATES = (
(WAITTING_PROCESSING, _("Waitting processing")), (WAITTING_PROCESSING, _("Waitting processing")),
(WAITTING_CONFIRMATION, _("Waitting confirmation")), (WAITTING_EXECUTION, _("Waitting execution")),
(EXECUTED, _("Executed")), (EXECUTED, _("Executed")),
(SECURED, _("Secured")), (SECURED, _("Secured")),
(REJECTED, _("Rejected")), (REJECTED, _("Rejected")),
) )
objects = TransactionQuerySet.as_manager()
bill = models.ForeignKey('bills.bill', verbose_name=_("bill"), bill = models.ForeignKey('bills.bill', verbose_name=_("bill"),
related_name='transactions') related_name='transactions')
source = models.ForeignKey(PaymentSource, null=True, blank=True, source = models.ForeignKey(PaymentSource, null=True, blank=True,
@ -93,6 +102,8 @@ class Transaction(models.Model):
created_on = models.DateTimeField(auto_now_add=True) created_on = models.DateTimeField(auto_now_add=True)
modified_on = models.DateTimeField(auto_now=True) modified_on = models.DateTimeField(auto_now=True)
objects = TransactionQuerySet.as_manager()
def __unicode__(self): def __unicode__(self):
return "Transaction {}".format(self.id) return "Transaction {}".format(self.id)
@ -100,19 +111,26 @@ class Transaction(models.Model):
def account(self): def account(self):
return self.bill.account 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): def mark_as_executed(self):
self.state = self.EXECUTED self.state = self.EXECUTED
self.save() self.save()
def mark_as_secured(self): def mark_as_secured(self):
self.state = self.SECURED self.state = self.SECURED
# TODO think carefully about bill feedback
self.bill.mark_as_paid()
self.save() self.save()
def mark_as_rejected(self): def mark_as_rejected(self):
self.state = self.REJECTED self.state = self.REJECTED
# TODO bill feedback
self.save() self.save()
@ -120,15 +138,49 @@ class TransactionProcess(models.Model):
""" """
Stores arbitrary data generated by payment methods while processing transactions 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) data = JSONField(_("data"), blank=True)
file = models.FileField(_("file"), 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: class Meta:
verbose_name_plural = _("Transaction processes") verbose_name_plural = _("Transaction processes")
def __unicode__(self): def __unicode__(self):
return str(self.id) 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) accounts.register(PaymentSource)

View file

@ -164,7 +164,7 @@ class ServiceHandler(plugins.Plugin):
for order in givers: for order in givers:
if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: 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) interval = helpers.Interval(order.cancelled_on, order.billed_until, order)
compensations.append[interval] compensations.append(interval)
for order in receivers: for order in receivers:
if not order.billed_until or order.billed_until < order.new_billed_until: if not order.billed_until or order.billed_until < order.new_billed_until:
# receiver # receiver
@ -277,9 +277,10 @@ class ServiceHandler(plugins.Plugin):
ini = min(ini, cini) ini = min(ini, cini)
end = max(end, bp) end = max(end, bp)
related_orders = account.orders.filter(service=self.service) 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 # 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) givers.sort(cmp=helpers.cmp_billed_until_or_registered_on)
orders.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) self.compensate(givers, orders, commit=commit)
@ -341,6 +342,7 @@ class ServiceHandler(plugins.Plugin):
return lines return lines
def generate_bill_lines(self, orders, account, **options): def generate_bill_lines(self, orders, account, **options):
# TODO filter out orders with cancelled_on < billed_until ?
if not self.metric: if not self.metric:
lines = self.bill_with_orders(orders, account, **options) lines = self.bill_with_orders(orders, account, **options)
else: else:

View file

@ -1,6 +1,5 @@
import sys import sys
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
from django.db.models.signals import pre_delete, post_delete, post_save from django.db.models.signals import pre_delete, post_delete, post_save
@ -90,7 +89,6 @@ class Service(models.Model):
NOTHING = 'NOTHING' NOTHING = 'NOTHING'
DISCOUNT = 'DISCOUNT' DISCOUNT = 'DISCOUNT'
REFOUND = 'REFOUND' REFOUND = 'REFOUND'
COMPENSATE = 'COMPENSATE'
PREPAY = 'PREPAY' PREPAY = 'PREPAY'
POSTPAY = 'POSTPAY' POSTPAY = 'POSTPAY'
STEP_PRICE = 'STEP_PRICE' STEP_PRICE = 'STEP_PRICE'
@ -174,7 +172,6 @@ class Service(models.Model):
choices=( choices=(
(NOTHING, _("Nothing")), (NOTHING, _("Nothing")),
(DISCOUNT, _("Discount")), (DISCOUNT, _("Discount")),
(COMPENSATE, _("Discount and compensate")),
), ),
default=DISCOUNT) default=DISCOUNT)
payment_style = models.CharField(_("payment style"), max_length=16, payment_style = models.CharField(_("payment style"), max_length=16,
@ -229,11 +226,10 @@ class Service(models.Model):
def clean(self): def clean(self):
content_type = self.handler.get_content_type() content_type = self.handler.get_content_type()
if self.content_type != content_type: if self.content_type != content_type:
msg =_("Content type must be equal to '%s'.") % str(content_type) ct = str(content_type)
raise ValidationError(msg) raise ValidationError(_("Content type must be equal to '%s'.") % ct)
if not self.match: if not self.match:
msg =_("Match should be provided") raise ValidationError(_("Match should be provided"))
raise ValidationError(msg)
try: try:
obj = content_type.model_class().objects.all()[0] obj = content_type.model_class().objects.all()[0]
except IndexError: except IndexError:

View file

@ -136,7 +136,7 @@ class HandlerTests(BaseTestCase):
self.assertIn([order4.billed_until, end, [order2, order3]], chunks) self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
def test_sort_billed_until_or_registered_on(self): def test_sort_billed_until_or_registered_on(self):
now = timezone.now() now = timezone.now().date()
order = Order( order = Order(
billed_until=now+datetime.timedelta(days=200)) billed_until=now+datetime.timedelta(days=200))
order1 = Order( order1 = Order(
@ -158,7 +158,7 @@ class HandlerTests(BaseTestCase):
self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on)) self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on))
def test_compensation(self): def test_compensation(self):
now = timezone.now() now = timezone.now().date()
order = Order( order = Order(
description='0', description='0',
billed_until=now+datetime.timedelta(days=220), billed_until=now+datetime.timedelta(days=220),
@ -353,5 +353,16 @@ class HandlerTests(BaseTestCase):
self.assertEqual(rate['price'], result.price) self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity) self.assertEqual(rate['quantity'], result.quantity)
def test_compensations(self): def test_generate_bill_lines_with_compensation(self):
pass 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)

View file

@ -10,7 +10,7 @@ from django.utils.encoding import force_text
from django.utils.html import escape from django.utils.html import escape
from django.utils.translation import ugettext, ugettext_lazy as _ 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 .forms import role_form_factory
from ..models import User from ..models import User
@ -71,9 +71,8 @@ class RoleAdmin(object):
modeladmin.log_change(request, request.user, "%s saved" % self.name.capitalize()) 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 msg = _('The role %(name)s for user "%(obj)s" was %(action)s successfully.') % context
modeladmin.message_user(request, msg, messages.SUCCESS) modeladmin.message_user(request, msg, messages.SUCCESS)
url = 'admin:%s_%s_change' % (opts.app_label, opts.module_name)
if not "_continue" in request.POST: if not "_continue" in request.POST:
return redirect(url, object_id) return redirect(change_url(user))
exists = True exists = True
if exists: if exists:
@ -117,7 +116,7 @@ class RoleAdmin(object):
obj_display = force_text(obj) obj_display = force_text(obj)
modeladmin.log_deletion(request, obj, obj_display) modeladmin.log_deletion(request, obj, obj_display)
modeladmin.delete_model(request, obj) 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) preserved_filters = modeladmin.get_preserved_filters(request)
post_url = add_preserved_filters( post_url = add_preserved_filters(
{'preserved_filters': preserved_filters, 'opts': opts}, post_url {'preserved_filters': preserved_filters, 'opts': opts}, post_url

View file

@ -4,6 +4,7 @@ from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import change_url
from orchestra.apps.accounts.admin import SelectAccountAdminMixin from orchestra.apps.accounts.admin import SelectAccountAdminMixin
from .models import WebApp, WebAppOption from .models import WebApp, WebAppOption
@ -37,7 +38,7 @@ class WebAppAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
websites = [] websites = []
for content in webapp.content_set.all().select_related('website'): for content in webapp.content_set.all().select_related('website'):
website = content.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) name = "%s on %s" % (website.name, content.path)
websites.append('<a href="%s">%s</a>' % (url, name)) websites.append('<a href="%s">%s</a>' % (url, name))
return '<br>'.join(websites) return '<br>'.join(websites)

View file

@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin 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.admin import AccountAdminMixin, SelectAccountAdminMixin
from orchestra.apps.accounts.widgets import account_related_field_widget_factory from orchestra.apps.accounts.widgets import account_related_field_widget_factory
@ -72,7 +72,7 @@ class WebsiteAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
webapps = [] webapps = []
for content in website.content_set.all().select_related('webapp'): for content in website.content_set.all().select_related('webapp'):
webapp = content.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) name = "%s on %s" % (webapp.get_type_display(), content.path)
webapps.append('<a href="%s">%s</a>' % (url, name)) webapps.append('<a href="%s">%s</a>' % (url, name))
return '<br>'.join(webapps) return '<br>'.join(webapps)

View file

@ -97,6 +97,7 @@ INSTALLED_APPS = (
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'passlib.ext.django', 'passlib.ext.django',
'django_nose',
# Django.contrib # Django.contrib
'django.contrib.auth', 'django.contrib.auth',
@ -248,3 +249,6 @@ PASSLIB_CONFIG = (
"superuser__django_pbkdf2_sha256__default_rounds = 15000\n" "superuser__django_pbkdf2_sha256__default_rounds = 15000\n"
"superuser__sha512_crypt__default_rounds = 120000\n" "superuser__sha512_crypt__default_rounds = 120000\n"
) )
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'

View file

@ -27,11 +27,7 @@
<div> <div>
<div style="margin:20px;"> <div style="margin:20px;">
<p>{{ content_message | safe }}</p> <p>{{ content_message | safe }}</p>
<ul> <ul>{{ display_objects | unordered_list }}</ul>
{% for display_object in display_objects %}
<li> <a href="{% url opts|admin_urlname:'change' display_object.pk %}">{{ display_object }} </a></li>
{% endfor %}
</ul>
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
{% if form %} {% if form %}
<fieldset class="module aligned"> <fieldset class="module aligned">
@ -53,7 +49,6 @@
{% if formset %} {% if formset %}
{{ formset.as_admin }} {{ formset.as_admin }}
{% endif %} {% endif %}
<div> <div>
{% for obj in queryset %} {% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" /> <input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />

View file

@ -3,7 +3,7 @@ from django.core.urlresolvers import reverse, NoReverseMatch
from django.forms import CheckboxInput from django.forms import CheckboxInput
from orchestra import get_version from orchestra import get_version
from orchestra.admin.utils import admin_change_url from orchestra.admin.utils import change_url
register = template.Library() register = template.Library()
@ -49,4 +49,4 @@ def is_checkbox(field):
@register.filter @register.filter
def admin_link(obj): def admin_link(obj):
return admin_change_url(obj) return change_url(obj)