diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py index d0d507ee..cc4d760d 100644 --- a/orchestra/apps/accounts/admin.py +++ b/orchestra/apps/accounts/admin.py @@ -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 '%s' % (url, account.name) + pk = account.pk + return '%s' % (url, str(account)) account_link.short_description = _("account") account_link.allow_tags = True account_link.admin_order_field = 'account__user__username' diff --git a/orchestra/apps/accounts/models.py b/orchestra/apps/accounts/models.py index ecc4c52d..a35d9b02 100644 --- a/orchestra/apps/accounts/models.py +++ b/orchestra/apps/accounts/models.py @@ -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): diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index b29c8403..47012c3d 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -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) diff --git a/orchestra/apps/databases/backends.py b/orchestra/apps/databases/backends.py index 5606e657..5579adb5 100644 --- a/orchestra/apps/databases/backends.py +++ b/orchestra/apps/databases/backends.py @@ -73,7 +73,7 @@ class MySQLPermissionBackend(ServiceController): class MysqlDisk(ServiceMonitor): - model = 'database.Database' + model = 'databases.Database' verbose_name = _("MySQL disk") def exceeded(self, db): diff --git a/orchestra/apps/orders/actions.py b/orchestra/apps/orders/actions.py index b4a053f1..964a9e47 100644 --- a/orchestra/apps/orders/actions.py +++ b/orchestra/apps/orders/actions.py @@ -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: diff --git a/orchestra/apps/orders/forms.py b/orchestra/apps/orders/forms.py index 69fe43bf..a26465cb 100644 --- a/orchestra/apps/orders/forms.py +++ b/orchestra/apps/orders/forms.py @@ -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()) diff --git a/orchestra/apps/orders/handlers.py b/orchestra/apps/orders/handlers.py index 31c57f14..3c8506bb 100644 --- a/orchestra/apps/orders/handlers.py +++ b/orchestra/apps/orders/handlers.py @@ -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) diff --git a/orchestra/apps/orders/helpers.py b/orchestra/apps/orders/helpers.py index e2617b5e..bb4b1b2e 100644 --- a/orchestra/apps/orders/helpers.py +++ b/orchestra/apps/orders/helpers.py @@ -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 diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index 1525bb1c..c0850195 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -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."), @@ -320,8 +323,39 @@ class OrderQuerySet(models.QuerySet): else: bills += [(account, bill_lines)] return bills + + 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): + 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: diff --git a/orchestra/apps/orders/tests/functional_tests/tests.py b/orchestra/apps/orders/tests/functional_tests/tests.py index f47349c2..b3924949 100644 --- a/orchestra/apps/orders/tests/functional_tests/tests.py +++ b/orchestra/apps/orders/tests/functional_tests/tests.py @@ -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): @@ -62,24 +60,177 @@ class OrderTests(BaseTestCase): # 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): + + 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) diff --git a/orchestra/apps/payments/methods/sepadirectdebit.py b/orchestra/apps/payments/methods/sepadirectdebit.py index e243118b..88cf1aff 100644 --- a/orchestra/apps/payments/methods/sepadirectdebit.py +++ b/orchestra/apps/payments/methods/sepadirectdebit.py @@ -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', @@ -75,12 +76,12 @@ class SEPADirectDebit(PaymentMethod): E.CstmrCdtTrfInitn( cls.get_header(context), E.PmtInf( # Payment Info - E.PmtInfId(str(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 - E.ReqdExctnDt ( # Requested Execution Date - context['now'].strftime("%Y-%m-%d") + E.ReqdExctnDt( # Requested Execution Date + (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', @@ -118,7 +120,7 @@ class SEPADirectDebit(PaymentMethod): E.CstmrDrctDbtInitn( cls.get_header(context, process), E.PmtInf( # Payment Info - E.PmtInfId(str(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