This commit is contained in:
Marc 2014-09-14 09:52:45 +00:00
parent f6045869ac
commit fba8dac8f5
11 changed files with 384 additions and 37 deletions

View File

@ -137,7 +137,8 @@ class AccountAdminMixin(object):
def account_link(self, instance):
account = instance.account if instance.pk else self.account
url = reverse('admin:accounts_account_change', args=(account.pk,))
return '<a href="%s">%s</a>' % (url, account.name)
pk = account.pk
return '<a href="%s">%s</a>' % (url, str(account))
account_link.short_description = _("account")
account_link.allow_tags = True
account_link.admin_order_field = 'account__user__username'

View File

@ -10,6 +10,7 @@ from . import settings
class Account(models.Model):
# Users depends on Accounts (think about what should happen when you delete an account)
user = models.OneToOneField(djsettings.AUTH_USER_MODEL,
verbose_name=_("user"), related_name='accounts', null=True)
type = models.CharField(_("type"), choices=settings.ACCOUNTS_TYPES,
@ -24,9 +25,9 @@ class Account(models.Model):
def __unicode__(self):
return self.name
@cached_property
@property
def name(self):
return self.user.username
return self.user.username if self.user_id else str(self.pk)
@classmethod
def get_main(cls):

View File

@ -220,6 +220,9 @@ class BillLine(models.Model):
amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2)
total = models.DecimalField(_("total"), max_digits=12, decimal_places=2)
tax = models.PositiveIntegerField(_("tax"))
# TODO
# order_id = models.ForeignKey('orders.Order', null=True, blank=True,
# help_text=_("Informative link back to the order"))
amended_line = models.ForeignKey('self', verbose_name=_("amended line"),
related_name='amendment_lines', null=True, blank=True)

View File

@ -73,7 +73,7 @@ class MySQLPermissionBackend(ServiceController):
class MysqlDisk(ServiceMonitor):
model = 'database.Database'
model = 'databases.Database'
verbose_name = _("MySQL disk")
def exceeded(self, db):

View File

@ -53,6 +53,8 @@ class BillSelectedOrders(object):
def select_related(self, request):
related = self.queryset.get_related().select_related('account__user', 'service')
if not related:
return self.confirmation(request)
self.options['related_queryset'] = related
form = BillSelectRelatedForm(initial=self.options)
if int(request.POST.get('step')) >= 2:

View File

@ -39,7 +39,12 @@ def selected_related_choices(queryset):
class BillSelectRelatedForm(AdminFormMixin, forms.Form):
selected_related = forms.ModelMultipleChoiceField(label=_("Related"),
# This doesn't work well with reordering after billing
# pricing_with_all = forms.BooleanField(label=_("Do pricing with all orders"),
# initial=False, required=False, help_text=_("The price may vary "
# "depending on the billed orders. This options designates whether "
# "all existing orders will be used for price computation or not."))
selected_related = forms.ModelMultipleChoiceField(label=_("Related orders"),
queryset=Order.objects.none(), widget=forms.CheckboxSelectMultiple,
required=False)
billing_point = forms.DateField(widget=forms.HiddenInput())

View File

@ -10,8 +10,7 @@ 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
from . import settings, helpers
class ServiceHandler(plugins.Plugin):
@ -138,9 +137,9 @@ class ServiceHandler(plugins.Plugin):
).filter(registered_on__lt=end).order_by('registered_on')
price = 0
if self.orders_effect == self.REGISTER_OR_RENEW:
events = get_register_or_renew_events(porders, order, ini, end)
events = helpers.get_register_or_renew_events(porders, order, ini, end)
elif self.orders_effect == self.CONCURRENT:
events = get_register_or_cancel_events(porders, order, ini, end)
events = helpers.get_register_or_cancel_events(porders, order, ini, end)
else:
raise NotImplementedError
for metric, position, ratio in events:
@ -171,6 +170,68 @@ class ServiceHandler(plugins.Plugin):
'discounts': discounts,
})
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:
# ini >= registered_date, end < registered_date
# TODO Perform compensations on cancelled services
if self.on_cancel in (self.COMPENSATE, self.REFOUND):
pass
# TODO compensations with commit=False, fuck commit or just fuck the transaction?
# compensate(orders, **options)
# TODO create discount per compensation
bp = None
lines = []
commit = options.get('commit', True)
ini = datetime.date.max
end = datetime.date.ini
# boundary lookup
for order in orders:
cini = order.registered_on
if order.billed_until:
cini = order.billed_until
bp = self.get_billing_point(order, bp=bp, **options)
order.new_billed_until = bp
ini = min(ini, cini)
end = max(end, bp) # TODO if all bp are the same ...
porders = orders.pricing_orders(ini=ini, end=end)
porders.sort(cmp=helpers.cmp_billed_until_or_registered_on)
# Compensation
compensations = []
receivers = []
for order in porders:
if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until:
compensations.append[Interval(order.cancelled_on, order.billed_until, order)]
orders.sort(cmp=helpers.cmp_billed_until_or_registered_on)
for order in orders:
order_interval = Interval(order.billed_until or order.registered_on, order.new_billed_until)
helpers.compensate(order_interval, compensations)
def get_chunks(self, porders, ini, end, ix=0):
if ix >= len(porders):
return [[ini, end, []]]
order = porders[ix]
ix += 1
bu = getattr(order, 'new_billed_until', order.billed_until)
if not bu or bu <= ini or order.registered_on >= end:
return self.get_chunks(porders, ini, end, ix=ix)
result = []
if order.registered_on < end and order.registered_on > ini:
ro = order.registered_on
result = self.get_chunks(porders, ini, ro, ix=ix)
ini = ro
if bu < end:
result += self.get_chunks(porders, bu, end, ix=ix)
end = bu
chunks = self.get_chunks(porders, ini, end, ix=ix)
for chunk in chunks:
chunk[2].insert(0, order)
result.append(chunk)
return result
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)

