Random improvements

This commit is contained in:
Marc 2014-09-10 16:53:09 +00:00
parent 4e3105194c
commit 287f03ce19
41 changed files with 660 additions and 179 deletions

14
TODO.md
View file

@ -87,3 +87,17 @@ at + clock time, midnight, noon- At 3:30 p.m., At 4:01, At noon
* help_text on readonly_fields specialy Bill.state. (eg. A bill is in OPEN state when bla bla ) * help_text on readonly_fields specialy Bill.state. (eg. A bill is in OPEN state when bla bla )
* Create ProForma from orders orders.bill(proforma=True) * Create ProForma from orders orders.bill(proforma=True)
* generic confirmation breadcrumbs for single objects
* DirectDebit due date = bill.due_date
* settings.ENABLED_PLUGINS = ('path.module.ClassPlugin',)
* Transaction states: CREATED, PROCESSED, EXECUTED, COMMITED, ABORTED (SECURED, REJECTED?)
* bill.send() -> transacction.EXECUTED when source=None
* transaction.secured() -> bill.paid when bill.total == transaction.value else Error
* bill.paid() -> transacton.SECURED
* bill.bad_debt() -> transaction.ABORTED
* transaction.ABORTED -> bill.bad_debt
- Issue new transaction when current transaction is ABORTED

View file

@ -62,7 +62,7 @@ class ChangeViewActionsMixin(object):
action.url_name))) action.url_name)))
return new_urls + urls return new_urls + urls
def get_change_view_actions(self, obj=None): def get_change_view_actions(self):
views = [] views = []
for action in self.change_view_actions: for action in self.change_view_actions:
if isinstance(action, basestring): if isinstance(action, basestring):
@ -77,11 +77,10 @@ class ChangeViewActionsMixin(object):
return views return views
def change_view(self, request, object_id, **kwargs): def change_view(self, request, object_id, **kwargs):
obj = self.get_object(request, unquote(object_id))
if not 'extra_context' in kwargs: if not 'extra_context' in kwargs:
kwargs['extra_context'] = {} kwargs['extra_context'] = {}
kwargs['extra_context']['object_tools_items'] = [ kwargs['extra_context']['object_tools_items'] = [
action.__dict__ for action in self.get_change_view_actions(obj) action.__dict__ for action in self.get_change_view_actions()
] ]
return super(ChangeViewActionsMixin, self).change_view(request, object_id, **kwargs) return super(ChangeViewActionsMixin, self).change_view(request, object_id, **kwargs)

View file

@ -90,6 +90,12 @@ def action_to_view(action, modeladmin):
return action_view return action_view
def admin_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,))
@admin_field @admin_field
def admin_link(*args, **kwargs): def admin_link(*args, **kwargs):
instance = args[-1] instance = args[-1]
@ -99,9 +105,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 '---'
opts = obj._meta url = admin_change_url(obj)
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
url = reverse(view_name, args=(obj.pk,))
extra = '' extra = ''
if kwargs['popup']: if kwargs['popup']:
extra = 'onclick="return showAddAnotherPopup(this);"' extra = 'onclick="return showAddAnotherPopup(this);"'
@ -130,3 +134,12 @@ def admin_date(*args, **kwargs):
return '<span title="{0}">{1}</span>'.format( return '<span title="{0}">{1}</span>'.format(
escape(str(value)), escape(naturaldate(value)), escape(str(value)), escape(naturaldate(value)),
) )
def get_object_from_url(modeladmin, request):
try:
object_id = int(request.path.split('/')[-3])
except ValueError:
return None
else:
return modeladmin.model.objects.get(pk=object_id)

View file

@ -87,7 +87,6 @@ class AccountAdmin(ExtendedModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
""" Select related for performance """ """ Select related for performance """
# TODO move invoicecontact to contacts
qs = super(AccountAdmin, self).get_queryset(request) qs = super(AccountAdmin, self).get_queryset(request)
related = ('user', 'invoicecontact') related = ('user', 'invoicecontact')
return qs.select_related(*related) return qs.select_related(*related)

View file

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('type', models.CharField(default=b'INDIVIDUAL', max_length=32, verbose_name='type', choices=[(b'INDIVIDUAL', 'Individual'), (b'ASSOCIATION', 'Association'), (b'CUSTOMER', 'Customer'), (b'COMPANY', 'Company'), (b'PUBLICBODY', 'Public body')])),
('language', models.CharField(default=b'en', max_length=2, verbose_name='language', choices=[(b'en', 'English')])),
('register_date', models.DateTimeField(auto_now_add=True, verbose_name='register date')),
('comments', models.TextField(max_length=256, verbose_name='comments', blank=True)),
('is_active', models.BooleanField(default=True)),
('user', models.OneToOneField(related_name=b'accounts', verbose_name='user', to=settings.AUTH_USER_MODEL)),
],
options={
},
bases=(models.Model,),
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='account',
name='user',
field=models.OneToOneField(related_name=b'accounts', null=True, verbose_name='user', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -11,7 +11,7 @@ from . import settings
class Account(models.Model): class Account(models.Model):
user = models.OneToOneField(djsettings.AUTH_USER_MODEL, user = models.OneToOneField(djsettings.AUTH_USER_MODEL,
verbose_name=_("user"), related_name='accounts') verbose_name=_("user"), related_name='accounts', null=True)
type = models.CharField(_("type"), choices=settings.ACCOUNTS_TYPES, type = models.CharField(_("type"), choices=settings.ACCOUNTS_TYPES,
max_length=32, default=settings.ACCOUNTS_DEFAULT_TYPE) max_length=32, default=settings.ACCOUNTS_DEFAULT_TYPE)
language = models.CharField(_("language"), max_length=2, language = models.CharField(_("language"), max_length=2,

View file

@ -8,6 +8,7 @@ from django.shortcuts import render
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin.forms import adminmodelformset_factory from orchestra.admin.forms import adminmodelformset_factory
from orchestra.admin.utils import get_object_from_url
from orchestra.utils.html import html_to_pdf from orchestra.utils.html import html_to_pdf
from .forms import SelectSourceForm from .forms import SelectSourceForm
@ -69,6 +70,7 @@ def close_bills(modeladmin, request, queryset):
'app_label': opts.app_label, 'app_label': opts.app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'formset': formset, 'formset': formset,
'obj': get_object_from_url(modeladmin, request),
} }
return render(request, 'admin/orchestra/generic_confirmation.html', context) return render(request, 'admin/orchestra/generic_confirmation.html', context)
close_bills.verbose_name = _("Close") close_bills.verbose_name = _("Close")

View file

@ -19,16 +19,8 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
class BillLineInline(admin.TabularInline): class BillLineInline(admin.TabularInline):
model = BillLine model = BillLine
fields = ('description', 'rate', 'amount', 'tax', 'total', 'subtotal') fields = ('description', 'rate', 'amount', 'tax', 'total', 'get_total')
readonly_fields = ('subtotal',) readonly_fields = ('get_total',)
def subtotal(self, line):
if line.total:
subtotal = 0
for subline in line.sublines.all():
subtotal += subline.total
return line.total - subtotal
return ''
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 obj.status != Bill.OPEN:
@ -44,9 +36,17 @@ class BillLineInline(admin.TabularInline):
if obj and obj.status != Bill.OPEN: if obj and obj.status != Bill.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)
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'description':
kwargs['widget'] = forms.TextInput(attrs={'size':'110'})
else:
kwargs['widget'] = forms.TextInput(attrs={'size':'13'})
return super(BillLineInline, self).formfield_for_dbfield(db_field, **kwargs)
class BudgetLineInline(admin.TabularInline): class BudgetLineInline(BillLineInline):
model = Budget model = Budget
fields = ('description', 'rate', 'amount', 'tax', 'total') fields = ('description', 'rate', 'amount', 'tax', 'total')
@ -108,7 +108,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
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(obj) actions = super(BillAdmin, self).get_change_view_actions()
discard = [] discard = []
if obj: if obj:
if obj.status != Bill.OPEN: if obj.status != Bill.OPEN:

