Fix bugs on resource history and added order.billed_metric field

This commit is contained in:
Marc Aymerich 2015-07-30 16:43:12 +00:00
parent ae0968f58f
commit a8f4b17149
10 changed files with 182 additions and 104 deletions

14
TODO.md
View File

@ -417,15 +417,19 @@ Greatest
Colaesce('total', 'computed_total') Colaesce('total', 'computed_total')
Case Case
# case on payment transaction state ? case when trans.amount > # SQL case on payment transaction state ? case when trans.amount >
# Resource inline links point to custom changelist view that preserve state (breadcrumbs, title, etc) rather than generic changeview with queryarg filtering # Resource inline links point to custom changelist view that preserve state (breadcrumbs, title, etc) rather than generic changeview with queryarg filtering
# ORDER diff Pending vs ALL # ORDER diff Pending vs ALL
# pre-bill confirmation: remove account if lines.count() == 0 ?
# Discount prepaid metric should be more optimal https://orchestra.pangea.org/admin/orders/order/40/ # DELETING RESOURCE RELATED OBJECT SHOULD NOT delete related monitor data for traffic accountancy
# -> order.billed_metric besides billed_until
# round decimals on every billing operation
# websites directives: redirect strip() and allow empty URL_path # Serie1
# Pangea post-create: lorena no has afegit el webalizer
# cleanup monitor data
# Add SPF record type

View File

@ -51,24 +51,12 @@ class BilledOrderListFilter(SimpleListFilter):
queryset=MetricStorage.objects.filter(created_on__gt=F('order__billed_on'), queryset=MetricStorage.objects.filter(created_on__gt=F('order__billed_on'),
created_on__lte=(F('updated_on')-mindelta)) created_on__lte=(F('updated_on')-mindelta))
) )
prefetch_billed_metric = Prefetch('metrics', to_attr='billed_metric',
queryset=MetricStorage.objects.filter(order__billed_on__isnull=False,
created_on__lte=F('order__billed_on'), updated_on__gt=F('order__billed_on'))
)
metric_queryset = queryset.exclude(service__metric='').exclude(billed_on__isnull=True) metric_queryset = queryset.exclude(service__metric='').exclude(billed_on__isnull=True)
for order in metric_queryset.prefetch_related(prefetch_valid_metrics, prefetch_billed_metric): for order in metric_queryset.prefetch_related(prefetch_valid_metrics):
if len(order.billed_metric) != 1:
# corner case of prefetch_billed_metric: Does not always work with latests metrics
latest = order.metrics.latest()
if not latest:
raise ValueError("Data inconsistency #metrics %i != 1." % len(order.billed_metric))
billed_metric = latest.value
else:
billed_metric = order.billed_metric[0].value
for metric in order.valid_metrics: for metric in order.valid_metrics:
if metric.created_on <= order.billed_on: if metric.created_on <= order.billed_on:
raise ValueError("This value should already be filtered on the prefetch query.") raise ValueError("This value should already be filtered on the prefetch query.")
if metric.value > billed_metric: if metric.value > order.billed_metric:
metric_pks.append(order.pk) metric_pks.append(order.pk)
break break
return metric_pks return metric_pks

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_order_content_object_repr'),
]
operations = [
migrations.AddField(
model_name='order',
name='billed_metric',
field=models.DecimalField(verbose_name='billed metric', max_digits=16, decimal_places=2, null=True),
),
migrations.AlterField(
model_name='order',
name='content_object_repr',
field=models.CharField(verbose_name='content object representation', max_length=256, editable=False),
),
]

View File