View File

@ -88,3 +88,89 @@ def get_register_or_renew_events(handler, porders, order, ini, end):
elif porder.billed_until > send or porder.cancelled_on > send:
counter += 1
yield counter, position, (send-sini)/total
def cmp_billed_until_or_registered_on(a, b):
"""
1) billed_until greater first
2) registered_on smaller first
"""
if a.billed_until == b.billed_until:
return (a.registered_on-b.registered_on).days
elif a.billed_until and b.billed_until:
return (b.billed_until-a.billed_until).days
elif a.billed_until:
return (b.registered_on-a.billed_until).days
return (b.billed_until-a.registered_on).days
class Interval(object):
def __init__(self, ini, end, order=None):
self.ini = ini
self.end = end
self.order = order
def __len__(self):
return max((self.end-self.ini).days, 0)
def __sub__(self, other):
remaining = []
if self.ini < other.ini:
remaining.append(Interval(self.ini, min(self.end, other.ini)))
if self.end > other.end:
remaining.append(Interval(max(self.ini,other.end), self.end))
return remaining
def __repr__(self):
return "Start: %s End: %s" % (self.ini, self.end)
def intersect(self, other, remaining_self=None, remaining_other=None):
if remaining_self is not None:
remaining_self += (self - other)
if remaining_other is not None:
remaining_other += (other - self)
result = Interval(max(self.ini, other.ini), min(self.end, other.end))
if len(result)>0:
return result
else:
return None
def get_intersections(order, compensations):
intersections = []
for compensation in compensations:
intersection = compensation.intersect(order)
if intersection:
intersections.append((len(intersection), intersection))
return intersections
# Intervals should not overlap
def intersect(compensation, order_intervals):
compensated = []
not_compensated = []
unused_compensation = []
for interval in order_intervals:
compensated.append(compensation.intersect(interval, unused_compensation, not_compensated))
return (compensated, not_compensated, unused_compensation)
def update_intersections(not_compensated, compensations):
intersections = []
for (_,compensation) in compensations:
intersections += get_intersections(compensation, not_compensated)
return intersections
def compensate(order, compensations):
intersections = get_intersections(order, compensations)
not_compensated = [order]
result = []
while intersections:
# Apply the biggest intersection
intersections.sort(reverse=True)
(_,intersection) = intersections.pop()
(compensated, not_compensated, unused_compensation) = intersect(intersection, not_compensated)
# Reorder de intersections:
intersections = update_intersections(not_compensated, intersections)
result += compensated
return result

View File

