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 )
|
||||
|
||||
* 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)))
|
||||
return new_urls + urls
|
||||
|
||||
def get_change_view_actions(self, obj=None):
|
||||
def get_change_view_actions(self):
|
||||
views = []
|
||||
for action in self.change_view_actions:
|
||||
if isinstance(action, basestring):
|
||||
|
@ -77,11 +77,10 @@ class ChangeViewActionsMixin(object):
|
|||
return views
|
||||
|
||||
def change_view(self, request, object_id, **kwargs):
|
||||
obj = self.get_object(request, unquote(object_id))
|
||||
if not 'extra_context' in kwargs:
|
||||
kwargs['extra_context'] = {}
|
||||
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)
|
||||
|
||||
|
|
|
@ -90,6 +90,12 @@ def action_to_view(action, modeladmin):
|
|||
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
|
||||
def admin_link(*args, **kwargs):
|
||||
instance = args[-1]
|
||||
|
@ -99,9 +105,7 @@ def admin_link(*args, **kwargs):
|
|||
obj = get_field_value(instance, kwargs['field'])
|
||||
if not getattr(obj, 'pk', None):
|
||||
return '---'
|
||||
opts = obj._meta
|
||||
view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name)
|
||||
url = reverse(view_name, args=(obj.pk,))
|
||||
url = admin_change_url(obj)
|
||||
extra = ''
|
||||
if kwargs['popup']:
|
||||
extra = 'onclick="return showAddAnotherPopup(this);"'
|
||||
|
@ -130,3 +134,12 @@ def admin_date(*args, **kwargs):
|
|||
return '<span title="{0}">{1}</span>'.format(
|
||||
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):
|
||||
""" Select related for performance """
|
||||
# TODO move invoicecontact to contacts
|
||||
qs = super(AccountAdmin, self).get_queryset(request)
|
||||
related = ('user', 'invoicecontact')
|
||||
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):
|
||||
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,
|
||||
max_length=32, default=settings.ACCOUNTS_DEFAULT_TYPE)
|
||||
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 orchestra.admin.forms import adminmodelformset_factory
|
||||
from orchestra.admin.utils import get_object_from_url
|
||||
from orchestra.utils.html import html_to_pdf
|
||||
|
||||
from .forms import SelectSourceForm
|
||||
|
@ -69,6 +70,7 @@ def close_bills(modeladmin, request, queryset):
|
|||
'app_label': opts.app_label,
|
||||
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
|
||||
'formset': formset,
|
||||
'obj': get_object_from_url(modeladmin, request),
|
||||
}
|
||||
return render(request, 'admin/orchestra/generic_confirmation.html', context)
|
||||
close_bills.verbose_name = _("Close")
|
||||
|
|
|
@ -19,16 +19,8 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
|
|||
|
||||
class BillLineInline(admin.TabularInline):
|
||||
model = BillLine
|
||||
fields = ('description', 'rate', 'amount', 'tax', 'total', 'subtotal')
|
||||
readonly_fields = ('subtotal',)
|
||||
|
||||
def subtotal(self, line):
|
||||
if line.total:
|
||||
subtotal = 0
|
||||
for subline in line.sublines.all():
|
||||
subtotal += subline.total
|
||||
return line.total - subtotal
|
||||
return ''
|
||||
fields = ('description', 'rate', 'amount', 'tax', 'total', 'get_total')
|
||||
readonly_fields = ('get_total',)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj and obj.status != Bill.OPEN:
|
||||
|
@ -44,9 +36,17 @@ class BillLineInline(admin.TabularInline):
|
|||
if obj and obj.status != Bill.OPEN:
|
||||
return False
|
||||
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
|
||||
fields = ('description', 'rate', 'amount', 'tax', 'total')
|
||||
|
||||
|
@ -108,7 +108,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
return fieldsets
|
||||
|
||||
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 = []
|
||||
if obj:
|
||||
if obj.status != Bill.OPEN:
|
||||
|
|
|
@ -57,7 +57,7 @@ class Bill(models.Model):
|
|||
closed_on = models.DateTimeField(_("closed on"), blank=True, null=True)
|
||||
due_on = models.DateField(_("due on"), null=True, blank=True)
|
||||
last_modified_on = models.DateTimeField(_("last modified on"), auto_now=True)
|
||||
total = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
comments = models.TextField(_("comments"), blank=True)
|
||||
html = models.TextField(_("HTML"), blank=True)
|
||||
|
||||
|
@ -170,24 +170,18 @@ class Bill(models.Model):
|
|||
def save(self, *args, **kwargs):
|
||||
if not self.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):
|
||||
self.set_number()
|
||||
super(Bill, self).save(*args, **kwargs)
|
||||
|
||||
@cached
|
||||
def get_subtotals(self):
|
||||
subtotals = {}
|
||||
for line in self.lines.all():
|
||||
subtotal, taxes = subtotals.get(line.tax, (0, 0))
|
||||
subtotal += line.total
|
||||
for subline in line.sublines.all():
|
||||
subtotal += subline.total
|
||||
subtotal += line.get_total()
|
||||
subtotals[line.tax] = (subtotal, (line.tax/100)*subtotal)
|
||||
return subtotals
|
||||
|
||||
@cached
|
||||
def get_total(self):
|
||||
total = 0
|
||||
for tax, subtotal in self.get_subtotals().iteritems():
|
||||
|
@ -246,7 +240,7 @@ class BaseBillLine(models.Model):
|
|||
def number(self):
|
||||
lines = type(self).objects.filter(bill=self.bill_id)
|
||||
return lines.filter(id__lte=self.id).order_by('id').count()
|
||||
|
||||
|
||||
|
||||
class BudgetLine(BaseBillLine):
|
||||
pass
|
||||
|
@ -259,6 +253,20 @@ class BillLine(BaseBillLine):
|
|||
auto = models.BooleanField(default=False)
|
||||
amended_line = models.ForeignKey('self', verbose_name=_("amended line"),
|
||||
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):
|
||||
|
@ -268,6 +276,12 @@ class BillSubline(models.Model):
|
|||
description = models.CharField(_("description"), max_length=256)
|
||||
total = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
# 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)
|
||||
|
|
|
@ -3,7 +3,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||
|
||||
|
||||
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.system import run
|
||||
|
||||
from orchestra.apps.domains import settings, utils, backends
|
||||
from orchestra.apps.domains.models import Domain, Record
|
||||
from ... import settings, utils, backends
|
||||
from ...models import Domain, Record
|
||||
|
||||
|
||||
run = functools.partial(run, display=False)
|
||||
|
|
|
@ -69,6 +69,10 @@ class BackendOperationInline(admin.TabularInline):
|
|||
|
||||
def has_add_permission(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super(BackendOperationInline, self).get_queryset(request)
|
||||
return queryset.prefetch_related('instance')
|
||||
|
||||
|
||||
def display_mono(field):
|
||||
|
@ -106,7 +110,7 @@ class BackendLogAdmin(admin.ModelAdmin):
|
|||
def get_queryset(self, request):
|
||||
""" Order by structured name and imporve performance """
|
||||
qs = super(BackendLogAdmin, self).get_queryset(request)
|
||||
return qs.select_related('server')
|
||||
return qs.select_related('server').defer('script', 'stdout')
|
||||
|
||||
|
||||
class ServerAdmin(admin.ModelAdmin):
|
||||
|
|
|
@ -13,7 +13,7 @@ from . import settings
|
|||
|
||||
def BashSSH(backend, log, server, cmds):
|
||||
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', '')
|
||||
log.script = script
|
||||
log.save()
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.contrib.contenttypes import generic
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
@ -70,6 +71,9 @@ class BackendLog(models.Model):
|
|||
@property
|
||||
def execution_time(self):
|
||||
return (self.last_update-self.created).total_seconds()
|
||||
|
||||
def backend_class(self):
|
||||
return ServiceBackend.get_backend(self.backend)
|
||||
|
||||
|
||||
class BackendOperation(models.Model):
|
||||
|
@ -85,6 +89,7 @@ class BackendOperation(models.Model):
|
|||
action = models.CharField(_("action"), max_length=64)
|
||||
content_type = models.ForeignKey(ContentType)
|
||||
object_id = models.PositiveIntegerField()
|
||||
# TODO rename to content_object
|
||||
instance = generic.GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -2,6 +2,7 @@ from django.contrib import admin, messages
|
|||
from django.core.urlresolvers import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ungettext
|
||||
from django.shortcuts import render
|
||||
|
||||
from .forms import (BillSelectedOptionsForm, BillSelectConfirmationForm,
|
||||
|
@ -41,15 +42,16 @@ class BillSelectedOrders(object):
|
|||
return self.select_related(request)
|
||||
self.context.update({
|
||||
'title': _("Options for billing selected orders, step 1 / 3"),
|
||||
'step': 'one',
|
||||
'step': 1,
|
||||
'form': form,
|
||||
})
|
||||
return render(request, self.template, self.context)
|
||||
|
||||
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)
|
||||
if request.POST.get('step') == 'two':
|
||||
if int(request.POST.get('step')) >= 2:
|
||||
form = BillSelectRelatedForm(request.POST, initial=self.options)
|
||||
if form.is_valid():
|
||||
select_related = form.cleaned_data['selected_related']
|
||||
|
@ -57,14 +59,14 @@ class BillSelectedOrders(object):
|
|||
return self.confirmation(request)
|
||||
self.context.update({
|
||||
'title': _("Select related order for billing, step 2 / 3"),
|
||||
'step': 'two',
|
||||
'step': 2,
|
||||
'form': form,
|
||||
})
|
||||
return render(request, self.template, self.context)
|
||||
|
||||
def confirmation(self, request):
|
||||
form = BillSelectConfirmationForm(initial=self.options)
|
||||
if request.POST:
|
||||
if int(request.POST.get('step')) >= 3:
|
||||
bills = self.queryset.bill(commit=True, **self.options)
|
||||
if not bills:
|
||||
msg = _("Selected orders do not have pending billing")
|
||||
|
@ -72,19 +74,21 @@ class BillSelectedOrders(object):
|
|||
else:
|
||||
ids = ','.join([str(bill.id) for bill in bills])
|
||||
url = reverse('admin:bills_bill_changelist')
|
||||
context = {
|
||||
'url': url + '?id=%s' % ids,
|
||||
'num': len(bills),
|
||||
'bills': _("bills"),
|
||||
'msg': _("have been generated"),
|
||||
}
|
||||
msg = '<a href="%(url)s">%(num)s %(bills)s</a> %(msg)s' % context
|
||||
url += '?id__in=%s' % ids
|
||||
num = len(bills)
|
||||
msg = ungettext(
|
||||
'<a href="{url}">One bill</a> has been created.',
|
||||
'<a href="{url}">{num} bills</a> have been created.',
|
||||
num).format(url=url, num=num)
|
||||
msg = mark_safe(msg)
|
||||
self.modeladmin.message_user(request, msg, messages.INFO)
|
||||
return
|
||||
bills = self.queryset.bill(commit=False, **self.options)
|
||||
self.context.update({
|
||||
'title': _("Confirmation for billing selected orders"),
|
||||
'step': 'three',
|
||||
'step': 3,
|
||||
'form': form,
|
||||
'bills': bills,
|
||||
'selected_related_objects': self.options['selected_related']
|
||||
})
|
||||
return render(request, self.template, self.context)
|
||||
|
|
|
@ -9,38 +9,39 @@ class BillsBackend(object):
|
|||
def create_bills(self, account, lines):
|
||||
invoice = None
|
||||
bills = []
|
||||
for order, nominal_price, size, ini, end, discounts in lines:
|
||||
service = order.service
|
||||
for line in lines:
|
||||
service = line.order.service
|
||||
if service.is_fee:
|
||||
fee, __ = Fee.objects.get_or_create(account=account, status=Fee.OPEN)
|
||||
line = fee.lines.create(
|
||||
storedline = fee.lines.create(
|
||||
rate=service.nominal_price,
|
||||
amount=size,
|
||||
total=nominal_price, tax=0,
|
||||
description=self.format_period(ini, end),
|
||||
amount=line.size,
|
||||
total=line.subtotal, tax=0,
|
||||
description=self.format_period(line.ini, line.end),
|
||||
)
|
||||
self.create_sublines(line, discounts)
|
||||
self.create_sublines(storedline, line.discounts)
|
||||
bills.append(fee)
|
||||
else:
|
||||
if invoice is None:
|
||||
invoice, __ = Invoice.objects.get_or_create(account=account,
|
||||
status=Invoice.OPEN)
|
||||
bills.append(invoice)
|
||||
description = order.description
|
||||
description = line.order.description
|
||||
if service.billing_period != service.NEVER:
|
||||
description += " %s" % self.format_period(ini, end)
|
||||
line = invoice.lines.create(
|
||||
description += " %s" % self.format_period(line.ini, line.end)
|
||||
storedline = invoice.lines.create(
|
||||
description=description,
|
||||
rate=service.nominal_price,
|
||||
amount=size,
|
||||
total=nominal_price,
|
||||
amount=line.size,
|
||||
# TODO rename line.total > subtotal
|
||||
total=line.subtotal,
|
||||
tax=service.tax,
|
||||
)
|
||||
self.create_sublines(line, discounts)
|
||||
self.create_sublines(storedline, line.discounts)
|
||||
return bills
|
||||
|
||||
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")
|
||||
if ini == end:
|
||||
return ini
|
||||
|
@ -48,8 +49,8 @@ class BillsBackend(object):
|
|||
|
||||
|
||||
def create_sublines(self, line, discounts):
|
||||
for name, value in discounts:
|
||||
for discount in discounts:
|
||||
line.sublines.create(
|
||||
description=_("Discount per %s") % name,
|
||||
total=value,
|
||||
description=_("Discount per %s") % discount.type,
|
||||
total=discount.total,
|
||||
)
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
from django import forms
|
||||
from django.contrib.admin import widgets
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orchestra.admin.forms import AdminFormMixin
|
||||
from orchestra.admin.utils import admin_change_url
|
||||
|
||||
from .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 "
|
||||
"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):
|
||||
selected_related = forms.ModelMultipleChoiceField(queryset=Order.objects.none(),
|
||||
selected_related = forms.ModelMultipleChoiceField(label=_("Related"),
|
||||
queryset=Order.objects.none(), widget=forms.CheckboxSelectMultiple,
|
||||
required=False)
|
||||
billing_point = forms.DateField(widget=forms.HiddenInput())
|
||||
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)
|
||||
if queryset:
|
||||
self.fields['selected_related'].queryset = queryset
|
||||
self.fields['selected_related'].choices = selected_related_choices(queryset)
|
||||
|
||||
|
||||
class BillSelectConfirmationForm(forms.Form):
|
||||
selected_related = forms.ModelMultipleChoiceField(queryset=Order.objects.none(),
|
||||
widget=forms.HiddenInput(), required=False)
|
||||
class BillSelectConfirmationForm(AdminFormMixin, forms.Form):
|
||||
# selected_related = forms.ModelMultipleChoiceField(queryset=Order.objects.none(),
|
||||
# widget=forms.HiddenInput(), required=False)
|
||||
billing_point = forms.DateField(widget=forms.HiddenInput())
|
||||
fixed_point = 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 orchestra.utils import plugins
|
||||
from orchestra.utils.python import AttributeDict
|
||||
|
||||
from . import settings
|
||||
from .helpers import get_register_or_cancel_events, get_register_or_renew_events
|
||||
|
@ -70,8 +71,8 @@ class ServiceHandler(plugins.Plugin):
|
|||
day = 1
|
||||
else:
|
||||
raise NotImplementedError(msg)
|
||||
bp = datetime.datetime(year=date.year, month=date.month,
|
||||
day=day, tzinfo=timezone.get_current_timezone())
|
||||
bp = datetime.datetime(year=date.year, month=date.month, day=day,
|
||||
tzinfo=timezone.get_current_timezone())
|
||||
elif self.billing_period == self.ANUAL:
|
||||
if self.billing_point == self.ON_REGISTER:
|
||||
month = order.registered_on.month
|
||||
|
@ -151,17 +152,26 @@ class ServiceHandler(plugins.Plugin):
|
|||
price = self.get_price(order, metric) * size
|
||||
return price
|
||||
|
||||
def create_line(self, order, price, size, ini, end):
|
||||
nominal_price = self.nominal_price * size
|
||||
def generate_line(self, order, price, size, ini, end):
|
||||
subtotal = float(self.nominal_price) * size
|
||||
discounts = []
|
||||
if nominal_price > price:
|
||||
discounts.append(('volume', nominal_price-price))
|
||||
# TODO Uncomment when prices are done
|
||||
# elif nominal_price < price:
|
||||
# raise ValueError("Something is wrong!")
|
||||
return (order, nominal_price, size, ini, end, discounts)
|
||||
if subtotal > price:
|
||||
discounts.append(AttributeDict(**{
|
||||
'type': 'volume',
|
||||
'total': price-subtotal
|
||||
}))
|
||||
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:
|
||||
# date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0)
|
||||
# In most cases:
|
||||
|
@ -175,6 +185,7 @@ class ServiceHandler(plugins.Plugin):
|
|||
# TODO create discount per compensation
|
||||
bp = None
|
||||
lines = []
|
||||
commit = options.get('commit', True)
|
||||
for order in orders:
|
||||
bp = self.get_billing_point(order, bp=bp, **options)
|
||||
ini = order.billed_until or order.registered_on
|
||||
|
@ -184,15 +195,16 @@ class ServiceHandler(plugins.Plugin):
|
|||
# Number of orders metric; bill line per order
|
||||
size = self.get_pricing_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:
|
||||
# weighted metric; bill line per pricing period
|
||||
for ini, end in self.get_pricing_slots(ini, bp):
|
||||
size = self.get_pricing_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.save() # TODO if commit
|
||||
if commit:
|
||||
order.save()
|
||||
return lines
|
||||
|
||||
def compensate(self, orders):
|
||||
|
|
|
@ -36,13 +36,14 @@ def get_related_objects(origin, max_depth=2):
|
|||
new_models.append(related)
|
||||
queue.append(new_models)
|
||||
|
||||
|
||||
def get_register_or_cancel_events(porders, order, ini, end):
|
||||
assert ini <= end, "ini > end"
|
||||
CANCEL = 'cancel'
|
||||
REGISTER = 'register'
|
||||
changes = {}
|
||||
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:
|
||||
position = num
|
||||
if porder.cancelled_on:
|
||||
|
@ -76,7 +77,7 @@ def get_register_or_renew_events(handler, porders, order, ini, end):
|
|||
total = float((end-ini).days)
|
||||
for sini, send in handler.get_pricing_slots(ini, end):
|
||||
counter = 0
|
||||
position = 0
|
||||
position = -1
|
||||
for porder in porders.order_by('registered_on'):
|
||||
if porder == order:
|
||||
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.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.dispatch import receiver
|
||||
from django.contrib.admin.models import LogEntry
|
||||
|
@ -35,7 +38,7 @@ class RateQuerySet(models.QuerySet):
|
|||
|
||||
def by_account(self, account):
|
||||
# Default allways selected
|
||||
qset = Q(plan__isnull=True)
|
||||
qset = Q(plan='')
|
||||
for plan in account.plans.all():
|
||||
qset |= Q(plan=plan)
|
||||
return self.filter(qset)
|
||||
|
@ -47,7 +50,7 @@ class Rate(models.Model):
|
|||
plan = models.CharField(_("plan"), max_length=128, blank=True,
|
||||
choices=(('', _("Default")),) + settings.ORDERS_PLANS)
|
||||
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()
|
||||
|
||||
|
@ -55,7 +58,7 @@ class Rate(models.Model):
|
|||
unique_together = ('service', 'plan', 'quantity')
|
||||
|
||||
def __unicode__(self):
|
||||
return "{}-{}".format(str(self.value), self.quantity)
|
||||
return "{}-{}".format(str(self.price), self.quantity)
|
||||
|
||||
|
||||
autodiscover('handlers')
|
||||
|
@ -82,7 +85,7 @@ class Service(models.Model):
|
|||
BEST_PRICE = 'BEST_PRICE'
|
||||
PROGRESSIVE_PRICE = 'PROGRESSIVE_PRICE'
|
||||
MATCH_PRICE = 'MATCH_PRICE'
|
||||
PRICING_METHODS = {
|
||||
RATE_METHODS = {
|
||||
BEST_PRICE: pricing.best_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,
|
||||
accumulated price is returned otherwise
|
||||
"""
|
||||
rates = self.rates.by_account(order.account)
|
||||
if not rates:
|
||||
return self.nominal_price
|
||||
rates = self.rate_method(rates, metric)
|
||||
rates = self.get_rates(order.account, metric)
|
||||
counter = 0
|
||||
if position is None:
|
||||
ant_counter = 0
|
||||
accumulated = 0
|
||||
for rate in self.get_rates(order.account, metric):
|
||||
counter += rate['number']
|
||||
for rate in rates:
|
||||
counter += rate['quantity']
|
||||
if counter >= metric:
|
||||
counter = metric
|
||||
accumulated += (counter - ant_counter) * rate['price']
|
||||
return accumulated
|
||||
return float(accumulated)
|
||||
ant_counter = counter
|
||||
accumulated += rate['price'] * rate['number']
|
||||
accumulated += rate['price'] * rate['quantity']
|
||||
else:
|
||||
for rate in self.get_rates(order.account, metric):
|
||||
counter += rate['number']
|
||||
for rate in rates:
|
||||
counter += rate['quantity']
|
||||
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
|
||||
def rate_method(self, *args, **kwargs):
|
||||
def rate_method(self):
|
||||
return self.RATE_METHODS[self.rate_algorithm]
|
||||
|
||||
|
||||
|
@ -289,16 +309,26 @@ class OrderQuerySet(models.QuerySet):
|
|||
bills = []
|
||||
bill_backend = Order.get_bill_backend()
|
||||
qs = self.select_related('account', 'service')
|
||||
commit = options.get('commit', True)
|
||||
for account, services in qs.group_by('account', 'service'):
|
||||
bill_lines = []
|
||||
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)
|
||||
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
|
||||
|
||||
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):
|
||||
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_delete, dispatch_uid="orders.update_orders_post_delete")
|
||||
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']
|
||||
if instance.pk:
|
||||
# post_save
|
||||
|
|
|
@ -1,35 +1,29 @@
|
|||
import sys
|
||||
|
||||
|
||||
def best_price(rates, metric):
|
||||
rates = rates.order_by('metric').order_by('plan')
|
||||
ix = 0
|
||||
steps = []
|
||||
num = rates.count()
|
||||
while ix < num:
|
||||
if ix+1 == num or rates[ix].plan != rates[ix+1].plan:
|
||||
number = metric
|
||||
else:
|
||||
number = rates[ix+1].metric - rates[ix].metric
|
||||
steps.append({
|
||||
'number': sys.maxint,
|
||||
'price': rates[ix].price
|
||||
})
|
||||
ix += 1
|
||||
|
||||
steps.sort(key=lambda s: s['price'])
|
||||
acumulated = 0
|
||||
for step in steps:
|
||||
previous = acumulated
|
||||
acumulated += step['number']
|
||||
if acumulated >= metric:
|
||||
step['number'] = metric - previous
|
||||
yield step
|
||||
raise StopIteration
|
||||
yield step
|
||||
def best_price(rates, metric, cache={}):
|
||||
steps = cache.get('steps')
|
||||
if not steps:
|
||||
rates = rates.order_by('quantity').order_by('plan')
|
||||
ix = 0
|
||||
steps = []
|
||||
num = rates.count()
|
||||
while ix < num:
|
||||
if ix+1 == num or rates[ix].plan != rates[ix+1].plan:
|
||||
quantity = sys.maxint
|
||||
else:
|
||||
quantity = rates[ix+1].quantity - rates[ix].quantity
|
||||
steps.append({
|
||||
'quantity': quantity,
|
||||
'price': rates[ix].price
|
||||
})
|
||||
ix += 1
|
||||
steps.sort(key=lambda s: s['price'])
|
||||
cache['steps'] = steps
|
||||
return steps
|
||||
|
||||
|
||||
def match_price(rates, metric):
|
||||
def match_price(rates, metric, cache={}):
|
||||
minimal = None
|
||||
for plan, rates in rates.order_by('-metric').group_by('plan'):
|
||||
if minimal is None:
|
||||
|
@ -37,6 +31,6 @@ def match_price(rates, metric):
|
|||
else:
|
||||
minimal = min(minimal, rates[0].price)
|
||||
return [{
|
||||
'number': sys.maxint,
|
||||
'quantity': sys.maxint,
|
||||
'price': minimal
|
||||
}]
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n l10n staticfiles admin_urls %}
|
||||
{% load i18n l10n staticfiles admin_urls utils %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}" />
|
||||
<style type="text/css">
|
||||
.account {
|
||||
float: right;
|
||||
margin-right: 400px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
|
@ -19,18 +26,53 @@
|
|||
{% block content %}
|
||||
<form action="" method="post">{% csrf_token %}
|
||||
<div>
|
||||
|
||||
<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 }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% for obj in selected_related_objects %}
|
||||
<input type="hidden" name="selected_related" value="{{ obj.pk|unlocalize }}" />
|
||||
{% endfor %}
|
||||
{% for obj in queryset %}
|
||||
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk|unlocalize }}" />
|
||||
{% endfor %}
|
||||
<input type="hidden" name="action" value="bill_selected_orders" />
|
||||
<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>
|
||||
</form>
|
||||
{% 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 .models import Transaction
|
||||
|
||||
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'):
|
||||
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]
|
||||
|
||||
def get_bill_message(self, source):
|
||||
raise NotImplementedError
|
||||
return ''
|
||||
|
||||
|
||||
class PaymentSourceDataForm(forms.ModelForm):
|
||||
|
|
|
@ -42,7 +42,8 @@ class SEPADirectDebit(PaymentMethod):
|
|||
return _("This bill will been automatically charged to your bank account "
|
||||
" with IBAN number<br><strong>%s</strong>.") % source.number
|
||||
|
||||
def process(self, transactions):
|
||||
@classmethod
|
||||
def process(cls, transactions):
|
||||
debts = []
|
||||
credits = []
|
||||
for transaction in transactions:
|
||||
|
@ -50,15 +51,20 @@ class SEPADirectDebit(PaymentMethod):
|
|||
credits.append(transaction)
|
||||
else:
|
||||
debts.append(transaction)
|
||||
processes = []
|
||||
if debts:
|
||||
self._process_debts(debts)
|
||||
proc = cls.process_debts(debts)
|
||||
processes.append(proc)
|
||||
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
|
||||
self.process = TransactionProcess.objects.create()
|
||||
context = self.get_context(transactions)
|
||||
process = TransactionProcess.objects.create()
|
||||
context = cls.get_context(transactions)
|
||||
sepa = lxml.builder.ElementMaker(
|
||||
nsmap = {
|
||||
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
||||
|
@ -67,9 +73,9 @@ class SEPADirectDebit(PaymentMethod):
|
|||
)
|
||||
sepa = sepa.Document(
|
||||
E.CstmrCdtTrfInitn(
|
||||
self._get_header(context),
|
||||
cls.get_header(context),
|
||||
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.NbOfTxs(context['num_transactions']), # Number of Transactions
|
||||
E.CtrlSum(context['total']), # Control Sum
|
||||
|
@ -89,17 +95,19 @@ class SEPADirectDebit(PaymentMethod):
|
|||
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
|
||||
self._process_xml(sepa, 'pain.001.001.03.xsd', file_name)
|
||||
file_name = 'credit-transfer-%i.xml' % process.id
|
||||
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
|
||||
self.process = TransactionProcess.objects.create()
|
||||
context = self.get_context(transactions)
|
||||
process = TransactionProcess.objects.create()
|
||||
context = cls.get_context(transactions)
|
||||
sepa = lxml.builder.ElementMaker(
|
||||
nsmap = {
|
||||
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
||||
|
@ -108,9 +116,9 @@ class SEPADirectDebit(PaymentMethod):
|
|||
)
|
||||
sepa = sepa.Document(
|
||||
E.CstmrDrctDbtInitn(
|
||||
self._get_header(context),
|
||||
cls.get_header(context, process),
|
||||
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.NbOfTxs(context['num_transactions']), # Number of Transactions
|
||||
E.CtrlSum(context['total']), # Control Sum
|
||||
|
@ -139,14 +147,16 @@ class SEPADirectDebit(PaymentMethod):
|
|||
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
|
||||
self._process_xml(sepa, 'pain.008.001.02.xsd', file_name)
|
||||
file_name = 'direct-debit-%i.xml' % process.id
|
||||
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 {
|
||||
'name': settings.PAYMENTS_DD_CREDITOR_NAME,
|
||||
'iban': settings.PAYMENTS_DD_CREDITOR_IBAN,
|
||||
|
@ -157,9 +167,10 @@ class SEPADirectDebit(PaymentMethod):
|
|||
'num_transactions': str(len(transactions)),
|
||||
}
|
||||
|
||||
def _get_debt_transactions(self, transactions):
|
||||
@classmethod
|
||||
def get_debt_transactions(cls, transactions, process):
|
||||
for transaction in transactions:
|
||||
transaction.process = self.process
|
||||
transaction.process = process
|
||||
account = transaction.account
|
||||
data = transaction.source.data
|
||||
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:
|
||||
transaction.process = self.process
|
||||
transaction.process = process
|
||||
account = transaction.account
|
||||
data = transaction.source.data
|
||||
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
|
||||
E.MsgId(str(self.process.id)), # Message Id
|
||||
E.MsgId(str(process.id)), # Message Id
|
||||
E.CreDtTm( # Creation Date Time
|
||||
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
|
||||
path = os.path.dirname(os.path.realpath(__file__))
|
||||
xsd_path = os.path.join(path, xsd)
|
||||
|
@ -259,9 +273,9 @@ class SEPADirectDebit(PaymentMethod):
|
|||
schema = etree.XMLSchema(schema_doc)
|
||||
sepa = etree.parse(StringIO(etree.tostring(sepa)))
|
||||
schema.assertValid(sepa)
|
||||
self.process.file = file_name
|
||||
self.process.save()
|
||||
sepa.write(self.process.file.path,
|
||||
process.file = file_name
|
||||
process.save()
|
||||
sepa.write(process.file.path,
|
||||
pretty_print=True,
|
||||
xml_declaration=True,
|
||||
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',)
|
||||
|
||||
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):
|
||||
|
@ -78,6 +82,10 @@ class MonitorDataAdmin(admin.ModelAdmin):
|
|||
readonly_fields = ('content_object_link',)
|
||||
|
||||
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)
|
||||
|
|
|
@ -47,4 +47,3 @@ class UserChangeForm(forms.ModelForm):
|
|||
# This is done here, rather than on the field, because the
|
||||
# field does not have access to the initial value
|
||||
return self.initial["password"]
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ class User(auth.AbstractBaseUser):
|
|||
validators=[validators.RegexValidator(r'^[\w.-]+$',
|
||||
_("Enter a valid username."), 'invalid')])
|
||||
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)
|
||||
last_name = models.CharField(_("last name"), max_length=30, blank=True)
|
||||
email = models.EmailField(_('email address'), blank=True)
|
||||
|
|
|
@ -13,10 +13,10 @@ class Register(object):
|
|||
def register(self, name, model):
|
||||
if name in self._registry:
|
||||
raise KeyError("%s already registered" % name)
|
||||
def has_role(user):
|
||||
def has_role(user, model=model):
|
||||
try:
|
||||
getattr(user, name)
|
||||
except models.DoesNotExist:
|
||||
except model.DoesNotExist:
|
||||
return False
|
||||
return True
|
||||
setattr(User, 'has_%s' % name, has_role)
|
||||
|
|
|
@ -144,7 +144,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
|
|||
'orchestra.apps.contacts.models.Contact',
|
||||
'orchestra.apps.users.models.User',
|
||||
'orchestra.apps.orders.models.Order',
|
||||
'orchestra.apps.orders.models.Pack',
|
||||
'orchestra.apps.orders.models.Plan',
|
||||
'orchestra.apps.bills.models.Bill',
|
||||
# 'orchestra.apps.payments.models.PaymentSource',
|
||||
'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 orchestra import get_version
|
||||
from orchestra.admin.utils import admin_change_url
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
@ -41,6 +42,11 @@ def size(value, length):
|
|||
return str(value) + (' '*num_spaces)
|
||||
|
||||
|
||||
@register.filter(name='is_checkbox')
|
||||
@register.filter
|
||||
def is_checkbox(field):
|
||||
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 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.username = 'orchestra'
|
||||
self.password = 'orchestra'
|
||||
self.user = User.objects.create_superuser(username='orchestra', password='orchestra',
|
||||
email='orchestra@orchestra.org', account=self.account)
|
||||
self.user = User.objects.create_superuser(username='orchestra',
|
||||
password='orchestra', email='orchestra@orchestra.org',
|
||||
account=self.account)
|
||||
|
||||
def admin_login(self):
|
||||
session = SessionStore()
|
||||
|
|
Loading…
Reference in a new issue