@ -114,13 +114,14 @@ class Order(models.Model):
related_name='orders') related_name='orders')
registered_on = models.DateField(_("registered"), default=timezone.now, db_index=True) registered_on = models.DateField(_("registered"), default=timezone.now, db_index=True)
cancelled_on = models.DateField(_("cancelled"), null=True, blank=True) cancelled_on = models.DateField(_("cancelled"), null=True, blank=True)
# TODO billed metric
billed_on = models.DateField(_("billed"), null=True, blank=True) billed_on = models.DateField(_("billed"), null=True, blank=True)
billed_metric = models.DecimalField(_("billed metric"), max_digits=16, decimal_places=2,
null=True, blank=True)
billed_until = models.DateField(_("billed until"), null=True, blank=True) billed_until = models.DateField(_("billed until"), null=True, blank=True)
ignore = models.BooleanField(_("ignore"), default=False) ignore = models.BooleanField(_("ignore"), default=False)
description = models.TextField(_("description"), blank=True) description = models.TextField(_("description"), blank=True)
content_object_repr = models.CharField(_("content object representation"), max_length=256, content_object_repr = models.CharField(_("content object representation"), max_length=256,
editable=False) editable=False, help_text=_("Used for searches."))
content_object = GenericForeignKey() content_object = GenericForeignKey()
objects = OrderQuerySet.as_manager() objects = OrderQuerySet.as_manager()
@ -239,13 +240,15 @@ class Order(models.Model):
if kwargs: if kwargs:
raise AttributeError raise AttributeError
if len(args) == 2: if len(args) == 2:
# Slot
ini, end = args ini, end = args
metrics = self.metrics.filter(updated_on__lt=end, updated_on__gte=ini) metrics = self.metrics.filter(created_on__lt=end, updated_on__gte=ini)
elif len(args) == 1: elif len(args) == 1:
# On effect on date
date = args[0] date = args[0]
date = datetime.date(year=date.year, month=date.month, day=date.day) date = datetime.date(year=date.year, month=date.month, day=date.day)
date += datetime.timedelta(days=1) date += datetime.timedelta(days=1)
metrics = self.metrics.filter(updated_on__lt=date) metrics = self.metrics.filter(created_on__lte=date)
elif not args: elif not args:
return self.metrics.latest('updated_on').value return self.metrics.latest('updated_on').value
else: else:
@ -261,7 +264,6 @@ class MetricStorage(models.Model):
order = models.ForeignKey(Order, verbose_name=_("order"), related_name='metrics') order = models.ForeignKey(Order, verbose_name=_("order"), related_name='metrics')
value = models.DecimalField(_("value"), max_digits=16, decimal_places=2) value = models.DecimalField(_("value"), max_digits=16, decimal_places=2)
created_on = models.DateField(_("created"), auto_now_add=True) created_on = models.DateField(_("created"), auto_now_add=True)
# default=lambda: timezone.now())
# TODO time field? # TODO time field?
updated_on = models.DateTimeField(_("updated")) updated_on = models.DateTimeField(_("updated"))

View File