@ -152,6 +152,9 @@ class Service(models.Model):
(MATCH_PRICE, _("Match price")),
),
default=BEST_PRICE)
# TODO remove since it can be infered from pricing period?
# VARIABLE -> REGISTER_OR_RENEW
# FIXED -> CONCURRENT
orders_effect = models.CharField(_("orders effect"), max_length=16,
help_text=_("Defines the lookup behaviour when using orders for "
"the pricing rate computation of this service."),
@ -321,7 +324,38 @@ class OrderQuerySet(models.QuerySet):
bills += [(account, bill_lines)]
return bills
def get_related(self):
def pricing_effect(self, ini=None, end=None, **options):
# TODO register but not billed duscard
if not ini:
for cini, ro in self.values_list('billed_until', 'registered_on'):
if not cini:
cini = ro
if not ini:
ini = cini
ini = min(ini, cini)
if not end:
order = self.first()
if order:
service = order.service
service.billing_point == service.FIXED_DATE
end = service.handler.get_billing_point(order, **options)
else:
pass
return self.exclude(
cancelled_on__isnull=False, billed_until__isnull=False,
cancelled_on__lte=F('billed_until'), billed_until__lte=ini,
registered_on__gte=end)
def get_related(self, ini=None, end=None):
if not ini:
ini = ''
if not end:
end = ''
return self.pricing_effect().filter(
Q(billed_until__isnull=False, billed_until__lt=end) |
Q(billed_until__isnull=True, registered_on__lt=end))
# TODO iterate over every order, calculate its billing point and find related
qs = self.exclude(cancelled_on__isnull=False,
billed_until__gte=F('cancelled_on')).distinct()
original_ids = self.values_list('id', flat=True)
@ -439,7 +473,8 @@ class MetricStorage(models.Model):
except cls.DoesNotExist:
return 0
# TODO If this happens to be very costly then, consider an additional
# implementation when runnning within a request/Response cycle, more efficient :)
@receiver(pre_delete, dispatch_uid="orders.cancel_orders")
def cancel_orders(sender, **kwargs):
if sender in services:

View File

@ -6,10 +6,11 @@ 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 orchestra.utils.tests import BaseTestCase, random_ascii
from ... import settings
from ...models import Service
from ...helpers import cmp_billed_until_or_registered_on
from ...models import Service, Order
class OrderTests(BaseTestCase):
@ -51,10 +52,7 @@ class OrderTests(BaseTestCase):
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)
self.account = self.create_account()
return service
# def test_ftp_account_1_year_fiexed(self):
@ -63,23 +61,176 @@ class OrderTests(BaseTestCase):
# 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):
def create_ftp(self):
username = '%s_ftp' % random_ascii(10)
user = User.objects.create_user(username=username, account=self.account)
POSIX = user._meta.get_field_by_name('posix')[0].model
POSIX.objects.create(user=user)
return user
def atest_get_chunks(self):
service = self.create_service()
handler = service.handler
porders = []
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
ct = ContentType.objects.get_for_model(User)
ftp = self.create_ftp()
order = Order.objects.get(content_type=ct, object_id=ftp.pk)
porders.append(order)
end = handler.get_billing_point(order).date()
chunks = handler.get_chunks(porders, now, end)
self.assertEqual(1, len(chunks))
self.assertIn([now, end, []], chunks)
ftp = self.create_ftp()
order1 = Order.objects.get(content_type=ct, object_id=ftp.pk)
order1.billed_until = now+datetime.timedelta(days=2)
porders.append(order1)
chunks = handler.get_chunks(porders, now, end)
self.assertEqual(2, len(chunks))
self.assertIn([order1.registered_on, order1.billed_until, [order1]], chunks)
self.assertIn([order1.billed_until, end, []], chunks)
ftp = self.create_ftp()
order2 = Order.objects.get(content_type=ct, object_id=ftp.pk)
order2.billed_until = now+datetime.timedelta(days=700)
porders.append(order2)
chunks = handler.get_chunks(porders, now, end)
self.assertEqual(2, len(chunks))
self.assertIn([order.registered_on, order1.billed_until, [order1, order2]], chunks)
self.assertIn([order1.billed_until, end, [order2]], chunks)
ftp = self.create_ftp()
order3 = Order.objects.get(content_type=ct, object_id=ftp.pk)
order3.billed_until = now+datetime.timedelta(days=700)
porders.append(order3)
chunks = handler.get_chunks(porders, now, end)
self.assertEqual(2, len(chunks))
self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks)
self.assertIn([order1.billed_until, end, [order2, order3]], chunks)
ftp = self.create_ftp()
order4 = Order.objects.get(content_type=ct, object_id=ftp.pk)
order4.registered_on = now+datetime.timedelta(days=5)
order4.billed_until = now+datetime.timedelta(days=10)
porders.append(order4)
chunks = handler.get_chunks(porders, now, end)
self.assertEqual(4, len(chunks))
self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks)
self.assertIn([order1.billed_until, order4.registered_on, [order2, order3]], chunks)
self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks)
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
ftp = self.create_ftp()
order5 = Order.objects.get(content_type=ct, object_id=ftp.pk)
order5.registered_on = now+datetime.timedelta(days=700)
order5.billed_until = now+datetime.timedelta(days=780)
porders.append(order5)
chunks = handler.get_chunks(porders, now, end)
self.assertEqual(4, len(chunks))
self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks)
self.assertIn([order1.billed_until, order4.registered_on, [order2, order3]], chunks)
self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks)
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
ftp = self.create_ftp()
order6 = Order.objects.get(content_type=ct, object_id=ftp.pk)
order6.registered_on = now-datetime.timedelta(days=780)
order6.billed_until = now-datetime.timedelta(days=700)
porders.append(order6)
chunks = handler.get_chunks(porders, now, end)
self.assertEqual(4, len(chunks))
self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks)
self.assertIn([order1.billed_until, order4.registered_on, [order2, order3]], chunks)
self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks)
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
def atest_sort_billed_until_or_registered_on(self):
service = self.create_service()
now = timezone.now()
order = Order(
service=service,
registered_on=now,
billed_until=now+datetime.timedelta(days=200))
order1 = Order(
service=service,
registered_on=now+datetime.timedelta(days=5),
billed_until=now+datetime.timedelta(days=200))
order2 = Order(
service=service,
registered_on=now+datetime.timedelta(days=6),
billed_until=now+datetime.timedelta(days=200))
order3 = Order(
service=service,
registered_on=now+datetime.timedelta(days=6),
billed_until=now+datetime.timedelta(days=201))
order4 = Order(
service=service,
registered_on=now+datetime.timedelta(days=6))
order5 = Order(
service=service,
registered_on=now+datetime.timedelta(days=7))
order6 = Order(
service=service,
registered_on=now+datetime.timedelta(days=8))
orders = [order3, order, order1, order2, order4, order5, order6]
self.assertEqual(orders, sorted(orders, cmp=cmp_billed_until_or_registered_on))
def test_compensation(self):
now = timezone.now()
order = Order(
registered_on=now,
billed_until=now+datetime.timedelta(days=200),
cancelled_on=now+datetime.timedelta(days=100))
order1 = Order(
registered_on=now+datetime.timedelta(days=5),
cancelled_on=now+datetime.timedelta(days=190),
billed_until=now+datetime.timedelta(days=200))
order2 = Order(
registered_on=now+datetime.timedelta(days=6),
cancelled_on=now+datetime.timedelta(days=200),
billed_until=now+datetime.timedelta(days=200))
order3 = Order(
registered_on=now+datetime.timedelta(days=6),
billed_until=now+datetime.timedelta(days=200))
order4 = Order(
registered_on=now+datetime.timedelta(days=6))
order5 = Order(
registered_on=now+datetime.timedelta(days=7))
order6 = Order(
registered_on=now+datetime.timedelta(days=8))
porders = [order3, order, order1, order2, order4, order5, order6]
porders = sorted(porders, cmp=cmp_billed_until_or_registered_on)
service = self.create_service()
compensations = []
from ... import helpers
for order in porders:
if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until:
compensations.append(helpers.Interval(order.cancelled_on, order.billed_until, order=order))
for order in porders:
bp = service.handler.get_billing_point(order)
order_interval = helpers.Interval(order.billed_until or order.registered_on, bp)
print helpers.compensate(order_interval, compensations)
# def test_ftp_account_1_year_fiexed(self):
# service = self.create_service()
# now = timezone.now().date()etb
# 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)

