From f4c8ca06ca730c60c16e73b2faec332d0f474262 Mon Sep 17 00:00:00 2001 From: Marc Date: Wed, 3 Sep 2014 13:56:02 +0000 Subject: [PATCH] Finished billing prototype --- orchestra/apps/bills/actions.py | 2 +- orchestra/apps/bills/admin.py | 46 +++-- orchestra/apps/bills/models.py | 32 +++- .../bills/templates/bills/microspective.html | 20 +- orchestra/apps/contacts/admin.py | 2 +- orchestra/apps/orders/actions.py | 2 +- orchestra/apps/orders/admin.py | 5 +- orchestra/apps/orders/backends.py | 42 +++++ orchestra/apps/orders/handlers.py | 172 ++++++++++++------ orchestra/apps/orders/helpers.py | 11 +- orchestra/apps/orders/models.py | 50 +++-- orchestra/bin/orchestra-admin | 2 +- orchestra/utils/humanize.py | 44 +++-- 13 files changed, 308 insertions(+), 122 deletions(-) create mode 100644 orchestra/apps/orders/backends.py diff --git a/orchestra/apps/bills/actions.py b/orchestra/apps/bills/actions.py index 414ba6c8..5161f2c3 100644 --- a/orchestra/apps/bills/actions.py +++ b/orchestra/apps/bills/actions.py @@ -6,7 +6,7 @@ from orchestra.utils.system import run def generate_bill(modeladmin, request, queryset): bill = queryset.get() bill.close() -# return HttpResponse(bill.html) + return HttpResponse(bill.html) pdf = run('xvfb-run -a -s "-screen 0 640x4800x16" ' 'wkhtmltopdf --footer-center "Page [page] of [topage]" --footer-font-size 9 - -', stdin=bill.html.encode('utf-8'), display=False) diff --git a/orchestra/apps/bills/admin.py b/orchestra/apps/bills/admin.py index dcba6b74..643ed8af 100644 --- a/orchestra/apps/bills/admin.py +++ b/orchestra/apps/bills/admin.py @@ -1,12 +1,14 @@ from django import forms from django.contrib import admin from django.core.urlresolvers import reverse +from django.db import models from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin from orchestra.admin.utils import admin_link, admin_date from orchestra.apps.accounts.admin import AccountAdminMixin +from . import settings from .actions import generate_bill from .filters import BillTypeListFilter from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget, @@ -15,9 +17,16 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget, class BillLineInline(admin.TabularInline): model = BillLine - fields = ( - 'description', 'initial_date', 'final_date', 'price', 'amount', 'tax' - ) + 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 '' def get_readonly_fields(self, request, obj=None): if obj and obj.status != Bill.OPEN: @@ -37,21 +46,20 @@ class BillLineInline(admin.TabularInline): class BudgetLineInline(admin.TabularInline): model = Budget - fields = ( - 'description', 'initial_date', 'final_date', 'price', 'amount', 'tax' - ) + fields = ('description', 'rate', 'amount', 'tax', 'total') class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): list_display = ( - 'number', 'status', 'type_link', 'account_link', 'created_on_display' + 'number', 'status', 'type_link', 'account_link', 'created_on_display', + 'num_lines', 'display_total' ) list_filter = (BillTypeListFilter, 'status',) add_fields = ('account', 'type', 'status', 'due_on', 'comments') fieldsets = ( (None, { - 'fields': ('number', 'account_link', 'type', 'status', 'due_on', - 'comments'), + 'fields': ('number', 'display_total', 'account_link', 'type', + 'status', 'due_on', 'comments'), }), (_("Raw"), { 'classes': ('collapse',), @@ -60,11 +68,21 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): ) change_view_actions = [generate_bill] change_readonly_fields = ('account_link', 'type', 'status') - readonly_fields = ('number',) + readonly_fields = ('number', 'display_total') inlines = [BillLineInline] created_on_display = admin_date('created_on') + def num_lines(self, bill): + return bill.billlines__count + num_lines.admin_order_field = 'billlines__count' + num_lines.short_description = _("lines") + + def display_total(self, bill): + return "%i &%s;" % (bill.get_total(), settings.BILLS_CURRENCY.lower()) + display_total.allow_tags = True + display_total.short_description = _("total") + def type_link(self, bill): bill_type = bill.type.lower() url = reverse('admin:bills_%s_changelist' % bill_type) @@ -93,7 +111,13 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): if db_field.name == 'html': kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20}) return super(BillAdmin, self).formfield_for_dbfield(db_field, **kwargs) - + + def queryset(self, request): + qs = super(BillAdmin, self).queryset(request) + qs = qs.annotate(models.Count('billlines')) + qs = qs.prefetch_related('billlines', 'billlines__sublines') + return qs + admin.site.register(Bill, BillAdmin) admin.site.register(Invoice, BillAdmin) diff --git a/orchestra/apps/bills/models.py b/orchestra/apps/bills/models.py index a3895d7c..5f550530 100644 --- a/orchestra/apps/bills/models.py +++ b/orchestra/apps/bills/models.py @@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _ from orchestra.apps.accounts.models import Account from orchestra.core import accounts +from orchestra.utils.functional import cached from . import settings @@ -140,6 +141,25 @@ class Bill(models.Model): 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 + 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(): + subtotal, taxes = subtotal + total += subtotal + taxes + return total class Invoice(Bill): @@ -176,11 +196,11 @@ class BaseBillLine(models.Model): bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='%(class)ss') description = models.CharField(_("description"), max_length=256) - initial_date = models.DateTimeField() - final_date = models.DateTimeField() - price = models.DecimalField(max_digits=12, decimal_places=2) + rate = models.DecimalField(_("rate"), blank=True, null=True, + max_digits=12, decimal_places=2) amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2) - tax = models.DecimalField(_("tax"), max_digits=12, decimal_places=2) + total = models.DecimalField(_("total"), max_digits=12, decimal_places=2) + tax = models.PositiveIntegerField(_("tax")) class Meta: abstract = True @@ -188,7 +208,7 @@ class BaseBillLine(models.Model): def __unicode__(self): return "#%i" % self.number - @property + @cached_property def number(self): lines = type(self).objects.filter(bill=self.bill_id) return lines.filter(id__lte=self.id).order_by('id').count() @@ -207,7 +227,7 @@ class BillLine(BaseBillLine): related_name='amendment_lines', null=True, blank=True) -class SubBillLine(models.Model): +class BillSubline(models.Model): """ Subline used for describing an item discount """ bill_line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines') diff --git a/orchestra/apps/bills/templates/bills/microspective.html b/orchestra/apps/bills/templates/bills/microspective.html index f851d8aa..981cd10f 100644 --- a/orchestra/apps/bills/templates/bills/microspective.html +++ b/orchestra/apps/bills/templates/bills/microspective.html @@ -50,7 +50,7 @@
TOTAL
- {{ bill.total }} &{{ currency.lower }}; + {{ bill.get_total }} &{{ currency.lower }};
{{ bill.get_type_display.upper }} DATE
@@ -79,20 +79,22 @@ {{ line.description }} {{ line.amount|default:" " }} {% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %} {% endif %} - {{ line.price }} &{{ currency.lower }}; + {{ line.total }} &{{ currency.lower }};
{% endfor %}

 
- subtotal - {{ bill.subtotal }} &{{ currency.lower }}; -
- tax - {{ bill.taxes }} &{{ currency.lower }}; -
+ {% for tax, subtotal in bill.get_subtotals.iteritems %} + subtotal {{ tax }}% VAT + {{ subtotal | first }} &{{ currency.lower }}; +
+ taxes {{ tax }}% VAT + {{ subtotal | last }} &{{ currency.lower }}; +
+ {% endfor %} total - {{ bill.total }} &{{ currency.lower }}; + {{ bill.get_total }} &{{ currency.lower }};
{% endblock %} diff --git a/orchestra/apps/contacts/admin.py b/orchestra/apps/contacts/admin.py index 903622eb..96eba692 100644 --- a/orchestra/apps/contacts/admin.py +++ b/orchestra/apps/contacts/admin.py @@ -97,7 +97,7 @@ class ContactInline(InvoiceContactInline): def has_invoice(account): try: - account.invoicecontact.get() + account.invoicecontact except InvoiceContact.DoesNotExist: return False return True diff --git a/orchestra/apps/orders/actions.py b/orchestra/apps/orders/actions.py index b4310d56..876f3b8a 100644 --- a/orchestra/apps/orders/actions.py +++ b/orchestra/apps/orders/actions.py @@ -70,7 +70,7 @@ class BillSelectedOrders(object): msg = _("Selected orders do not have pending billing") self.modeladmin.message_user(request, msg, messages.WARNING) else: - ids = ','.join([bill.id for bill in bills]) + ids = ','.join([str(bill.id) for bill in bills]) url = reverse('admin:bills_bill_changelist') context = { 'url': url + '?id=%s' % ids, diff --git a/orchestra/apps/orders/admin.py b/orchestra/apps/orders/admin.py index 46e2487e..8923da9d 100644 --- a/orchestra/apps/orders/admin.py +++ b/orchestra/apps/orders/admin.py @@ -78,9 +78,9 @@ class ServiceAdmin(admin.ModelAdmin): class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin): list_display = ( 'id', 'service', 'account_link', 'content_object_link', - 'display_registered_on', 'display_cancelled_on' + 'display_registered_on', 'display_billed_until', 'display_cancelled_on' ) - list_display_link = ('id', 'service') + list_display_links = ('id', 'service') list_filter = (ActiveOrderListFilter, 'service',) actions = (BillSelectedOrders(),) date_hierarchy = 'registered_on' @@ -90,6 +90,7 @@ class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin): content_object_link = admin_link('content_object', order=False) display_registered_on = admin_date('registered_on') + display_billed_until = admin_date('billed_until') display_cancelled_on = admin_date('cancelled_on') def get_queryset(self, request): diff --git a/orchestra/apps/orders/backends.py b/orchestra/apps/orders/backends.py new file mode 100644 index 00000000..93bd7c9d --- /dev/null +++ b/orchestra/apps/orders/backends.py @@ -0,0 +1,42 @@ +import datetime + +from orchestra.apps.bills.models import Invoice, Fee, BillLine, BillSubline + + +class BillsBackend(object): + def create_bills(self, account, lines): + invoice = None + fees = [] + for order, nominal_price, size, ini, end, discounts in lines: + service = order.service + if service.is_fee: + fee = Fee.objects.get_or_create(account=account, status=Fee.OPEN) + line = fee.lines.create(rate=service.nominal_price, amount=size, + total=nominal_price, tax=0) + self.create_sublines(line, discounts) + fees.append(fee) + else: + if invoice is None: + invoice, __ = Invoice.objects.get_or_create(account=account, + status=Invoice.OPEN) + description = order.description + if service.billing_period != service.NEVER: + description += " {ini} to {end}".format( + ini=ini.strftime("%b, %Y"), + end=(end-datetime.timedelta(seconds=1)).strftime("%b, %Y")) + line = invoice.lines.create( + description=description, + rate=service.nominal_price, + amount=size, + total=nominal_price, + tax=service.tax, + ) + self.create_sublines(line, discounts) + return [invoice] + fees + + def create_sublines(self, line, discounts): + for name, value in discounts: + line.sublines.create( + description=_("Discount per %s") % name, + total=value, + ) diff --git a/orchestra/apps/orders/handlers.py b/orchestra/apps/orders/handlers.py index 096c4604..2c6fe2c2 100644 --- a/orchestra/apps/orders/handlers.py +++ b/orchestra/apps/orders/handlers.py @@ -1,6 +1,7 @@ import calendar +import datetime -from dateutil.relativedelta import relativedelta +from dateutil import relativedelta from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils import timezone @@ -8,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from orchestra.utils import plugins +from . import settings from .helpers import get_register_or_cancel_events, get_register_or_renew_events @@ -52,55 +54,59 @@ class ServiceHandler(plugins.Plugin): return eval(self.metric, safe_locals) def get_billing_point(self, order, bp=None, **options): - not_cachable = self.billing_point is self.FIXED_DATE and options.get('fixed_point') + not_cachable = self.billing_point == self.FIXED_DATE and options.get('fixed_point') if not_cachable or bp is None: bp = options.get('billing_point', timezone.now().date()) if not options.get('fixed_point'): - if self.billing_period is self.MONTHLY: + msg = ("Support for '%s' period and '%s' point is not implemented" + % (self.get_billing_period_display(), self.get_billing_point_display())) + if self.billing_period == self.MONTHLY: date = bp - if self.payment_style is self.PREPAY: - date += relativedelta(months=1) - if self.billing_point is self.ON_REGISTER: + if self.payment_style == self.PREPAY: + date += relativedelta.relativedelta(months=1) + if self.billing_point == self.ON_REGISTER: day = order.registered_on.day - elif self.billing_point is self.FIXED_DATE: + elif self.billing_point == self.FIXED_DATE: day = 1 + else: + raise NotImplementedError(msg) bp = datetime.datetime(year=date.year, month=date.month, day=day, tzinfo=timezone.get_current_timezone()) - elif self.billing_period is self.ANUAL: - if self.billing_point is self.ON_REGISTER: + elif self.billing_period == self.ANUAL: + if self.billing_point == self.ON_REGISTER: month = order.registered_on.month day = order.registered_on.day - elif self.billing_point is self.FIXED_DATE: + elif self.billing_point == self.FIXED_DATE: month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH day = 1 + else: + raise NotImplementedError(msg) year = bp.year - if self.payment_style is self.POSTPAY: - year = bo.year - relativedelta(years=1) + if self.payment_style == self.POSTPAY: + year = bo.year - relativedelta.relativedelta(years=1) if bp.month >= month: year = bp.year + 1 bp = datetime.datetime(year=year, month=month, day=day, tzinfo=timezone.get_current_timezone()) - elif self.billing_period is self.NEVER: + elif self.billing_period == self.NEVER: bp = order.registered_on else: - raise NotImplementedError( - "Support for '%s' billing period and '%s' billing point is not implemented" - % (self.display_billing_period(), self.display_billing_point()) - ) - if self.on_cancel is not self.NOTHING and order.cancelled_on < bp: + raise NotImplementedError(msg) + if self.on_cancel != self.NOTHING and order.cancelled_on and order.cancelled_on < bp: return order.cancelled_on return bp def get_pricing_size(self, ini, end): rdelta = relativedelta.relativedelta(end, ini) - if self.get_pricing_period() is self.MONTHLY: + if self.get_pricing_period() == self.MONTHLY: size = rdelta.months - days = calendar.monthrange(bp.year, bp.month)[1] - size += float(bp.day)/days - elif self.get_pricint_period() is self.ANUAL: + days = calendar.monthrange(end.year, end.month)[1] + size += float(rdelta.days)/days + elif self.get_pricing_period() == self.ANUAL: size = rdelta.years - size += float(rdelta.days)/365 - elif self.get_pricing_period() is self.NEVER: + days = 366 if calendar.isleap(end.year) else 365 + size += float((end-ini).days)/days + elif self.get_pricing_period() == self.NEVER: size = 1 else: raise NotImplementedError @@ -108,11 +114,11 @@ class ServiceHandler(plugins.Plugin): def get_pricing_slots(self, ini, end): period = self.get_pricing_period() - if period is self.MONTHLY: - rdelta = relativedelta(months=1) - elif period is self.ANUAL: - rdelta = relativedelta(years=1) - elif period is self.NEVER: + if period == self.MONTHLY: + rdelta = relativedelta.relativedelta(months=1) + elif period == self.ANUAL: + rdelta = relativedelta.relativedelta(years=1) + elif period == self.NEVER: yield ini, end raise StopIteration else: @@ -125,51 +131,107 @@ class ServiceHandler(plugins.Plugin): yield ini, next ini = next - def create_line(self, order, price, size): + def get_price_with_orders(self, order, size, ini, end): + porders = self.orders.filter(account=order.account).filter( + Q(cancelled_on__isnull=True) | Q(cancelled_on__gt=ini) + ).filter(registered_on__lt=end) + price = 0 + if self.orders_effect == self.REGISTER_OR_RENEW: + events = get_register_or_renew_events(porders, ini, end) + elif self.orders_effect == self.CONCURRENT: + events = get_register_or_cancel_events(porders, ini, end) + else: + raise NotImplementedError + for metric, ratio in events: + price += self.get_rate(order, metric) * size * ratio + return price + + def get_price_with_metric(self, order, size, ini, end): + metric = order.get_metric(ini, end) + price = self.get_rate(order, metric) * size + return price + + def create_line(self, order, price, size, ini, end): nominal_price = self.nominal_price * size + discounts = [] if nominal_price > price: - discount = 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) def create_bill_lines(self, orders, **options): - # Perform compensations on cancelled services - # TODO WTF to do with day 1 of each month. - if self.on_cancel in (Order.COMPENSATE, Order.REFOUND): + # 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) + # compensate(orders, **options) # TODO create discount per compensation bp = None lines = [] for order in orders: bp = self.get_billing_point(order, bp=bp, **options) ini = order.billed_until or order.registered_on - if bp < ini: + if bp <= ini: continue if not self.metric: # Number of orders metric; bill line per order - porders = service.orders.filter(account=order.account).filter( - Q(is_active=True) | Q(cancelled_on__gt=order.billed_until) - ).filter(registered_on__lt=bp) - price = 0 size = self.get_pricing_size(ini, bp) - if self.orders_effect is self.REGISTER_OR_RENEW: - events = get_register_or_renew_events(porders, ini, bp) - elif self.orders_effect is self.CONCURRENT: - events = get_register_or_cancel_events(porders, ini, bp) - else: - raise NotImplementedError - for metric, ratio in events: - price += self.get_rate(metric, account) * size * ratio - lines += self.create_line(order, price, size) + price = self.get_price_with_orders(order, size, ini, bp) + lines.append(self.create_line(order, price, size, ini, bp)) else: # weighted metric; bill line per pricing period for ini, end in self.get_pricing_slots(ini, bp): - metric = order.get_metric(ini, end) size = self.get_pricing_size(ini, end) - price = self.get_rate(metric, account) * size - lines += self.create_line(order, price, size) + price = self.get_price_with_metric(order, size, ini, end) + lines.append(self.create_line(order, price, size, ini, end)) + order.billed_until = bp + order.save() # TODO if commit return lines def compensate(self, orders): - # num orders and weights - # Discounts - pass + # TODO this compensation is a bit hard to write it propertly + # don't forget to think about weighted and num order prices. + # Greedy algorithm for maximizing discount (non-deterministic) + # Reduce and break orders in donors and receivers + donors = [] + receivers = [] + for order in orders: + if order.cancelled_on and order.billed_until > order.cancelled_on: + donors.append(order) + elif not order.cancelled_on or order.cancelled_on > order.billed_until: + receivers.append(order) + + # Assign weights to every donor-receiver combination + weights = [] + for donor in donors: + for receiver in receivers: + if receiver.cancelled_on: + if not receiver.cancelled_on or receiver.cancelled_on < donor.billed_until: + end = receiver.cancelled_on + else: + end = donor.billed_until + else: + end = donor.billed_until + ini = donor.billed_until or donor.registered_on + if donor.cancelled_on > ini: + ini = donor.cancelled_on + weight = (end-ini).days + weights.append((weight, ini, end, donor, receiver)) + + # Choose weightest pairs + choosen = [] + weights.sort(key=lambda n: n[0]) + for weight, ini, end, donor, receiver in weigths: + if donor not in choosen and receiver not in choosen: + choosen += [donor, receiver] + donor.billed_until = end + donor.save() + price = self.get_price()#TODO + receiver.__discount_per_compensation =None diff --git a/orchestra/apps/orders/helpers.py b/orchestra/apps/orders/helpers.py index 6fa6a287..91714b47 100644 --- a/orchestra/apps/orders/helpers.py +++ b/orchestra/apps/orders/helpers.py @@ -37,7 +37,7 @@ def get_related_objects(origin, max_depth=2): queue.append(new_models) def get_register_or_cancel_events(porders, ini, end): - assert ini > end, "ini > end" + assert ini <= end, "ini > end" CANCEL = 'cancel' REGISTER = 'register' changes = {} @@ -50,21 +50,22 @@ def get_register_or_cancel_events(porders, ini, end): if cancel > ini and cancel < end: changes.setdefault(cancel, []) changes[cancel].append(CANCEL) - if order.registered_on < ini: + if order.registered_on <= ini: counter += 1 elif order.registered_on < end: changes.setdefault(order.registered_on, []) changes[order.registered_on].append(REGISTER) pointer = ini total = float((end-ini).days) - for date in changes.keys().sort(): + for date in sorted(changes.keys()): + yield counter, (date-pointer).days/total for change in changes[date]: if change is CANCEL: counter -= 1 else: counter += 1 - yield counter, (date-pointer).days/total pointer = date + yield counter, (end-pointer).days/total def get_register_or_renew_events(handler, porders, ini, end): @@ -72,7 +73,7 @@ def get_register_or_renew_events(handler, porders, ini, end): for sini, send in handler.get_pricing_slots(ini, end): counter = 0 for order in porders: - if order.registered_on > sini and order.registered_on < send: + if order.registered_on >= sini and order.registered_on < send: counter += 1 elif order.billed_until > send or order.cancelled_on > send: counter += 1 diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index 584b7bbe..89de5229 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -84,7 +84,7 @@ class Service(models.Model): metric = models.CharField(_("metric"), max_length=256, blank=True, help_text=_("Metric used to compute the pricing rate. " "Number of orders is used when left blank.")) - tax = models.IntegerField(_("tax"), choices=settings.ORDERS_SERVICE_TAXES, + tax = models.PositiveIntegerField(_("tax"), choices=settings.ORDERS_SERVICE_TAXES, default=settings.ORDERS_SERVICE_DEFAUL_TAX) pricing_period = models.CharField(_("pricing period"), max_length=16, help_text=_("Period used for calculating the metric used on the " @@ -163,6 +163,11 @@ class Service(models.Model): ), default=NEVER) + @property + def nominal_price(self): + # FIXME delete and make it a model field + return 10 + def __unicode__(self): return self.description @@ -215,24 +220,29 @@ class Service(models.Model): msg = "{0} {1}: {2}".format(attr, name, message) raise ValidationError(msg) - def get_nominal_price(self, order): - """ returns the price of an item """ - + def get_pricing_period(self): + if self.pricing_period == self.BILLING_PERIOD: + return self.billing_period + return self.pricing_period - def get_price(self, order, amount='TODO'): - pass + def get_rate(self, order, metric): + # TODO implement + return 12 class OrderQuerySet(models.QuerySet): group_by = queryset.group_by def bill(self, **options): - for account, services in self.group_by('account_id', 'service_id'): + bills = [] + bill_backend = Order.get_bill_backend() + for account, services in self.group_by('account', 'service'): bill_lines = [] for service, orders in services: - lines = helpers.create_bill_lines(service, orders, **options) + lines = service.handler.create_bill_lines(orders, **options) bill_lines.extend(lines) - helpers.create_bills(account, bill_lines) + bills += bill_backend.create_bills(account, bill_lines) + return bills def get_related(self): pass @@ -259,10 +269,10 @@ class Order(models.Model): object_id = models.PositiveIntegerField(null=True) service = models.ForeignKey(Service, verbose_name=_("service"), related_name='orders') - registered_on = models.DateTimeField(_("registered on"), auto_now_add=True) - cancelled_on = models.DateTimeField(_("cancelled on"), null=True, blank=True) - billed_on = models.DateTimeField(_("billed on"), null=True, blank=True) - billed_until = models.DateTimeField(_("billed until"), null=True, blank=True) + registered_on = models.DateField(_("registered on"), auto_now_add=True) + cancelled_on = models.DateField(_("cancelled on"), null=True, blank=True) + billed_on = models.DateField(_("billed on"), null=True, blank=True) + billed_until = models.DateField(_("billed until"), null=True, blank=True) ignore = models.BooleanField(_("ignore"), default=False) description = models.TextField(_("description"), blank=True) @@ -302,16 +312,26 @@ class Order(models.Model): elif orders: orders.get().cancel() + @classmethod + def get_bill_backend(cls): + # TODO + from .backends import BillsBackend + return BillsBackend() + def cancel(self): self.cancelled_on = timezone.now() self.save() + + def get_metric(self, ini, end): + # TODO implement + return 10 class MetricStorage(models.Model): order = models.ForeignKey(Order, verbose_name=_("order")) value = models.BigIntegerField(_("value")) - created_on = models.DateTimeField(_("created on"), auto_now_add=True) - updated_on = models.DateTimeField(_("updated on"), auto_now=True) + created_on = models.DateField(_("created on"), auto_now_add=True) + updated_on = models.DateField(_("updated on"), auto_now=True) class Meta: get_latest_by = 'created_on' diff --git a/orchestra/bin/orchestra-admin b/orchestra/bin/orchestra-admin index 8f487d7e..d39ad91c 100755 --- a/orchestra/bin/orchestra-admin +++ b/orchestra/bin/orchestra-admin @@ -131,7 +131,7 @@ function install_requirements () { wkhtmltopdf \ xvfb" - PIP="django==1.6.1 \ + PIP="django==1.7 \ django-celery-email==1.0.4 \ django-fluent-dashboard==0.3.5 \ https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \ diff --git a/orchestra/utils/humanize.py b/orchestra/utils/humanize.py index cbfd944f..dc821394 100644 --- a/orchestra/utils/humanize.py +++ b/orchestra/utils/humanize.py @@ -5,19 +5,27 @@ from django.utils.translation import ungettext, ugettext as _ def pluralize_year(n): - return ungettext(_('{num:.1f} year ago'), _('{num:.1f} years ago'), n) + return ungettext( + _('{ahead}{num:.1f} year{ago}'), + _('{ahead}{num:.1f} years{ago}'), n) def pluralize_month(n): - return ungettext(_('{num:.1f} month ago'), _('{num:.1f} months ago'), n) + return ungettext( + _('{ahead}{num:.1f} month{ago}'), + _('{ahead}{num:.1f} months{ago}'), n) def pluralize_week(n): - return ungettext(_('{num:.1f} week ago'), _('{num:.1f} weeks ago'), n) + return ungettext( + _('{ahead}{num:.1f} week{ago}'), + _('{ahead}{num:.1f} weeks {ago}'), n) def pluralize_day(n): - return ungettext(_('{num:.1f} day ago'), _('{num:.1f} days ago'), n) + return ungettext( + _('{ahead}{num:.1f} day{ago}'), + _('{ahead}{num:.1f} days{ago}'), n) OLDER_CHUNKS = ( @@ -48,29 +56,34 @@ def naturaldate(date, include_seconds=False): minutes = delta.seconds / 60 seconds = delta.seconds + ago = ' ago' + ahead = '' if days < 0: - return _('just now') + ago = '' + ahead = 'in ' + days = abs(days) if days == 0: if hours == 0: if minutes > 0: minutes += float(seconds)/60 return ungettext( - _('{minutes:.1f} minute ago'), - _('{minutes:.1f} minutes ago'), minutes - ).format(minutes=minutes) + _('{ahead}{minutes:.1f} minute{ago}'), + _('{ahead}{minutes:.1f} minutes{ago}'), minutes + ).format(minutes=minutes, ago=ago, ahead=ahead) else: if include_seconds and seconds: return ungettext( - _('{seconds} second ago'), - _('{seconds} seconds ago'), seconds - ).format(seconds=seconds) + _('{ahead}{seconds} second{ago}'), + _('{ahead}{seconds} seconds{ago}'), seconds + ).format(seconds=seconds, ago=ago, ahead=ahead) return _('just now') else: hours += float(minutes)/60 return ungettext( - _('{hours:.1f} hour ago'), _('{hours:.1f} hours ago'), hours - ).format(hours=hours) + _('{ahead}{hours:.1f} hour{ago}'), + _('{ahead}{hours:.1f} hours{ago}'), hours + ).format(hours=hours, ago=ago, ahead=ahead) if delta_midnight.days == 0: return _('yesterday at {time}').format(time=date.strftime('%H:%M')) @@ -80,8 +93,9 @@ def naturaldate(date, include_seconds=False): if days < 7.0: count = days + float(hours)/24 fmt = pluralize_day(count) - return fmt.format(num=count) + return fmt.format(num=count, ago=ago, ahead=ahead) if days >= chunk: count = (delta_midnight.days + 1) / chunk + count = abs(count) fmt = pluralizefun(count) - return fmt.format(num=count) + return fmt.format(num=count, ago=ago, ahead=ahead)