View file

@ -57,7 +57,7 @@ class Bill(models.Model):
closed_on = models.DateTimeField(_("closed on"), blank=True, null=True) closed_on = models.DateTimeField(_("closed on"), blank=True, null=True)
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) total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
comments = models.TextField(_("comments"), blank=True) comments = models.TextField(_("comments"), blank=True)
html = models.TextField(_("HTML"), blank=True) html = models.TextField(_("HTML"), blank=True)
@ -170,24 +170,18 @@ 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 self.status == self.OPEN:
self.total = self.get_total()
if not self.number or (self.number.startswith('O') and self.status != self.OPEN): if not self.number or (self.number.startswith('O') and self.status != self.OPEN):
self.set_number() self.set_number()
super(Bill, self).save(*args, **kwargs) super(Bill, self).save(*args, **kwargs)
@cached
def get_subtotals(self): def get_subtotals(self):
subtotals = {} subtotals = {}
for line in self.lines.all(): for line in self.lines.all():
subtotal, taxes = subtotals.get(line.tax, (0, 0)) subtotal, taxes = subtotals.get(line.tax, (0, 0))
subtotal += line.total subtotal += line.get_total()
for subline in line.sublines.all():
subtotal += subline.total
subtotals[line.tax] = (subtotal, (line.tax/100)*subtotal) subtotals[line.tax] = (subtotal, (line.tax/100)*subtotal)
return subtotals return subtotals
@cached
def get_total(self): def get_total(self):
total = 0 total = 0
for tax, subtotal in self.get_subtotals().iteritems(): for tax, subtotal in self.get_subtotals().iteritems():
@ -246,7 +240,7 @@ class BaseBillLine(models.Model):
def number(self): def number(self):
lines = type(self).objects.filter(bill=self.bill_id) lines = type(self).objects.filter(bill=self.bill_id)
return lines.filter(id__lte=self.id).order_by('id').count() return lines.filter(id__lte=self.id).order_by('id').count()
class BudgetLine(BaseBillLine): class BudgetLine(BaseBillLine):
pass pass
@ -259,6 +253,20 @@ class BillLine(BaseBillLine):
auto = models.BooleanField(default=False) auto = models.BooleanField(default=False)
amended_line = models.ForeignKey('self', verbose_name=_("amended line"), amended_line = models.ForeignKey('self', verbose_name=_("amended line"),
related_name='amendment_lines', null=True, blank=True) related_name='amendment_lines', null=True, blank=True)
def get_total(self):
""" Computes subline discounts """
subtotal = self.total
for subline in self.sublines.all():
subtotal += subline.total
return subtotal
def save(self, *args, **kwargs):
# TODO cost of this shit
super(BillLine, self).save(*args, **kwargs)
if self.bill.status == self.bill.OPEN:
self.bill.total = self.bill.get_total()
self.bill.save()
class BillSubline(models.Model): class BillSubline(models.Model):
@ -268,6 +276,12 @@ class BillSubline(models.Model):
description = models.CharField(_("description"), max_length=256) description = models.CharField(_("description"), max_length=256)
total = models.DecimalField(max_digits=12, decimal_places=2) total = models.DecimalField(max_digits=12, decimal_places=2)
# TODO type ? Volume and Compensation # TODO type ? Volume and Compensation
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:
self.line.bill.total = self.line.bill.get_total()
self.line.bill.save()
accounts.register(Bill) accounts.register(Bill)

View file

@ -3,7 +3,7 @@ from django.utils.translation import ugettext_lazy as _
CONTACTS_DEFAULT_EMAIL_USAGES = getattr(settings, 'CONTACTS_DEFAULT_EMAIL_USAGES', CONTACTS_DEFAULT_EMAIL_USAGES = getattr(settings, 'CONTACTS_DEFAULT_EMAIL_USAGES',
('SUPPORT', 'ADMIN', 'BILL', 'TECH', 'ADDS', 'EMERGENCY') ('SUPPORT', 'ADMIN', 'BILLING', 'TECH', 'ADDS', 'EMERGENCY')
) )

View file

@ -8,8 +8,8 @@ from orchestra.apps.orchestration.models import Server, Route
from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii from orchestra.utils.tests import BaseLiveServerTestCase, random_ascii
from orchestra.utils.system import run from orchestra.utils.system import run
from orchestra.apps.domains import settings, utils, backends from ... import settings, utils, backends
from orchestra.apps.domains.models import Domain, Record from ...models import Domain, Record
run = functools.partial(run, display=False) run = functools.partial(run, display=False)

View file

