diff --git a/TODO.md b/TODO.md index 2072087f..a318bc48 100644 --- a/TODO.md +++ b/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 diff --git a/orchestra/admin/options.py b/orchestra/admin/options.py index 714e74f0..ab99940c 100644 --- a/orchestra/admin/options.py +++ b/orchestra/admin/options.py @@ -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) diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py index 038169e2..6b542306 100644 --- a/orchestra/admin/utils.py +++ b/orchestra/admin/utils.py @@ -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 '{1}'.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) diff --git a/orchestra/apps/accounts/admin.py b/orchestra/apps/accounts/admin.py index dfd50ee5..d0d507ee 100644 --- a/orchestra/apps/accounts/admin.py +++ b/orchestra/apps/accounts/admin.py @@ -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) diff --git a/orchestra/apps/accounts/migrations/0001_initial.py b/orchestra/apps/accounts/migrations/0001_initial.py new file mode 100644 index 00000000..759b6e70 --- /dev/null +++ b/orchestra/apps/accounts/migrations/0001_initial.py @@ -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,), + ), + ] diff --git a/orchestra/apps/accounts/migrations/0002_auto_20140909_1850.py b/orchestra/apps/accounts/migrations/0002_auto_20140909_1850.py new file mode 100644 index 00000000..c7f11cde --- /dev/null +++ b/orchestra/apps/accounts/migrations/0002_auto_20140909_1850.py @@ -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), + ), + ] diff --git a/orchestra/apps/accounts/migrations/__init__.py b/orchestra/apps/accounts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/accounts/models.py b/orchestra/apps/accounts/models.py index a0c7ce80..ecc4c52d 100644 --- a/orchestra/apps/accounts/models.py +++ b/orchestra/apps/accounts/models.py @@ -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, diff --git a/orchestra/apps/bills/actions.py b/orchestra/apps/bills/actions.py index cc78921b..06418dbc 100644 --- a/orchestra/apps/bills/actions.py +++ b/orchestra/apps/bills/actions.py @@ -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") diff --git a/orchestra/apps/bills/admin.py b/orchestra/apps/bills/admin.py index 9bf70ac7..97a03e09 100644 --- a/orchestra/apps/bills/admin.py +++ b/orchestra/apps/bills/admin.py @@ -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: diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index b3f0b9d6..47f2b7d4 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -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) diff --git a/orchestra/apps/contacts/settings.py b/orchestra/apps/contacts/settings.py index c3632554..96ac58c4 100644 --- a/orchestra/apps/contacts/settings.py +++ b/orchestra/apps/contacts/settings.py @@ -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') ) diff --git a/orchestra/apps/domains/tests/functional_tests/tests.py b/orchestra/apps/domains/tests/functional_tests/tests.py index b9d341d9..b0681725 100644 --- a/orchestra/apps/domains/tests/functional_tests/tests.py +++ b/orchestra/apps/domains/tests/functional_tests/tests.py @@ -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) diff --git a/orchestra/apps/orchestration/admin.py b/orchestra/apps/orchestration/admin.py index bff2b61c..db1632ae 100644 --- a/orchestra/apps/orchestration/admin.py +++ b/orchestra/apps/orchestration/admin.py @@ -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): diff --git a/orchestra/apps/orchestration/methods.py b/orchestra/apps/orchestration/methods.py index fce32c95..accee364 100644 --- a/orchestra/apps/orchestration/methods.py +++ b/orchestra/apps/orchestration/methods.py @@ -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() diff --git a/orchestra/apps/orchestration/models.py b/orchestra/apps/orchestration/models.py index e5ea04a7..c990a406 100644 --- a/orchestra/apps/orchestration/models.py +++ b/orchestra/apps/orchestration/models.py @@ -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: diff --git a/orchestra/apps/orders/actions.py b/orchestra/apps/orders/actions.py index 876f3b8a..cc41174c 100644 --- a/orchestra/apps/orders/actions.py +++ b/orchestra/apps/orders/actions.py @@ -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 = '%(num)s %(bills)s %(msg)s' % context + url += '?id__in=%s' % ids + num = len(bills) + msg = ungettext( + 'One bill has been created.', + '{num} bills 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) diff --git a/orchestra/apps/orders/billing.py b/orchestra/apps/orders/billing.py index c344e787..d0f09b72 100644 --- a/orchestra/apps/orders/billing.py +++ b/orchestra/apps/orders/billing.py @@ -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, ) diff --git a/orchestra/apps/orders/forms.py b/orchestra/apps/orders/forms.py index bd0275b9..f4fd970e 100644 --- a/orchestra/apps/orders/forms.py +++ b/orchestra/apps/orders/forms.py @@ -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 = '{description} ' + verbose += '{account}' + 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) diff --git a/orchestra/apps/orders/handlers.py b/orchestra/apps/orders/handlers.py index 9810107b..31c57f14 100644 --- a/orchestra/apps/orders/handlers.py +++ b/orchestra/apps/orders/handlers.py @@ -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): diff --git a/orchestra/apps/orders/helpers.py b/orchestra/apps/orders/helpers.py index 233f56b9..e2617b5e 100644 --- a/orchestra/apps/orders/helpers.py +++ b/orchestra/apps/orders/helpers.py @@ -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) diff --git a/orchestra/apps/orders/migrations/0004_auto_20140909_1426.py b/orchestra/apps/orders/migrations/0004_auto_20140909_1426.py new file mode 100644 index 00000000..b578afee --- /dev/null +++ b/orchestra/apps/orders/migrations/0004_auto_20140909_1426.py @@ -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')]), + ), + ] diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index b35793b3..6461b036 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -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 diff --git a/orchestra/apps/orders/pricing.py b/orchestra/apps/orders/pricing.py index 1391deb9..86ecd195 100644 --- a/orchestra/apps/orders/pricing.py +++ b/orchestra/apps/orders/pricing.py @@ -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 }] diff --git a/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html b/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html index f1383f53..022f3fc4 100644 --- a/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html +++ b/orchestra/apps/orders/templates/admin/orders/order/bill_selected_options.html @@ -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 }} + {% endblock %} + {% block breadcrumbs %}