View File

@ -65,6 +65,7 @@ class SEPADirectDebit(PaymentMethod):
from ..models import TransactionProcess
process = TransactionProcess.objects.create()
context = cls.get_context(transactions)
# http://businessbanking.bankofireland.com/fs/doc/wysiwyg/b22440-mss130725-pain001-xml-file-structure-dec13.pdf
sepa = lxml.builder.ElementMaker(
nsmap = {
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
@ -80,7 +81,7 @@ class SEPADirectDebit(PaymentMethod):
E.NbOfTxs(context['num_transactions']), # Number of Transactions
E.CtrlSum(context['total']), # Control Sum
E.ReqdExctnDt( # Requested Execution Date
context['now'].strftime("%Y-%m-%d")
(context['now']+datetime.timedelta(days=10)).strftime("%Y-%m-%d")
),
E.Dbtr( # Debtor
E.Nm(context['name'])
@ -108,6 +109,7 @@ class SEPADirectDebit(PaymentMethod):
from ..models import TransactionProcess
process = TransactionProcess.objects.create()
context = cls.get_context(transactions)
# http://businessbanking.bankofireland.com/fs/doc/wysiwyg/sepa-direct-debit-pain-008-001-02-xml-file-structure-july-2013.pdf
sepa = lxml.builder.ElementMaker(
nsmap = {
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',