@ -69,6 +69,10 @@ class BackendOperationInline(admin.TabularInline):
def has_add_permission(self, *args, **kwargs): def has_add_permission(self, *args, **kwargs):
return False return False
def get_queryset(self, request):
queryset = super(BackendOperationInline, self).get_queryset(request)
return queryset.prefetch_related('instance')
def display_mono(field): def display_mono(field):
@ -106,7 +110,7 @@ class BackendLogAdmin(admin.ModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
""" Order by structured name and imporve performance """ """ Order by structured name and imporve performance """
qs = super(BackendLogAdmin, self).get_queryset(request) qs = super(BackendLogAdmin, self).get_queryset(request)
return qs.select_related('server') return qs.select_related('server').defer('script', 'stdout')
class ServerAdmin(admin.ModelAdmin): class ServerAdmin(admin.ModelAdmin):

View file

@ -13,7 +13,7 @@ from . import settings
def BashSSH(backend, log, server, cmds): def BashSSH(backend, log, server, cmds):
from .models import BackendLog from .models import BackendLog
script = '\n\n'.join(['set -e', 'set -o pipefail'] + cmds + ['exit 0']) script = '\n'.join(['set -e', 'set -o pipefail'] + cmds + ['exit 0'])
script = script.replace('\r', '') script = script.replace('\r', '')
log.script = script log.script = script
log.save() log.save()

View file

@ -1,4 +1,5 @@
from django.contrib.contenttypes import generic from django.contrib.contenttypes import generic
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -70,6 +71,9 @@ class BackendLog(models.Model):
@property @property
def execution_time(self): def execution_time(self):
return (self.last_update-self.created).total_seconds() return (self.last_update-self.created).total_seconds()
def backend_class(self):
return ServiceBackend.get_backend(self.backend)
class BackendOperation(models.Model): class BackendOperation(models.Model):
@ -85,6 +89,7 @@ class BackendOperation(models.Model):
action = models.CharField(_("action"), max_length=64) action = models.CharField(_("action"), max_length=64)
content_type = models.ForeignKey(ContentType) content_type = models.ForeignKey(ContentType)
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
# TODO rename to content_object
instance = generic.GenericForeignKey('content_type', 'object_id') instance = generic.GenericForeignKey('content_type', 'object_id')
class Meta: class Meta:

View file

@ -2,6 +2,7 @@ from django.contrib import admin, messages
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext
from django.shortcuts import render from django.shortcuts import render
from .forms import (BillSelectedOptionsForm, BillSelectConfirmationForm, from .forms import (BillSelectedOptionsForm, BillSelectConfirmationForm,
@ -41,15 +42,16 @@ class BillSelectedOrders(object):
return self.select_related(request) return self.select_related(request)
self.context.update({ self.context.update({
'title': _("Options for billing selected orders, step 1 / 3"), 'title': _("Options for billing selected orders, step 1 / 3"),
'step': 'one', 'step': 1,
'form': form, 'form': form,
}) })
return render(request, self.template, self.context) return render(request, self.template, self.context)
def select_related(self, request): def select_related(self, request):
self.options['related_queryset'] = self.queryset.all() #get_related(**options) related = self.queryset.get_related().select_related('account__user', 'service')
self.options['related_queryset'] = related
form = BillSelectRelatedForm(initial=self.options) form = BillSelectRelatedForm(initial=self.options)
if request.POST.get('step') == 'two': if int(request.POST.get('step')) >= 2:
form = BillSelectRelatedForm(request.POST, initial=self.options) form = BillSelectRelatedForm(request.POST, initial=self.options)
if form.is_valid(): if form.is_valid():
select_related = form.cleaned_data['selected_related'] select_related = form.cleaned_data['selected_related']
@ -57,14 +59,14 @@ class BillSelectedOrders(object):
return self.confirmation(request) return self.confirmation(request)
self.context.update({ self.context.update({
'title': _("Select related order for billing, step 2 / 3"), 'title': _("Select related order for billing, step 2 / 3"),
'step': 'two', 'step': 2,
'form': form, 'form': form,
}) })
return render(request, self.template, self.context) return render(request, self.template, self.context)
def confirmation(self, request): def confirmation(self, request):
form = BillSelectConfirmationForm(initial=self.options) form = BillSelectConfirmationForm(initial=self.options)
if request.POST: if int(request.POST.get('step')) >= 3:
bills = self.queryset.bill(commit=True, **self.options) bills = self.queryset.bill(commit=True, **self.options)
if not bills: if not bills:
msg = _("Selected orders do not have pending billing") msg = _("Selected orders do not have pending billing")
@ -72,19 +74,21 @@ class BillSelectedOrders(object):
else: else:
ids = ','.join([str(bill.id) for bill in bills]) ids = ','.join([str(bill.id) for bill in bills])
url = reverse('admin:bills_bill_changelist') url = reverse('admin:bills_bill_changelist')
context = { url += '?id__in=%s' % ids
'url': url + '?id=%s' % ids, num = len(bills)
'num': len(bills), msg = ungettext(
'bills': _("bills"), '<a href="{url}">One bill</a> has been created.',
'msg': _("have been generated"), '<a href="{url}">{num} bills</a> have been created.',
} num).format(url=url, num=num)
msg = '<a href="%(url)s">%(num)s %(bills)s</a> %(msg)s' % context
msg = mark_safe(msg) msg = mark_safe(msg)
self.modeladmin.message_user(request, msg, messages.INFO) self.modeladmin.message_user(request, msg, messages.INFO)
return return
bills = self.queryset.bill(commit=False, **self.options)
self.context.update({ self.context.update({
'title': _("Confirmation for billing selected orders"), 'title': _("Confirmation for billing selected orders"),
'step': 'three', 'step': 3,
'form': form, 'form': form,
'bills': bills,
'selected_related_objects': self.options['selected_related']
}) })
return render(request, self.template, self.context) return render(request, self.template, self.context)

View file

@ -9,38 +9,39 @@ class BillsBackend(object):
def create_bills(self, account, lines): def create_bills(self, account, lines):
invoice = None invoice = None
bills = [] bills = []
for order, nominal_price, size, ini, end, discounts in lines: for line in lines:
service = order.service service = line.order.service
if service.is_fee: if service.is_fee:
fee, __ = Fee.objects.get_or_create(account=account, status=Fee.OPEN) fee, __ = Fee.objects.get_or_create(account=account, status=Fee.OPEN)
line = fee.lines.create( storedline = fee.lines.create(
rate=service.nominal_price, rate=service.nominal_price,
amount=size, amount=line.size,
total=nominal_price, tax=0, total=line.subtotal, tax=0,
description=self.format_period(ini, end), description=self.format_period(line.ini, line.end),
) )
self.create_sublines(line, discounts) self.create_sublines(storedline, line.discounts)
bills.append(fee) bills.append(fee)
else: else:
if invoice is None: if invoice is None:
invoice, __ = Invoice.objects.get_or_create(account=account, invoice, __ = Invoice.objects.get_or_create(account=account,
status=Invoice.OPEN) status=Invoice.OPEN)
bills.append(invoice) bills.append(invoice)
description = order.description description = line.order.description
if service.billing_period != service.NEVER: if service.billing_period != service.NEVER:
description += " %s" % self.format_period(ini, end) description += " %s" % self.format_period(line.ini, line.end)
line = invoice.lines.create( storedline = invoice.lines.create(
description=description, description=description,
rate=service.nominal_price, rate=service.nominal_price,
amount=size, amount=line.size,
total=nominal_price, # TODO rename line.total > subtotal
total=line.subtotal,
tax=service.tax, tax=service.tax,
) )
self.create_sublines(line, discounts) self.create_sublines(storedline, line.discounts)
return bills return bills
def format_period(self, ini, end): def format_period(self, ini, end):
ini = ini=ini.strftime("%b, %Y") ini = ini.strftime("%b, %Y")
end = (end-datetime.timedelta(seconds=1)).strftime("%b, %Y") end = (end-datetime.timedelta(seconds=1)).strftime("%b, %Y")
if ini == end: if ini == end:
return ini return ini
@ -48,8 +49,8 @@ class BillsBackend(object):
def create_sublines(self, line, discounts): def create_sublines(self, line, discounts):
for name, value in discounts: for discount in discounts:
line.sublines.create( line.sublines.create(
description=_("Discount per %s") % name, description=_("Discount per %s") % discount.type,
total=value, total=discount.total,
) )

View file

@ -1,9 +1,11 @@
from django import forms from django import forms
from django.contrib.admin import widgets from django.contrib.admin import widgets
from django.utils import timezone from django.utils import timezone
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 .models import Order from .models import Order
@ -21,9 +23,20 @@ class BillSelectedOptionsForm(AdminFormMixin, forms.Form):
help_text=_("Deisgnates whether you want to put this orders on a new " help_text=_("Deisgnates whether you want to put this orders on a new "
"open bill, or allow to reuse an existing one.")) "open bill, or allow to reuse an existing one."))
def selected_related_choices(queryset):
for order in queryset:
verbose = '<a href="{order_url}">{description}</a> '
verbose += '<a class="account" href="{account_url}">{account}</a>'
verbose = verbose.format(
order_url=admin_change_url(order), description=order.description,
account_url=admin_change_url(order.account), account=str(order.account)
)
yield (order.pk, mark_safe(verbose))
class BillSelectRelatedForm(AdminFormMixin, forms.Form): class BillSelectRelatedForm(AdminFormMixin, forms.Form):
selected_related = forms.ModelMultipleChoiceField(queryset=Order.objects.none(), selected_related = forms.ModelMultipleChoiceField(label=_("Related"),
queryset=Order.objects.none(), widget=forms.CheckboxSelectMultiple,
required=False) required=False)
billing_point = forms.DateField(widget=forms.HiddenInput()) billing_point = forms.DateField(widget=forms.HiddenInput())
fixed_point = forms.BooleanField(widget=forms.HiddenInput(), required=False) fixed_point = forms.BooleanField(widget=forms.HiddenInput(), required=False)
@ -34,11 +47,12 @@ class BillSelectRelatedForm(AdminFormMixin, forms.Form):
queryset = kwargs['initial'].get('related_queryset', None) queryset = kwargs['initial'].get('related_queryset', None)
if queryset: if queryset:
self.fields['selected_related'].queryset = queryset self.fields['selected_related'].queryset = queryset
self.fields['selected_related'].choices = selected_related_choices(queryset)
class BillSelectConfirmationForm(forms.Form): class BillSelectConfirmationForm(AdminFormMixin, forms.Form):
selected_related = forms.ModelMultipleChoiceField(queryset=Order.objects.none(), # selected_related = forms.ModelMultipleChoiceField(queryset=Order.objects.none(),
widget=forms.HiddenInput(), required=False) # widget=forms.HiddenInput(), required=False)
billing_point = forms.DateField(widget=forms.HiddenInput()) billing_point = forms.DateField(widget=forms.HiddenInput())
fixed_point = forms.BooleanField(widget=forms.HiddenInput(), required=False) fixed_point = forms.BooleanField(widget=forms.HiddenInput(), required=False)
create_new_open = forms.BooleanField(widget=forms.HiddenInput(), required=False) create_new_open = forms.BooleanField(widget=forms.HiddenInput(), required=False)

View file

@ -8,6 +8,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.utils import plugins from orchestra.utils import plugins
from orchestra.utils.python import AttributeDict
from . import settings from . import settings
from .helpers import get_register_or_cancel_events, get_register_or_renew_events from .helpers import get_register_or_cancel_events, get_register_or_renew_events
@ -70,8 +71,8 @@ class ServiceHandler(plugins.Plugin):
day = 1 day = 1
else: else:
raise NotImplementedError(msg) raise NotImplementedError(msg)
bp = datetime.datetime(year=date.year, month=date.month, bp = datetime.datetime(year=date.year, month=date.month, day=day,
day=day, tzinfo=timezone.get_current_timezone()) tzinfo=timezone.get_current_timezone())
elif self.billing_period == self.ANUAL: elif self.billing_period == self.ANUAL:
if self.billing_point == self.ON_REGISTER: if self.billing_point == self.ON_REGISTER:
month = order.registered_on.month month = order.registered_on.month
@ -151,17 +152,26 @@ class ServiceHandler(plugins.Plugin):
price = self.get_price(order, metric) * size price = self.get_price(order, metric) * size
return price return price
def create_line(self, order, price, size, ini, end): def generate_line(self, order, price, size, ini, end):
nominal_price = self.nominal_price * size subtotal = float(self.nominal_price) * size
discounts = [] discounts = []
if nominal_price > price: if subtotal > price:
discounts.append(('volume', nominal_price-price)) discounts.append(AttributeDict(**{
# TODO Uncomment when prices are done 'type': 'volume',
# elif nominal_price < price: 'total': price-subtotal
# raise ValueError("Something is wrong!") }))
return (order, nominal_price, size, ini, end, discounts) elif subtotal < price:
raise ValueError("Something is wrong!")
return AttributeDict(**{
'order': order,
'subtotal': subtotal,
'size': size,
'ini': ini,
'end': end,
'discounts': discounts,
})
def create_bill_lines(self, orders, **options): def generate_bill_lines(self, orders, **options):
# For the "boundary conditions" just think that: # For the "boundary conditions" just think that:
# date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0)
# In most cases: # In most cases:
@ -175,6 +185,7 @@ class ServiceHandler(plugins.Plugin):
# TODO create discount per compensation # TODO create discount per compensation
bp = None bp = None
lines = [] lines = []
commit = options.get('commit', True)
for order in orders: for order in orders:
bp = self.get_billing_point(order, bp=bp, **options) bp = self.get_billing_point(order, bp=bp, **options)
ini = order.billed_until or order.registered_on ini = order.billed_until or order.registered_on
@ -184,15 +195,16 @@ class ServiceHandler(plugins.Plugin):
# Number of orders metric; bill line per order # Number of orders metric; bill line per order
size = self.get_pricing_size(ini, bp) size = self.get_pricing_size(ini, bp)
price = self.get_price_with_orders(order, size, ini, bp) price = self.get_price_with_orders(order, size, ini, bp)
lines.append(self.create_line(order, price, size, ini, bp)) lines.append(self.generate_line(order, price, size, ini, bp))
else: else:
# weighted metric; bill line per pricing period # weighted metric; bill line per pricing period
for ini, end in self.get_pricing_slots(ini, bp): for ini, end in self.get_pricing_slots(ini, bp):
size = self.get_pricing_size(ini, end) size = self.get_pricing_size(ini, end)
price = self.get_price_with_metric(order, size, ini, end) price = self.get_price_with_metric(order, size, ini, end)
lines.append(self.create_line(order, price, size, ini, end)) lines.append(self.generate_line(order, price, size, ini, end))
order.billed_until = bp order.billed_until = bp
order.save() # TODO if commit if commit:
order.save()
return lines return lines
def compensate(self, orders): def compensate(self, orders):

View file

@ -36,13 +36,14 @@ def get_related_objects(origin, max_depth=2):
new_models.append(related) new_models.append(related)
queue.append(new_models) queue.append(new_models)
def get_register_or_cancel_events(porders, order, ini, end): def get_register_or_cancel_events(porders, order, ini, end):
assert ini <= end, "ini > end" assert ini <= end, "ini > end"
CANCEL = 'cancel' CANCEL = 'cancel'
REGISTER = 'register' REGISTER = 'register'
changes = {} changes = {}
counter = 0 counter = 0
for num, porder in enumerate(porders.order_by('registered_on')): for num, porder in enumerate(porders.order_by('registered_on'), start=1):
if porder == order: if porder == order:
position = num position = num
if porder.cancelled_on: if porder.cancelled_on:
@ -76,7 +77,7 @@ def get_register_or_renew_events(handler, porders, order, ini, end):
total = float((end-ini).days) total = float((end-ini).days)
for sini, send in handler.get_pricing_slots(ini, end): for sini, send in handler.get_pricing_slots(ini, end):
counter = 0 counter = 0
position = 0 position = -1
for porder in porders.order_by('registered_on'): for porder in porders.order_by('registered_on'):
if porder == order: if porder == order:
position = abs(position) position = abs(position)

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_auto_20140908_1409'),
]
operations = [
migrations.RemoveField(
model_name='rate',
name='value',
),
migrations.AddField(
model_name='rate',
name='price',
field=models.DecimalField(default=1, verbose_name='price', max_digits=12, decimal_places=2),
preserve_default=False,
),
migrations.AlterField(
model_name='rate',
name='plan',
field=models.CharField(blank=True, max_length=128, verbose_name='plan', choices=[(b'', 'Default'), (b'basic', 'Basic'), (b'advanced', 'Advanced')]),
),
]

View file

@ -1,5 +1,8 @@
import sys
from django.db import models from django.db import models
from django.db.models import Q from django.db.migrations.recorder import MigrationRecorder
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
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
@ -35,7 +38,7 @@ class RateQuerySet(models.QuerySet):
def by_account(self, account): def by_account(self, account):
# Default allways selected # Default allways selected
qset = Q(plan__isnull=True) qset = Q(plan='')
for plan in account.plans.all(): for plan in account.plans.all():
qset |= Q(plan=plan) qset |= Q(plan=plan)
return self.filter(qset) return self.filter(qset)
@ -47,7 +50,7 @@ class Rate(models.Model):
plan = models.CharField(_("plan"), max_length=128, blank=True, plan = models.CharField(_("plan"), max_length=128, blank=True,
choices=(('', _("Default")),) + settings.ORDERS_PLANS) choices=(('', _("Default")),) + settings.ORDERS_PLANS)
quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True) quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True)
value = models.DecimalField(_("value"), max_digits=12, decimal_places=2) price = models.DecimalField(_("price"), max_digits=12, decimal_places=2)
objects = RateQuerySet.as_manager() objects = RateQuerySet.as_manager()
@ -55,7 +58,7 @@ class Rate(models.Model):
unique_together = ('service', 'plan', 'quantity') unique_together = ('service', 'plan', 'quantity')
def __unicode__(self): def __unicode__(self):
return "{}-{}".format(str(self.value), self.quantity) return "{}-{}".format(str(self.price), self.quantity)
autodiscover('handlers') autodiscover('handlers')
@ -82,7 +85,7 @@ class Service(models.Model):
BEST_PRICE = 'BEST_PRICE' BEST_PRICE = 'BEST_PRICE'
PROGRESSIVE_PRICE = 'PROGRESSIVE_PRICE' PROGRESSIVE_PRICE = 'PROGRESSIVE_PRICE'
MATCH_PRICE = 'MATCH_PRICE' MATCH_PRICE = 'MATCH_PRICE'
PRICING_METHODS = { RATE_METHODS = {
BEST_PRICE: pricing.best_price, BEST_PRICE: pricing.best_price,
MATCH_PRICE: pricing.match_price, MATCH_PRICE: pricing.match_price,
} }
@ -255,30 +258,47 @@ class Service(models.Model):
if position is provided an specific price for that position is returned, if position is provided an specific price for that position is returned,
accumulated price is returned otherwise accumulated price is returned otherwise
""" """
rates = self.rates.by_account(order.account) rates = self.get_rates(order.account, metric)
if not rates:
return self.nominal_price
rates = self.rate_method(rates, metric)
counter = 0 counter = 0
if position is None: if position is None:
ant_counter = 0 ant_counter = 0
accumulated = 0 accumulated = 0
for rate in self.get_rates(order.account, metric): for rate in rates:
counter += rate['number'] counter += rate['quantity']
if counter >= metric: if counter >= metric:
counter = metric counter = metric
accumulated += (counter - ant_counter) * rate['price'] accumulated += (counter - ant_counter) * rate['price']
return accumulated return float(accumulated)
ant_counter = counter ant_counter = counter
accumulated += rate['price'] * rate['number'] accumulated += rate['price'] * rate['quantity']
else: else:
for rate in self.get_rates(order.account, metric): for rate in rates:
counter += rate['number'] counter += rate['quantity']
if counter >= position: if counter >= position:
return rate['price'] return float(rate['price'])
def get_rates(self, account, metric):
if not hasattr(self, '__cached_rates'):
self.__cached_rates = {}
if account.id in self.__cached_rates:
rates, cache = self.__cached_rates.get(account.id)
else:
rates = self.rates.by_account(account)
cache = {}
if not rates:
rates = [{
'quantity': sys.maxint,
'price': self.nominal_price,
}]
self.__cached_rates[account.id] = (rates, cache)
return rates
self.__cached_rates[account.id] = (rates, cache)
# Caching depends on the specific rating method
return self.rate_method(rates, metric, cache=cache)
@property @property
def rate_method(self, *args, **kwargs): def rate_method(self):
return self.RATE_METHODS[self.rate_algorithm] return self.RATE_METHODS[self.rate_algorithm]
@ -289,16 +309,26 @@ class OrderQuerySet(models.QuerySet):
bills = [] bills = []
bill_backend = Order.get_bill_backend() bill_backend = Order.get_bill_backend()
qs = self.select_related('account', 'service') qs = self.select_related('account', 'service')
commit = options.get('commit', True)
for account, services in qs.group_by('account', 'service'): for account, services in qs.group_by('account', 'service'):
bill_lines = [] bill_lines = []
for service, orders in services: for service, orders in services:
lines = service.handler.create_bill_lines(orders, **options) lines = service.handler.generate_bill_lines(orders, **options)
bill_lines.extend(lines) bill_lines.extend(lines)
bills += bill_backend.create_bills(account, bill_lines) if commit:
bills += bill_backend.create_bills(account, bill_lines)
else:
bills += [(account, bill_lines)]
return bills return bills
def get_related(self): def get_related(self):
pass qs = self.exclude(cancelled_on__isnull=False,
billed_until__gte=F('cancelled_on')).distinct()
original_ids = self.values_list('id', flat=True)
return self.model.objects.exclude(id__in=original_ids).filter(
service__in=qs.values_list('service_id', flat=True),
account__in=qs.values_list('account_id', flat=True)
)
def by_object(self, obj, **kwargs): def by_object(self, obj, **kwargs):
ct = ContentType.objects.get_for_model(obj) ct = ContentType.objects.get_for_model(obj)
@ -421,7 +451,10 @@ def cancel_orders(sender, **kwargs):
@receiver(post_save, dispatch_uid="orders.update_orders") @receiver(post_save, dispatch_uid="orders.update_orders")
@receiver(post_delete, dispatch_uid="orders.update_orders_post_delete") @receiver(post_delete, dispatch_uid="orders.update_orders_post_delete")
def update_orders(sender, **kwargs): def update_orders(sender, **kwargs):
if sender not in [MetricStorage, LogEntry, Order, Service]: exclude = (
MetricStorage, LogEntry, Order, Service, ContentType, MigrationRecorder.Migration
)
if sender not in exclude:
instance = kwargs['instance'] instance = kwargs['instance']
if instance.pk: if instance.pk:
# post_save # post_save

View file

@ -1,35 +1,29 @@
import sys import sys
def best_price(rates, metric): def best_price(rates, metric, cache={}):
rates = rates.order_by('metric').order_by('plan') steps = cache.get('steps')
ix = 0 if not steps:
steps = [] rates = rates.order_by('quantity').order_by('plan')
num = rates.count() ix = 0
while ix < num: steps = []
if ix+1 == num or rates[ix].plan != rates[ix+1].plan: num = rates.count()
number = metric while ix < num:
else: if ix+1 == num or rates[ix].plan != rates[ix+1].plan:
number = rates[ix+1].metric - rates[ix].metric quantity = sys.maxint
steps.append({ else:
'number': sys.maxint, quantity = rates[ix+1].quantity - rates[ix].quantity
'price': rates[ix].price steps.append({
}) 'quantity': quantity,
ix += 1 'price': rates[ix].price
})
steps.sort(key=lambda s: s['price']) ix += 1
acumulated = 0 steps.sort(key=lambda s: s['price'])
for step in steps: cache['steps'] = steps
previous = acumulated return steps
acumulated += step['number']
if acumulated >= metric:
step['number'] = metric - previous
yield step
raise StopIteration
yield step
def match_price(rates, metric): def match_price(rates, metric, cache={}):
minimal = None minimal = None
for plan, rates in rates.order_by('-metric').group_by('plan'): for plan, rates in rates.order_by('-metric').group_by('plan'):
if minimal is None: if minimal is None:
@ -37,6 +31,6 @@ def match_price(rates, metric):
else: else:
minimal = min(minimal, rates[0].price) minimal = min(minimal, rates[0].price)
return [{ return [{
'number': sys.maxint, 'quantity': sys.maxint,
'price': minimal 'price': minimal
}] }]

View file

@ -1,11 +1,18 @@
{% extends "admin/base_site.html" %} {% extends "admin/base_site.html" %}
{% load i18n l10n staticfiles admin_urls %} {% load i18n l10n staticfiles admin_urls utils %}
{% block extrastyle %} {% block extrastyle %}
{{ block.super }} {{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}" /> <link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}" />
<style type="text/css">
.account {
float: right;
margin-right: 400px;
}
</style>
{% endblock %} {% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<div class="breadcrumbs"> <div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> <a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
@ -19,18 +26,53 @@
{% block content %} {% block content %}
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
<div> <div>
<div style="margin:20px;"> <div style="margin:20px;">
{% if bills %}
{% for account, lines in bills %}
<div class="inline-group" id="rates-group">
<div class="tabular inline-related last-related">
<fieldset class="module">
<h2><a href="{% url 'admin:accounts_account_change' account.pk %}">{{ account }}</a></h2>
<table>
<thead>
<tr><th style="width:30%;">Description</th> <th style="width:30%;">Period</th> <th style="width:10%;">Quantity</th> <th style="width:10%;">Price</th></tr>
</thead>
<tbody>
{% for line in lines %}
<tr class="form-row {% if forloop.counter|divisibleby:2 %}row2{% else %}row1{% endif %}">
<td>
<a href="{{ line.order | admin_link }}">{{ line.order.description }}</a>
{% for discount in line.discounts %}
<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Discount per {{ discount.type }}
{% endfor %}
</td>
<td>{{ line.ini | date }} to {{ line.end | date }}</td>
<td>{{ line.size | floatformat:"-2" }}</td>
<td>
&nbsp;{{ line.subtotal | floatformat:"-2" }} &euro;
{% for discount in line.discounts %}<br>{{ discount.total | floatformat:"-2" }} &euro;{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</fieldset>
</div>
</div>
{% endfor %}
{% else %}
{{ form.as_admin }} {{ form.as_admin }}
{% endif %}
</div> </div>
{% for obj in selected_related_objects %}
<input type="hidden" name="selected_related" value="{{ obj.pk|unlocalize }}" />
{% endfor %}
{% 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 }}" />
{% endfor %} {% endfor %}
<input type="hidden" name="action" value="bill_selected_orders" /> <input type="hidden" name="action" value="bill_selected_orders" />
<input type="hidden" name="step" value="{{ step }}" /> <input type="hidden" name="step" value="{{ step }}" />
<input type="submit" value="{% trans "Yes, create slivers" %}" /> <input type="submit" value="{% if step == 3 %}{% trans "Yes, create bills" %}{% else %}{% trans "Next" %}{% endif %}" />
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View file

View file

@ -0,0 +1,109 @@
import datetime
from dateutil import relativedelta
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from orchestra.apps.accounts.models import Account
from orchestra.apps.users.models import User
from orchestra.utils.tests import BaseTestCase
from ... import settings
from ...models import Service
class OrderTests(BaseTestCase):
DEPENDENCIES = (
'orchestra.apps.orders',
'orchestra.apps.users',
'orchestra.apps.users.roles.posix',
)
def create_account(self):
account = Account.objects.create()
user = User.objects.create_user(username='rata_palida', account=account)
account.user = user
account.save()
return account
def create_service(self):
service = Service.objects.create(
description="FTP Account",
content_type=ContentType.objects.get_for_model(User),
match='not user.is_main and user.has_posix()',
billing_period=Service.ANUAL,
billing_point=Service.FIXED_DATE,
delayed_billing=Service.NEVER,
is_fee=False,
metric='',
pricing_period=Service.BILLING_PERIOD,
rate_algorithm=Service.BEST_PRICE,
orders_effect=Service.CONCURRENT,
on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY,
trial_period=Service.NEVER,
refound_period=Service.NEVER,
tax=21,
nominal_price=10,
)
service.rates.create(
plan='',
quantity=1,
price=9,
)
account = self.create_account()
user = User.objects.create_user(username='rata_palida_ftp', account=account)
POSIX = user._meta.get_field_by_name('posix')[0].model
POSIX.objects.create(user=user)
return service
# def test_ftp_account_1_year_fiexed(self):
# service = self.create_service()
# bp = timezone.now().date() + relativedelta.relativedelta(years=1)
# bills = service.orders.bill(billing_point=bp, fixed_point=True)
# self.assertEqual(20, bills[0].get_total())
def test_ftp_account_1_year_fiexed(self):
service = self.create_service()
now = timezone.now().date()
month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
ini = datetime.datetime(year=now.year, month=month,
day=1, tzinfo=timezone.get_current_timezone())
order = service.orders.all()[0]
order.registered_on = ini
order.save()
bp = ini
bills = service.orders.bill(billing_point=bp, fixed_point=False, commit=False)
print bills[0][1][0].subtotal
print bills
bp = ini + relativedelta.relativedelta(months=12)
bills = service.orders.bill(billing_point=bp, fixed_point=False, commit=False)
print bills[0][1][0].subtotal
print bills
# def test_ftp_account_2_year_fiexed(self):
# service = self.create_service()
# bp = timezone.now().date() + relativedelta.relativedelta(years=2)
# bills = service.orders.bill(billing_point=bp, fixed_point=True)
# self.assertEqual(40, bills[0].get_total())
#
# def test_ftp_account_6_month_fixed(self):
# service = self.create_service()
# bp = timezone.now().date() + relativedelta.relativedelta(months=6)
# bills = service.orders.bill(billing_point=bp, fixed_point=True)
# self.assertEqual(6, bills[0].get_total())
#
# def test_ftp_account_next_billing_point(self):
# service = self.create_service()
# now = timezone.now().date()
# bp_month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
# if date.month > bp_month:
# bp = datetime.datetime(year=now.year+1, month=bp_month,
# day=1, tzinfo=timezone.get_current_timezone())
# else:
# bp = datetime.datetime(year=now.year, month=bp_month,
# day=1, tzinfo=timezone.get_current_timezone())
#
# days = (bp - now).days
# bills = service.orders.bill(billing_point=bp, fixed_point=False)
# self.assertEqual(40, bills[0].get_total())

View file

@ -1,5 +1,29 @@
from django.contrib import messages
from django.shortcuts import render
from django.utils.translation import ugettext_lazy as _
from .methods import PaymentMethod from .methods import PaymentMethod
from .models import Transaction
def process_transactions(modeladmin, request, queryset): def process_transactions(modeladmin, request, queryset):
processes = []
if queryset.exclude(state=Transaction.WAITTING_PROCESSING).exists():
msg = _("Selected transactions must be on '{state}' state")
messages.error(request, msg.format(state=Transaction.WAITTING_PROCESSING))
return
for method, transactions in queryset.group_by('source__method'): for method, transactions in queryset.group_by('source__method'):
if method is not None: if method is not None:
PaymentMethod.get_plugin(method)().process(transactions) method = PaymentMethod.get_plugin(method)
procs = method.process(transactions)
processes += procs
if not processes:
return
opts = modeladmin.model._meta
context = {
'title': _("Huston, be advised"),
'action_name': _("Process"),
'processes': processes,
'opts': opts,
'app_label': opts.app_label,
}
return render(request, 'admin/payments/transaction/get_processes.html', context)

View file

@ -29,7 +29,7 @@ class PaymentMethod(plugins.Plugin):
return data[self.number_field] return data[self.number_field]
def get_bill_message(self, source): def get_bill_message(self, source):
raise NotImplementedError return ''
class PaymentSourceDataForm(forms.ModelForm): class PaymentSourceDataForm(forms.ModelForm):

View file

@ -42,7 +42,8 @@ class SEPADirectDebit(PaymentMethod):
return _("This bill will been automatically charged to your bank account " return _("This bill will been automatically charged to your bank account "
" with IBAN number<br><strong>%s</strong>.") % source.number " with IBAN number<br><strong>%s</strong>.") % source.number
def process(self, transactions): @classmethod
def process(cls, transactions):
debts = [] debts = []
credits = [] credits = []
for transaction in transactions: for transaction in transactions:
@ -50,15 +51,20 @@ class SEPADirectDebit(PaymentMethod):
credits.append(transaction) credits.append(transaction)
else: else:
debts.append(transaction) debts.append(transaction)
processes = []
if debts: if debts:
self._process_debts(debts) proc = cls.process_debts(debts)
processes.append(proc)
if credits: if credits:
self._process_credits(credits) proc = cls.process_credits(credits)
processes.append(proc)
return processes
def _process_credits(self, transactions): @classmethod
def process_credits(cls, transactions):
from ..models import TransactionProcess from ..models import TransactionProcess
self.process = TransactionProcess.objects.create() process = TransactionProcess.objects.create()
context = self.get_context(transactions) context = cls.get_context(transactions)
sepa = lxml.builder.ElementMaker( sepa = lxml.builder.ElementMaker(
nsmap = { nsmap = {
'xsi': 'http://www.w3.org/2001/XMLSchema-instance', 'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
@ -67,9 +73,9 @@ class SEPADirectDebit(PaymentMethod):
) )
sepa = sepa.Document( sepa = sepa.Document(
E.CstmrCdtTrfInitn( E.CstmrCdtTrfInitn(
self._get_header(context), cls.get_header(context),
E.PmtInf( # Payment Info E.PmtInf( # Payment Info
E.PmtInfId(str(self.process.id)), # Payment Id E.PmtInfId(str(process.id)), # Payment Id
E.PmtMtd("TRF"), # Payment Method E.PmtMtd("TRF"), # Payment Method
E.NbOfTxs(context['num_transactions']), # Number of Transactions E.NbOfTxs(context['num_transactions']), # Number of Transactions
E.CtrlSum(context['total']), # Control Sum E.CtrlSum(context['total']), # Control Sum
@ -89,17 +95,19 @@ class SEPADirectDebit(PaymentMethod):
E.BIC(context['bic']) E.BIC(context['bic'])
) )
), ),
*list(self._get_credit_transactions(transactions)) # Transactions *list(cls.get_credit_transactions(transactions, process)) # Transactions
) )
) )
) )
file_name = 'credit-transfer-%i.xml' % self.process.id file_name = 'credit-transfer-%i.xml' % process.id
self._process_xml(sepa, 'pain.001.001.03.xsd', file_name) cls.process_xml(sepa, 'pain.001.001.03.xsd', file_name, process)
return process
def _process_debts(self, transactions): @classmethod
def process_debts(cls, transactions):
from ..models import TransactionProcess from ..models import TransactionProcess
self.process = TransactionProcess.objects.create() process = TransactionProcess.objects.create()
context = self.get_context(transactions) context = cls.get_context(transactions)
sepa = lxml.builder.ElementMaker( sepa = lxml.builder.ElementMaker(
nsmap = { nsmap = {
'xsi': 'http://www.w3.org/2001/XMLSchema-instance', 'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
@ -108,9 +116,9 @@ class SEPADirectDebit(PaymentMethod):
) )
sepa = sepa.Document( sepa = sepa.Document(
E.CstmrDrctDbtInitn( E.CstmrDrctDbtInitn(
self._get_header(context), cls.get_header(context, process),
E.PmtInf( # Payment Info E.PmtInf( # Payment Info
E.PmtInfId(str(self.process.id)), # Payment Id E.PmtInfId(str(process.id)), # Payment Id
E.PmtMtd("DD"), # Payment Method E.PmtMtd("DD"), # Payment Method
E.NbOfTxs(context['num_transactions']), # Number of Transactions E.NbOfTxs(context['num_transactions']), # Number of Transactions
E.CtrlSum(context['total']), # Control Sum E.CtrlSum(context['total']), # Control Sum
@ -139,14 +147,16 @@ class SEPADirectDebit(PaymentMethod):
E.BIC(context['bic']) E.BIC(context['bic'])
) )
), ),
*list(self._get_debt_transactions(transactions)) # Transactions *list(cls.get_debt_transactions(transactions, process)) # Transactions
) )
) )
) )
file_name = 'direct-debit-%i.xml' % self.process.id file_name = 'direct-debit-%i.xml' % process.id
self._process_xml(sepa, 'pain.008.001.02.xsd', file_name) cls.process_xml(sepa, 'pain.008.001.02.xsd', file_name, process)
return process
def get_context(self, transactions): @classmethod
def get_context(cls, transactions):
return { return {
'name': settings.PAYMENTS_DD_CREDITOR_NAME, 'name': settings.PAYMENTS_DD_CREDITOR_NAME,
'iban': settings.PAYMENTS_DD_CREDITOR_IBAN, 'iban': settings.PAYMENTS_DD_CREDITOR_IBAN,
@ -157,9 +167,10 @@ class SEPADirectDebit(PaymentMethod):
'num_transactions': str(len(transactions)), 'num_transactions': str(len(transactions)),
} }
def _get_debt_transactions(self, transactions): @classmethod
def get_debt_transactions(cls, transactions, process):
for transaction in transactions: for transaction in transactions:
transaction.process = self.process transaction.process = process
account = transaction.account account = transaction.account
data = transaction.source.data data = transaction.source.data
transaction.state = transaction.WAITTING_CONFIRMATION transaction.state = transaction.WAITTING_CONFIRMATION
@ -197,9 +208,10 @@ class SEPADirectDebit(PaymentMethod):
), ),
) )
def _get_credit_transactions(self, transactions): @classmethod
def get_credit_transactions(transactions, process):
for transaction in transactions: for transaction in transactions:
transaction.process = self.process transaction.process = process
account = transaction.account account = transaction.account
data = transaction.source.data data = transaction.source.data
transaction.state = transaction.WAITTING_CONFIRMATION transaction.state = transaction.WAITTING_CONFIRMATION
@ -231,9 +243,10 @@ class SEPADirectDebit(PaymentMethod):
), ),
) )
def _get_header(self, context): @classmethod
def get_header(cls, context, process):
return E.GrpHdr( # Group Header return E.GrpHdr( # Group Header
E.MsgId(str(self.process.id)), # Message Id E.MsgId(str(process.id)), # Message Id
E.CreDtTm( # Creation Date Time E.CreDtTm( # Creation Date Time
context['now'].strftime("%Y-%m-%dT%H:%M:%S") context['now'].strftime("%Y-%m-%dT%H:%M:%S")
), ),
@ -251,7 +264,8 @@ class SEPADirectDebit(PaymentMethod):
) )
) )
def _process_xml(self, sepa, xsd, file_name): @classmethod
def process_xml(cls, sepa, xsd, file_name, process):
# http://www.iso20022.org/documents/messages/1_0_version/pain/schemas/pain.008.001.02.zip # http://www.iso20022.org/documents/messages/1_0_version/pain/schemas/pain.008.001.02.zip
path = os.path.dirname(os.path.realpath(__file__)) path = os.path.dirname(os.path.realpath(__file__))
xsd_path = os.path.join(path, xsd) xsd_path = os.path.join(path, xsd)
@ -259,9 +273,9 @@ class SEPADirectDebit(PaymentMethod):
schema = etree.XMLSchema(schema_doc) schema = etree.XMLSchema(schema_doc)
sepa = etree.parse(StringIO(etree.tostring(sepa))) sepa = etree.parse(StringIO(etree.tostring(sepa)))
schema.assertValid(sepa) schema.assertValid(sepa)
self.process.file = file_name process.file = file_name
self.process.save() process.save()
sepa.write(self.process.file.path, sepa.write(process.file.path,
pretty_print=True, pretty_print=True,
xml_declaration=True, xml_declaration=True,
encoding='UTF-8') encoding='UTF-8')

