Random improvements
This commit is contained in:
parent
4e3105194c
commit
287f03ce19
14
TODO.md
14
TODO.md
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
30
orchestra/apps/accounts/migrations/0001_initial.py
Normal file
30
orchestra/apps/accounts/migrations/0001_initial.py
Normal 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,),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
0
orchestra/apps/accounts/migrations/__init__.py
Normal file
0
orchestra/apps/accounts/migrations/__init__.py
Normal 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,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
29
orchestra/apps/orders/migrations/0004_auto_20140909_1426.py
Normal file
29
orchestra/apps/orders/migrations/0004_auto_20140909_1426.py
Normal 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')]),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}]
|
}]
|
||||||
|
|
|
@ -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> Discount per {{ discount.type }}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td>{{ line.ini | date }} to {{ line.end | date }}</td>
|
||||||
|
<td>{{ line.size | floatformat:"-2" }}</td>
|
||||||
|
<td>
|
||||||
|
{{ line.subtotal | floatformat:"-2" }} €
|
||||||
|
{% for discount in line.discounts %}<br>{{ discount.total | floatformat:"-2" }} €{% 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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
0
orchestra/apps/orders/tests/__init__.py
Normal file
0
orchestra/apps/orders/tests/__init__.py
Normal file
109
orchestra/apps/orders/tests/functional_tests/tests.py
Normal file
109
orchestra/apps/orders/tests/functional_tests/tests.py
Normal 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())
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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 %}
|
|
@ -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)
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=app_label %}">{{ app_label|capfirst|escape }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
{% if obj %}
|
||||||
|
› <a href="{% url opts|admin_urlname:'change' obj.pk %}">{{ obj }}</a>
|
||||||
|
› {{ action_name }}
|
||||||
|
{% else %}
|
||||||
|
› {{ 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 %}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in a new issue