@ -47,27 +47,35 @@ $(document).ready( function () {
<div class="tabular inline-related last-related"> <div class="tabular inline-related last-related">
<fieldset class="module"> <fieldset class="module">
<h2><a href="{% url 'admin:accounts_account_change' account.pk %}">{{ account }}</a><span style="float:right">{{ total | floatformat:"-2" }} &euro;</span></h2> <h2><a href="{% url 'admin:accounts_account_change' account.pk %}">{{ account }}</a><span style="float:right">{{ total | floatformat:"-2" }} &euro;</span></h2>
<table> {% if not lines %}
<thead> <table>
<tr><th style="width:30%;">Description</th> <th style="width:30%;">Period</th> <th style="width:10%;">Size&times;Quantity</th> <th style="width:10%;">Price</th></tr> <thead>
</thead> <tr><th>{% trans 'Nothing to bill' %}</th></tr>
<tbody> </thead>
{% for line in lines %} </table>
<tr class="form-row {% if forloop.counter|divisibleby:2 %}row2{% else %}row1{% endif %}"> {% else %}
<td> <table>
<a href="{{ line.order | admin_url }}">{{ line.order.description }}</a> <thead>
{% for discount in line.discounts %} <tr><th style="width:30%;">Description</th> <th style="width:30%;">Period</th> <th style="width:10%;">Size&times;Quantity</th> <th style="width:10%;">Price</th></tr>
<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Discount per {{ discount.type }} </thead>
{% endfor %} <tbody>
</td> {% for line in lines %}
<td>{{ line.ini | date }} to {{ line.end | date }}</td> <tr class="form-row {% if forloop.counter|divisibleby:2 %}row2{% else %}row1{% endif %}">
<td>{{ line.size | floatformat:"-2" }}&times;{{ line.metric | floatformat:"-2"}}</td> <td>
<td> <a href="{{ line.order | admin_url }}">{{ line.order.description }}</a>
&nbsp;{{ line.subtotal | floatformat:"-2" }} &euro; {% for discount in line.discounts %}
{% for discount in line.discounts %}<br>{{ discount.total | floatformat:"-2" }} &euro;{% endfor %} <br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Discount per {{ discount.type }}
</td> {% endfor %}
</tr> </td>
{% endfor %} <td>{{ line.ini | date }} to {{ line.end | date }}</td>
<td>{{ line.size | floatformat:"-2" }}&times;{{ line.metric | floatformat:"-2"}}</td>
<td>
&nbsp;{{ line.subtotal | floatformat:"-2" }} &euro;
{% for discount in line.discounts %}<br>{{ discount.total | floatformat:"-2" }} &euro;{% endfor %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody> </tbody>
</table> </table>
</fieldset> </fieldset>

View File

@ -1,6 +1,7 @@
import copy import copy
import datetime import datetime
import decimal import decimal
import itertools
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.utils import timezone from django.utils import timezone
@ -74,37 +75,36 @@ class MonthlySum(Last):
) )
def aggregate_history(self, dataset): def aggregate_history(self, dataset):
make_data = lambda mdata, current: AttrDict( prev = None
date=datetime.date(
year=mdata.created_at.year,
month=mdata.created_at.month,
day=1
),
value=current,
content_object_repr=mdata.content_object_repr
)
prev_month = None
prev_object_id = None prev_object_id = None
datas = [] datas = []
for mdata in dataset.order_by('object_id', 'created_at'): sink = AttrDict(object_id=-1, value=-1, content_object_repr='',
created_at=AttrDict(year=-1, month=-1))
for mdata in itertools.chain(dataset.order_by('object_id', 'created_at'), [sink]):
object_id = mdata.object_id object_id = mdata.object_id
if object_id != prev_object_id: ymonth = (mdata.created_at.year, mdata.created_at.month)
if object_id != prev_object_id or ymonth != prev.ymonth:
if prev_object_id is not None: if prev_object_id is not None:
yield (mdata.content_object_repr, datas) data = AttrDict(
datas = [] date=datetime.date(
month = mdata.created_at.month year=prev.ymonth[0],
if object_id != prev_object_id or month != prev_month: month=prev.ymonth[1],
if prev_month is not None: day=1
datas.append(make_data(mdata, current)) ),
value=current,
content_object_repr=prev.content_object_repr
)
datas.append(data)
current = mdata.value current = mdata.value
else: else:
current += mdata.value current += mdata.value
prev_month = month if object_id != prev_object_id:
if prev_object_id is not None:
yield(prev.content_object_repr, datas)
datas = []
prev = mdata
prev.ymonth = ymonth
prev_object_id = object_id prev_object_id = object_id
if prev_object_id is not None:
datas.append(make_data(mdata, current))
yield (mdata.content_object_repr, datas)
class MonthlyAvg(MonthlySum): class MonthlyAvg(MonthlySum):
@ -122,7 +122,7 @@ class MonthlyAvg(MonthlySum):
day=1, day=1,
) )
def compute_usage(self, dataset, historic=False): def compute_usage(self, dataset):
result = 0 result = 0
has_result = False has_result = False
aggregate = [] aggregate = []
@ -140,15 +140,9 @@ class MonthlyAvg(MonthlySum):
slot = (mdata.created_at-ini).total_seconds() slot = (mdata.created_at-ini).total_seconds()
current += mdata.value * decimal.Decimal(str(slot/total)) current += mdata.value * decimal.Decimal(str(slot/total))
ini = mdata.created_at ini = mdata.created_at
if historic:
aggregate.append(
(mdata, current)
)
else: else:
result += current result += current
if has_result: if has_result:
if historic:
return aggregate
return result return result
return None return None