View file

@ -0,0 +1,18 @@
{% extends "admin/orchestra/generic_confirmation.html" %}
{% load i18n admin_urls utils %}
{% block content %}
<p>The following transaction processes have been generated, you may want to save them on your computer now.</p>
<ul>
{% for proc in processes %}
<li> <a href="{{ proc.id }}">Process #{{ proc.id }}</a>
{% if proc.file %}
<ul><li>File: <a href="{{ proc.file.url }}">{{ proc.file }}</a></li></ul>
{% endif %}
{% if proc.data %}
<ul><li>Data: {{ proc.data }}</li></ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -70,6 +70,10 @@ class ResourceDataAdmin(admin.ModelAdmin):
readonly_fields = ('content_object_link',) readonly_fields = ('content_object_link',)
content_object_link = admin_link('content_object') content_object_link = admin_link('content_object')
def get_queryset(self, request):
queryset = super(ResourceDataAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_object')
class MonitorDataAdmin(admin.ModelAdmin): class MonitorDataAdmin(admin.ModelAdmin):
@ -78,6 +82,10 @@ class MonitorDataAdmin(admin.ModelAdmin):
readonly_fields = ('content_object_link',) readonly_fields = ('content_object_link',)
content_object_link = admin_link('content_object') content_object_link = admin_link('content_object')
def get_queryset(self, request):
queryset = super(MonitorDataAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_object')
admin.site.register(Resource, ResourceAdmin) admin.site.register(Resource, ResourceAdmin)

View file

@ -47,4 +47,3 @@ class UserChangeForm(forms.ModelForm):
# This is done here, rather than on the field, because the # This is done here, rather than on the field, because the
# field does not have access to the initial value # field does not have access to the initial value
return self.initial["password"] return self.initial["password"]

View file

@ -14,7 +14,7 @@ class User(auth.AbstractBaseUser):
validators=[validators.RegexValidator(r'^[\w.-]+$', validators=[validators.RegexValidator(r'^[\w.-]+$',
_("Enter a valid username."), 'invalid')]) _("Enter a valid username."), 'invalid')])
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='users', null=True) related_name='users')
first_name = models.CharField(_("first name"), max_length=30, blank=True) first_name = models.CharField(_("first name"), max_length=30, blank=True)
last_name = models.CharField(_("last name"), max_length=30, blank=True) last_name = models.CharField(_("last name"), max_length=30, blank=True)
email = models.EmailField(_('email address'), blank=True) email = models.EmailField(_('email address'), blank=True)