View File

@ -186,12 +186,12 @@
}; };
divs = ( divs = (
'<div style="background: '+(i % 2 ? "#EDF3FE" : "#FFFFFF")+'; margin: 10px; margin-bottom: -1px; border: 1px solid grey; padding: 20px;">' + '<div class="chart-box" style="background: '+(i % 2 ? "#EDF3FE" : "#FFFFFF")+';">' +
'<h1>'+resource['content_type'].capitalize() + ' ' + resource['verbose_name'].toLowerCase() + '</h1>' + '<h1>'+resource['content_type'].capitalize() + ' ' + resource['verbose_name'].toLowerCase() + '</h1>' +
'<div id="resource-'+i+'" style="height: 400px; min-width: 310px"></div>' '<div id="resource-'+i+'" class="chart"></div>'
); );
if (a_index > 1 && aggregated && resource['aggregated_history']) if (a_index > 1 && aggregated && resource['aggregated_history'])
divs += '<br><div id="resource-'+i+'-aggregate" style="height: 400px; min-width: 310px"></div>'; divs += '<br><div class="chart" id="resource-'+i+'-aggregate"></div>';
divs += '</div>'; divs += '</div>';
$("#charts").append(divs); $("#charts").append(divs);
@ -211,11 +211,36 @@
font-family: sans; font-family: sans;
font-size: 21px; font-size: 21px;
} }
#notice {
font-family: sans;
font-size: 12px;
text-align: right;
padding-right: 10px;
}
#message {
width:300px;
margin:0 auto;
font-family: monospace;
font-weight: bold;
font-size: 18px;
margin-top: 5%;
}
.chart-box {
margin: 10px;
margin-bottom: -1px;
border: 1px solid grey;
padding: 20px;
}
.chart {
height: 400px;
min-width: 310px;
}
</style> </style>
</head> </head>
<body> <body>
<div id="notice">&#9830;Notice that resources used by deleted services will not appear.</div>
<div id="charts"> <div id="charts">
<div id="message" style="width:300px; margin:0 auto; font-family: monospace; font-weight: bold; font-size: 18px; margin-top: 5%"> <div id="message">
> crunching data <span id="dancing-dots-text"> <span><span>.</span><span>.</span><span>.</span></span></span> > crunching data <span id="dancing-dots-text"> <span><span>.</span><span>.</span><span>.</span></span></span>
</div> </div>
</div> </div>

View File