View file

@ -13,10 +13,10 @@ class Register(object):
def register(self, name, model): def register(self, name, model):
if name in self._registry: if name in self._registry:
raise KeyError("%s already registered" % name) raise KeyError("%s already registered" % name)
def has_role(user): def has_role(user, model=model):
try: try:
getattr(user, name) getattr(user, name)
except models.DoesNotExist: except model.DoesNotExist:
return False return False
return True return True
setattr(User, 'has_%s' % name, has_role) setattr(User, 'has_%s' % name, has_role)

View file

@ -144,7 +144,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
'orchestra.apps.contacts.models.Contact', 'orchestra.apps.contacts.models.Contact',
'orchestra.apps.users.models.User', 'orchestra.apps.users.models.User',
'orchestra.apps.orders.models.Order', 'orchestra.apps.orders.models.Order',
'orchestra.apps.orders.models.Pack', 'orchestra.apps.orders.models.Plan',
'orchestra.apps.bills.models.Bill', 'orchestra.apps.bills.models.Bill',
# 'orchestra.apps.payments.models.PaymentSource', # 'orchestra.apps.payments.models.PaymentSource',
'orchestra.apps.payments.models.Transaction', 'orchestra.apps.payments.models.Transaction',

View file

@ -0,0 +1,67 @@
{% extends "admin/base_site.html" %}
{% load i18n l10n %}
{% load url from future %}
{% load admin_urls static utils %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}" />
<link rel="stylesheet" type="text/css" href="{% static "orchestra/css/hide-inline-id.css" %}" />
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=app_label %}">{{ app_label|capfirst|escape }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
{% if obj %}
&rsaquo; <a href="{% url opts|admin_urlname:'change' obj.pk %}">{{ obj }}</a>
&rsaquo; {{ action_name }}
{% else %}
&rsaquo; {{ action_name }} multiple objects
{% endif %}
</div>
{% endblock %}
{% block content %}
<div>
<div style="margin:20px;">
<p>{{ content_message | safe }}</p>
<ul>
{% for display_object in display_objects %}
<li> <a href="{% url 'admin:nodes_node_change' deletable_object.id %}">{{ deletable_object }} </a></li>
{% endfor %}
</ul>
<form action="" method="post">{% csrf_token %}
{% if form %}
<fieldset class="module aligned">
{% for field in form %}
<div class="form-row ">
<div >
{{ field.errors }}
{% if field|is_checkbox %}
{{ field }} <label for="{{ field.id_for_label }}" class="vCheckboxLabel">{{ field.label }}</label>
{% else %}
{{ field.label_tag }} {{ field }}
{% endif %}
<p class="help">{{ field.help_text|safe }}</p>
</div>
</div>
{% endfor %}
</fieldset>
{% endif %}
{% if formset %}
{{ formset.as_admin }}
{% endif %}
<div>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
{% endfor %}
<input type="hidden" name="action" value="{{ action_value }}" />
<input type="hidden" name="post" value="generic_confirmation" />
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
</div>
</form>
{% endblock %}

View file

@ -3,6 +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
register = template.Library() register = template.Library()
@ -41,6 +42,11 @@ def size(value, length):
return str(value) + (' '*num_spaces) return str(value) + (' '*num_spaces)
@register.filter(name='is_checkbox') @register.filter
def is_checkbox(field): def is_checkbox(field):
return isinstance(field.field.widget, CheckboxInput) return isinstance(field.field.widget, CheckboxInput)
@register.filter
def admin_link(obj):
return admin_change_url(obj)

View file

@ -65,3 +65,8 @@ class OrderedSet(collections.MutableSet):
return len(self) == len(other) and list(self) == list(other) return len(self) == len(other) and list(self) == list(other)
return set(self) == set(other) return set(self) == set(other)
class AttributeDict(dict):
def __init__(self, *args, **kwargs):
super(AttributeDict, self).__init__(*args, **kwargs)
self.__dict__ = self

View file

@ -76,8 +76,9 @@ class BaseLiveServerTestCase(AppDependencyMixin, LiveServerTestCase):
self.account = Account.objects.create(name='orchestra') self.account = Account.objects.create(name='orchestra')
self.username = 'orchestra' self.username = 'orchestra'
self.password = 'orchestra' self.password = 'orchestra'
self.user = User.objects.create_superuser(username='orchestra', password='orchestra', self.user = User.objects.create_superuser(username='orchestra',
email='orchestra@orchestra.org', account=self.account) password='orchestra', email='orchestra@orchestra.org',
account=self.account)
def admin_login(self): def admin_login(self):
session = SessionStore() session = SessionStore()