@ -203,6 +203,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
size = 1 size = 1
else: else:
raise NotImplementedError raise NotImplementedError
size = round(size, 2)
return decimal.Decimal(str(size)) return decimal.Decimal(str(size))
def get_pricing_slots(self, ini, end): def get_pricing_slots(self, ini, end):
@ -492,52 +493,69 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
lines = [] lines = []
bp = None bp = None
for order in orders: for order in orders:
recharges = [] prepay_discount = 0
bp = self.get_billing_point(order, bp=bp, **options) bp = self.get_billing_point(order, bp=bp, **options)
if (self.billing_period != self.NEVER and if (self.billing_period != self.NEVER and
self.get_pricing_period() == self.NEVER and self.get_pricing_period() == self.NEVER and
self.payment_style == self.PREPAY and order.billed_on): self.payment_style == self.PREPAY and order.billed_on):
# Recharge # Recharge
if self.payment_style == self.PREPAY and order.billed_on: if self.payment_style == self.PREPAY and order.billed_on:
recharges = []
rini = order.billed_on rini = order.billed_on
rend = min(bp, order.billed_until) rend = min(bp, order.billed_until)
cmetric = None bmetric = order.billed_metric
bsize = self.get_price_size(rini, order.billed_until)
prepay_discount = self.get_price(account, bmetric) * bsize
prepay_discount = round(prepay_discount, 2)
for cini, cend, metric in order.get_metric(rini, rend, changes=True): for cini, cend, metric in order.get_metric(rini, rend, changes=True):
if cmetric is None:
cmetric = metric
csize = self.get_price_size(rini, order.billed_until)
cprice = self.get_price(account, cmetric) * csize
size = self.get_price_size(cini, cend) size = self.get_price_size(cini, cend)
price = self.get_price(account, metric) * size price = self.get_price(account, metric) * size
discounts = () discounts = ()
discount = min(price, max(cprice, 0)) discount = min(price, max(prepay_discount, 0))
if discount: prepay_discount -= price
cprice -= price if discount > 0:
price -= discount price -= discount
discounts = ( discounts = (
('prepay', -discount), ('prepay', -discount),
) )
# if price-discount: # Don't overdload bills with lots of lines
recharges.append((order, price, cini, cend, metric, discounts)) if price > 0:
# only recharge when appropiate in order to preserve bigger prepays. recharges.append((order, price, cini, cend, metric, discounts))
if cmetric < metric or bp > order.billed_until: if prepay_discount < 0:
# User has prepaid less than the actual consumption
for order, price, cini, cend, metric, discounts in recharges: for order, price, cini, cend, metric, discounts in recharges:
line = self.generate_line(order, price, cini, cend, metric=metric, line = self.generate_line(order, price, cini, cend, metric=metric,
computed=True, discounts=discounts) computed=True, discounts=discounts)
lines.append(line) lines.append(line)
if order.billed_until and order.cancelled_on and order.cancelled_on >= order.billed_until: if order.billed_until and order.cancelled_on and order.cancelled_on >= order.billed_until:
# Cancelled order
continue continue
if self.billing_period != self.NEVER: if self.billing_period != self.NEVER:
ini = order.billed_until or order.registered_on ini = order.billed_until or order.registered_on
# Periodic billing # Periodic billing
if bp <= ini: if bp <= ini:
# Already billed
continue continue
order.new_billed_until = bp order.new_billed_until = bp
if self.get_pricing_period() == self.NEVER: if self.get_pricing_period() == self.NEVER:
# Changes (Mailbox disk-like) # Changes (Mailbox disk-like)
for cini, cend, metric in order.get_metric(ini, bp, changes=True): for cini, cend, metric in order.get_metric(ini, bp, changes=True):
price = self.get_price(account, metric) price = self.get_price(account, metric)
lines.append(self.generate_line(order, price, cini, cend, metric=metric)) discounts = ()
# Since the current datamodel can't guarantee to retrieve the exact
# state for calculating prepay_discount (service price could have change)
# maybe is it better not to discount anything.
# discount = min(price, max(prepay_discount, 0))
# if discount > 0:
# price -= discount
# prepay_discount -= discount
# discounts = (
# ('prepay', -discount),
# )
if metric > 0:
line = self.generate_line(order, price, cini, cend, metric=metric,
discounts=discounts)
lines.append(line)
elif self.get_pricing_period() == self.billing_period: elif self.get_pricing_period() == self.billing_period:
# pricing_slots (Traffic-like) # pricing_slots (Traffic-like)
if self.payment_style == self.PREPAY: if self.payment_style == self.PREPAY:
@ -545,7 +563,18 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
for cini, cend in self.get_pricing_slots(ini, bp): for cini, cend in self.get_pricing_slots(ini, bp):
metric = order.get_metric(cini, cend) metric = order.get_metric(cini, cend)
price = self.get_price(account, metric) price = self.get_price(account, metric)
lines.append(self.generate_line(order, price, cini, cend, metric=metric)) discounts = ()
# discount = min(price, max(prepay_discount, 0))
# if discount > 0:
# price -= discount
# prepay_discount -= discount
# discounts = (
# ('prepay', -discount),
# )
if metric > 0:
line = self.generate_line(order, price, cini, cend, metric=metric,
discounts=discounts)
lines.append(line)
else: else:
raise NotImplementedError raise NotImplementedError
else: else:
@ -558,9 +587,12 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
# get metric (Job-like) # get metric (Job-like)
metric = order.get_metric(date) metric = order.get_metric(date)
price = self.get_price(account, metric) price = self.get_price(account, metric)
lines.append(self.generate_line(order, price, date, metric=metric)) line = self.generate_line(order, price, date, metric=metric)
lines.append(line)
else: else:
raise NotImplementedError raise NotImplementedError
# Last processed metric for futrue recharges
order.new_billed_metric = metric
return lines return lines
def generate_bill_lines(self, orders, account, **options): def generate_bill_lines(self, orders, account, **options):
@ -575,6 +607,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
for line in lines: for line in lines:
order = line.order order = line.order
order.billed_on = now order.billed_on = now
order.billed_metric = getattr(order, 'new_billed_metric', order.billed_metric)
order.billed_until = getattr(order, 'new_billed_until', order.billed_until) order.billed_until = getattr(order, 'new_billed_until', order.billed_until)
order.save(update_fields=['billed_on', 'billed_until']) order.save(update_fields=('billed_on', 'billed_until', 'billed_metric'))
return lines return lines

View File

@ -211,6 +211,7 @@ class Service(models.Model):
if counter >= metric: if counter >= metric:
counter = metric counter = metric
accumulated += (counter - ant_counter) * rate['price'] accumulated += (counter - ant_counter) * rate['price']
accumulated = round(accumulated, 2)
return decimal.Decimal(str(accumulated)) return decimal.Decimal(str(accumulated))
ant_counter = counter ant_counter = counter
accumulated += rate['price'] * rate['quantity'] accumulated += rate['price'] * rate['quantity']
@ -221,6 +222,7 @@ class Service(models.Model):
for rate in rates: for rate in rates:
counter += rate['quantity'] counter += rate['quantity']
if counter >= position: if counter >= position:
price = round(rate['price'], 2)
return decimal.Decimal(str(rate['price'])) return decimal.Decimal(str(rate['price']))
raise RuntimeError("Rating algorithm bad result") raise RuntimeError("Rating algorithm bad result")

View File

@ -40,7 +40,7 @@ def _un(singular__plural, n=None):
return ungettext(singular, plural, n) return ungettext(singular, plural, n)
def naturaldatetime(date, include_seconds=True): def naturaldatetime(date, show_seconds=False):
"""Convert datetime into a human natural date string.""" """Convert datetime into a human natural date string."""
if not date: if not date:
return '' return ''
@ -63,19 +63,17 @@ def naturaldatetime(date, include_seconds=True):
if days == 0: if days == 0:
if hours == 0: if hours == 0:
if minutes >= 1: if minutes >= 1 or not show_seconds:
minutes = float(seconds)/60 minutes = float(seconds)/60
return ungettext( return ungettext(
_("{minutes:.1f} minute{ago}"), _("{minutes:.1f} minute{ago}"),
_("{minutes:.1f} minutes{ago}"), minutes _("{minutes:.1f} minutes{ago}"), minutes
).format(minutes=minutes, ago=ago) ).format(minutes=minutes, ago=ago)
else: else:
if include_seconds: return ungettext(
return ungettext( _("{seconds} second{ago}"),
_("{seconds} second{ago}"), _("{seconds} seconds{ago}"), seconds
_("{seconds} seconds{ago}"), seconds ).format(seconds=seconds, ago=ago)
).format(seconds=seconds, ago=ago)
return _("just now")
else: else:
hours = float(minutes)/60 hours = float(minutes)/60
return ungettext( return ungettext(