Finished billing prototype
This commit is contained in:
parent
9e8a76bc1b
commit
f4c8ca06ca
|
@ -6,7 +6,7 @@ from orchestra.utils.system import run
|
||||||
def generate_bill(modeladmin, request, queryset):
|
def generate_bill(modeladmin, request, queryset):
|
||||||
bill = queryset.get()
|
bill = queryset.get()
|
||||||
bill.close()
|
bill.close()
|
||||||
# return HttpResponse(bill.html)
|
return HttpResponse(bill.html)
|
||||||
pdf = run('xvfb-run -a -s "-screen 0 640x4800x16" '
|
pdf = run('xvfb-run -a -s "-screen 0 640x4800x16" '
|
||||||
'wkhtmltopdf --footer-center "Page [page] of [topage]" --footer-font-size 9 - -',
|
'wkhtmltopdf --footer-center "Page [page] of [topage]" --footer-font-size 9 - -',
|
||||||
stdin=bill.html.encode('utf-8'), display=False)
|
stdin=bill.html.encode('utf-8'), display=False)
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.admin import ExtendedModelAdmin
|
from orchestra.admin import ExtendedModelAdmin
|
||||||
from orchestra.admin.utils import admin_link, admin_date
|
from orchestra.admin.utils import admin_link, admin_date
|
||||||
from orchestra.apps.accounts.admin import AccountAdminMixin
|
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||||
|
|
||||||
|
from . import settings
|
||||||
from .actions import generate_bill
|
from .actions import generate_bill
|
||||||
from .filters import BillTypeListFilter
|
from .filters import BillTypeListFilter
|
||||||
from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
|
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):
|
class BillLineInline(admin.TabularInline):
|
||||||
model = BillLine
|
model = BillLine
|
||||||
fields = (
|
fields = ('description', 'rate', 'amount', 'tax', 'total', 'subtotal')
|
||||||
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax'
|
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):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
if obj and obj.status != Bill.OPEN:
|
if obj and obj.status != Bill.OPEN:
|
||||||
|
@ -37,21 +46,20 @@ class BillLineInline(admin.TabularInline):
|
||||||
|
|
||||||
class BudgetLineInline(admin.TabularInline):
|
class BudgetLineInline(admin.TabularInline):
|
||||||
model = Budget
|
model = Budget
|
||||||
fields = (
|
fields = ('description', 'rate', 'amount', 'tax', 'total')
|
||||||
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
list_display = (
|
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',)
|
list_filter = (BillTypeListFilter, 'status',)
|
||||||
add_fields = ('account', 'type', 'status', 'due_on', 'comments')
|
add_fields = ('account', 'type', 'status', 'due_on', 'comments')
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'fields': ('number', 'account_link', 'type', 'status', 'due_on',
|
'fields': ('number', 'display_total', 'account_link', 'type',
|
||||||
'comments'),
|
'status', 'due_on', 'comments'),
|
||||||
}),
|
}),
|
||||||
(_("Raw"), {
|
(_("Raw"), {
|
||||||
'classes': ('collapse',),
|
'classes': ('collapse',),
|
||||||
|
@ -60,11 +68,21 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
)
|
)
|
||||||
change_view_actions = [generate_bill]
|
change_view_actions = [generate_bill]
|
||||||
change_readonly_fields = ('account_link', 'type', 'status')
|
change_readonly_fields = ('account_link', 'type', 'status')
|
||||||
readonly_fields = ('number',)
|
readonly_fields = ('number', 'display_total')
|
||||||
inlines = [BillLineInline]
|
inlines = [BillLineInline]
|
||||||
|
|
||||||
created_on_display = admin_date('created_on')
|
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):
|
def type_link(self, bill):
|
||||||
bill_type = bill.type.lower()
|
bill_type = bill.type.lower()
|
||||||
url = reverse('admin:bills_%s_changelist' % bill_type)
|
url = reverse('admin:bills_%s_changelist' % bill_type)
|
||||||
|
@ -93,7 +111,13 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||||
if db_field.name == 'html':
|
if db_field.name == 'html':
|
||||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20})
|
kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20})
|
||||||
return super(BillAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
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(Bill, BillAdmin)
|
||||||
admin.site.register(Invoice, BillAdmin)
|
admin.site.register(Invoice, BillAdmin)
|
||||||
|
|
|
@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.apps.accounts.models import Account
|
from orchestra.apps.accounts.models import Account
|
||||||
from orchestra.core import accounts
|
from orchestra.core import accounts
|
||||||
|
from orchestra.utils.functional import cached
|
||||||
|
|
||||||
from . import settings
|
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):
|
if not self.number or (self.number.startswith('O') and self.status != self.OPEN):
|
||||||
self.set_number()
|
self.set_number()
|
||||||
super(Bill, self).save(*args, **kwargs)
|
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):
|
class Invoice(Bill):
|
||||||
|
@ -176,11 +196,11 @@ class BaseBillLine(models.Model):
|
||||||
bill = models.ForeignKey(Bill, verbose_name=_("bill"),
|
bill = models.ForeignKey(Bill, verbose_name=_("bill"),
|
||||||
related_name='%(class)ss')
|
related_name='%(class)ss')
|
||||||
description = models.CharField(_("description"), max_length=256)
|
description = models.CharField(_("description"), max_length=256)
|
||||||
initial_date = models.DateTimeField()
|
rate = models.DecimalField(_("rate"), blank=True, null=True,
|
||||||
final_date = models.DateTimeField()
|
max_digits=12, decimal_places=2)
|
||||||
price = models.DecimalField(max_digits=12, decimal_places=2)
|
|
||||||
amount = models.DecimalField(_("amount"), 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:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
@ -188,7 +208,7 @@ class BaseBillLine(models.Model):
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return "#%i" % self.number
|
return "#%i" % self.number
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def number(self):
|
def number(self):
|
||||||
lines = type(self).objects.filter(bill=self.bill_id)
|
lines = type(self).objects.filter(bill=self.bill_id)
|
||||||
return lines.filter(id__lte=self.id).order_by('id').count()
|
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)
|
related_name='amendment_lines', null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
class SubBillLine(models.Model):
|
class BillSubline(models.Model):
|
||||||
""" Subline used for describing an item discount """
|
""" Subline used for describing an item discount """
|
||||||
bill_line = models.ForeignKey(BillLine, verbose_name=_("bill line"),
|
bill_line = models.ForeignKey(BillLine, verbose_name=_("bill line"),
|
||||||
related_name='sublines')
|
related_name='sublines')
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="total">
|
<div id="total">
|
||||||
<span class="title">TOTAL</span><br>
|
<span class="title">TOTAL</span><br>
|
||||||
<psan class="value">{{ bill.total }} &{{ currency.lower }};</span>
|
<psan class="value">{{ bill.get_total }} &{{ currency.lower }};</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="bill-date">
|
<div id="bill-date">
|
||||||
<span class="title">{{ bill.get_type_display.upper }} DATE</span><br>
|
<span class="title">{{ bill.get_type_display.upper }} DATE</span><br>
|
||||||
|
@ -79,20 +79,22 @@
|
||||||
<span class="value column-description">{{ line.description }}</span>
|
<span class="value column-description">{{ line.description }}</span>
|
||||||
<span class="value column-quantity">{{ line.amount|default:" " }}</span>
|
<span class="value column-quantity">{{ line.amount|default:" " }}</span>
|
||||||
<span class="value column-rate">{% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %} {% endif %}</span>
|
<span class="value column-rate">{% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %} {% endif %}</span>
|
||||||
<span class="value column-subtotal">{{ line.price }} &{{ currency.lower }};</span>
|
<span class="value column-subtotal">{{ line.total }} &{{ currency.lower }};</span>
|
||||||
<br>
|
<br>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div id="totals">
|
<div id="totals">
|
||||||
<br> <br>
|
<br> <br>
|
||||||
<span class="subtotal column-title">subtotal</span>
|
{% for tax, subtotal in bill.get_subtotals.iteritems %}
|
||||||
<span class="subtotal column-value">{{ bill.subtotal }} &{{ currency.lower }};</span>
|
<span class="subtotal column-title">subtotal {{ tax }}% VAT</span>
|
||||||
<br>
|
<span class="subtotal column-value">{{ subtotal | first }} &{{ currency.lower }};</span>
|
||||||
<span class="tax column-title">tax</span>
|
<br>
|
||||||
<span class="tax column-value">{{ bill.taxes }} &{{ currency.lower }};</span>
|
<span class="tax column-title">taxes {{ tax }}% VAT</span>
|
||||||
<br>
|
<span class="tax column-value">{{ subtotal | last }} &{{ currency.lower }};</span>
|
||||||
|
<br>
|
||||||
|
{% endfor %}
|
||||||
<span class="total column-title">total</span>
|
<span class="total column-title">total</span>
|
||||||
<span class="total column-value">{{ bill.total }} &{{ currency.lower }};</span>
|
<span class="total column-value">{{ bill.get_total }} &{{ currency.lower }};</span>
|
||||||
<br>
|
<br>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -97,7 +97,7 @@ class ContactInline(InvoiceContactInline):
|
||||||
|
|
||||||
def has_invoice(account):
|
def has_invoice(account):
|
||||||
try:
|
try:
|
||||||
account.invoicecontact.get()
|
account.invoicecontact
|
||||||
except InvoiceContact.DoesNotExist:
|
except InvoiceContact.DoesNotExist:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -70,7 +70,7 @@ class BillSelectedOrders(object):
|
||||||
msg = _("Selected orders do not have pending billing")
|
msg = _("Selected orders do not have pending billing")
|
||||||
self.modeladmin.message_user(request, msg, messages.WARNING)
|
self.modeladmin.message_user(request, msg, messages.WARNING)
|
||||||
else:
|
else:
|
||||||
ids = ','.join([bill.id for bill in bills])
|
ids = ','.join([str(bill.id) for bill in bills])
|
||||||
url = reverse('admin:bills_bill_changelist')
|
url = reverse('admin:bills_bill_changelist')
|
||||||
context = {
|
context = {
|
||||||
'url': url + '?id=%s' % ids,
|
'url': url + '?id=%s' % ids,
|
||||||
|
|
|
@ -78,9 +78,9 @@ class ServiceAdmin(admin.ModelAdmin):
|
||||||
class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
'id', 'service', 'account_link', 'content_object_link',
|
'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',)
|
list_filter = (ActiveOrderListFilter, 'service',)
|
||||||
actions = (BillSelectedOrders(),)
|
actions = (BillSelectedOrders(),)
|
||||||
date_hierarchy = 'registered_on'
|
date_hierarchy = 'registered_on'
|
||||||
|
@ -90,6 +90,7 @@ class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
||||||
|
|
||||||
content_object_link = admin_link('content_object', order=False)
|
content_object_link = admin_link('content_object', order=False)
|
||||||
display_registered_on = admin_date('registered_on')
|
display_registered_on = admin_date('registered_on')
|
||||||
|
display_billed_until = admin_date('billed_until')
|
||||||
display_cancelled_on = admin_date('cancelled_on')
|
display_cancelled_on = admin_date('cancelled_on')
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
|
@ -1,6 +1,7 @@
|
||||||
import calendar
|
import calendar
|
||||||
|
import datetime
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil import relativedelta
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -8,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.utils import plugins
|
from orchestra.utils import plugins
|
||||||
|
|
||||||
|
from . import settings
|
||||||
from .helpers import get_register_or_cancel_events, get_register_or_renew_events
|
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)
|
return eval(self.metric, safe_locals)
|
||||||
|
|
||||||
def get_billing_point(self, order, bp=None, **options):
|
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:
|
if not_cachable or bp is None:
|
||||||
bp = options.get('billing_point', timezone.now().date())
|
bp = options.get('billing_point', timezone.now().date())
|
||||||
if not options.get('fixed_point'):
|
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
|
date = bp
|
||||||
if self.payment_style is self.PREPAY:
|
if self.payment_style == self.PREPAY:
|
||||||
date += relativedelta(months=1)
|
date += relativedelta.relativedelta(months=1)
|
||||||
if self.billing_point is self.ON_REGISTER:
|
if self.billing_point == self.ON_REGISTER:
|
||||||
day = order.registered_on.day
|
day = order.registered_on.day
|
||||||
elif self.billing_point is self.FIXED_DATE:
|
elif self.billing_point == self.FIXED_DATE:
|
||||||
day = 1
|
day = 1
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(msg)
|
||||||
bp = datetime.datetime(year=date.year, month=date.month,
|
bp = datetime.datetime(year=date.year, month=date.month,
|
||||||
day=day, tzinfo=timezone.get_current_timezone())
|
day=day, tzinfo=timezone.get_current_timezone())
|
||||||
elif self.billing_period is self.ANUAL:
|
elif self.billing_period == self.ANUAL:
|
||||||
if self.billing_point is self.ON_REGISTER:
|
if self.billing_point == self.ON_REGISTER:
|
||||||
month = order.registered_on.month
|
month = order.registered_on.month
|
||||||
day = order.registered_on.day
|
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
|
month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
|
||||||
day = 1
|
day = 1
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(msg)
|
||||||
year = bp.year
|
year = bp.year
|
||||||
if self.payment_style is self.POSTPAY:
|
if self.payment_style == self.POSTPAY:
|
||||||
year = bo.year - relativedelta(years=1)
|
year = bo.year - relativedelta.relativedelta(years=1)
|
||||||
if bp.month >= month:
|
if bp.month >= month:
|
||||||
year = bp.year + 1
|
year = bp.year + 1
|
||||||
bp = datetime.datetime(year=year, month=month, day=day,
|
bp = datetime.datetime(year=year, month=month, day=day,
|
||||||
tzinfo=timezone.get_current_timezone())
|
tzinfo=timezone.get_current_timezone())
|
||||||
elif self.billing_period is self.NEVER:
|
elif self.billing_period == self.NEVER:
|
||||||
bp = order.registered_on
|
bp = order.registered_on
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(msg)
|
||||||
"Support for '%s' billing period and '%s' billing point is not implemented"
|
if self.on_cancel != self.NOTHING and order.cancelled_on and order.cancelled_on < bp:
|
||||||
% (self.display_billing_period(), self.display_billing_point())
|
|
||||||
)
|
|
||||||
if self.on_cancel is not self.NOTHING and order.cancelled_on < bp:
|
|
||||||
return order.cancelled_on
|
return order.cancelled_on
|
||||||
return bp
|
return bp
|
||||||
|
|
||||||
def get_pricing_size(self, ini, end):
|
def get_pricing_size(self, ini, end):
|
||||||
rdelta = relativedelta.relativedelta(end, ini)
|
rdelta = relativedelta.relativedelta(end, ini)
|
||||||
if self.get_pricing_period() is self.MONTHLY:
|
if self.get_pricing_period() == self.MONTHLY:
|
||||||
size = rdelta.months
|
size = rdelta.months
|
||||||
days = calendar.monthrange(bp.year, bp.month)[1]
|
days = calendar.monthrange(end.year, end.month)[1]
|
||||||
size += float(bp.day)/days
|
size += float(rdelta.days)/days
|
||||||
elif self.get_pricint_period() is self.ANUAL:
|
elif self.get_pricing_period() == self.ANUAL:
|
||||||
size = rdelta.years
|
size = rdelta.years
|
||||||
size += float(rdelta.days)/365
|
days = 366 if calendar.isleap(end.year) else 365
|
||||||
elif self.get_pricing_period() is self.NEVER:
|
size += float((end-ini).days)/days
|
||||||
|
elif self.get_pricing_period() == self.NEVER:
|
||||||
size = 1
|
size = 1
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@ -108,11 +114,11 @@ class ServiceHandler(plugins.Plugin):
|
||||||
|
|
||||||
def get_pricing_slots(self, ini, end):
|
def get_pricing_slots(self, ini, end):
|
||||||
period = self.get_pricing_period()
|
period = self.get_pricing_period()
|
||||||
if period is self.MONTHLY:
|
if period == self.MONTHLY:
|
||||||
rdelta = relativedelta(months=1)
|
rdelta = relativedelta.relativedelta(months=1)
|
||||||
elif period is self.ANUAL:
|
elif period == self.ANUAL:
|
||||||
rdelta = relativedelta(years=1)
|
rdelta = relativedelta.relativedelta(years=1)
|
||||||
elif period is self.NEVER:
|
elif period == self.NEVER:
|
||||||
yield ini, end
|
yield ini, end
|
||||||
raise StopIteration
|
raise StopIteration
|
||||||
else:
|
else:
|
||||||
|
@ -125,51 +131,107 @@ class ServiceHandler(plugins.Plugin):
|
||||||
yield ini, next
|
yield ini, next
|
||||||
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
|
nominal_price = self.nominal_price * size
|
||||||
|
discounts = []
|
||||||
if nominal_price > price:
|
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):
|
def create_bill_lines(self, orders, **options):
|
||||||
# Perform compensations on cancelled services
|
# For the "boundary conditions" just think that:
|
||||||
# TODO WTF to do with day 1 of each month.
|
# date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0)
|
||||||
if self.on_cancel in (Order.COMPENSATE, Order.REFOUND):
|
# 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?
|
# TODO compensations with commit=False, fuck commit or just fuck the transaction?
|
||||||
compensate(orders, **options)
|
# compensate(orders, **options)
|
||||||
# TODO create discount per compensation
|
# TODO create discount per compensation
|
||||||
bp = None
|
bp = None
|
||||||
lines = []
|
lines = []
|
||||||
for order in orders:
|
for order in orders:
|
||||||
bp = self.get_billing_point(order, bp=bp, **options)
|
bp = self.get_billing_point(order, bp=bp, **options)
|
||||||
ini = order.billed_until or order.registered_on
|
ini = order.billed_until or order.registered_on
|
||||||
if bp < ini:
|
if bp <= ini:
|
||||||
continue
|
continue
|
||||||
if not self.metric:
|
if not self.metric:
|
||||||
# Number of orders metric; bill line per order
|
# 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)
|
size = self.get_pricing_size(ini, bp)
|
||||||
if self.orders_effect is self.REGISTER_OR_RENEW:
|
price = self.get_price_with_orders(order, size, ini, bp)
|
||||||
events = get_register_or_renew_events(porders, ini, bp)
|
lines.append(self.create_line(order, price, size, 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)
|
|
||||||
else:
|
else:
|
||||||
# weighted metric; bill line per pricing period
|
# weighted metric; bill line per pricing period
|
||||||
for ini, end in self.get_pricing_slots(ini, bp):
|
for ini, end in self.get_pricing_slots(ini, bp):
|
||||||
metric = order.get_metric(ini, end)
|
|
||||||
size = self.get_pricing_size(ini, end)
|
size = self.get_pricing_size(ini, end)
|
||||||
price = self.get_rate(metric, account) * size
|
price = self.get_price_with_metric(order, size, ini, end)
|
||||||
lines += self.create_line(order, price, size)
|
lines.append(self.create_line(order, price, size, ini, end))
|
||||||
|
order.billed_until = bp
|
||||||
|
order.save() # TODO if commit
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def compensate(self, orders):
|
def compensate(self, orders):
|
||||||
# num orders and weights
|
# TODO this compensation is a bit hard to write it propertly
|
||||||
# Discounts
|
# don't forget to think about weighted and num order prices.
|
||||||
pass
|
# 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
|
||||||
|
|
|
@ -37,7 +37,7 @@ def get_related_objects(origin, max_depth=2):
|
||||||
queue.append(new_models)
|
queue.append(new_models)
|
||||||
|
|
||||||
def get_register_or_cancel_events(porders, ini, end):
|
def get_register_or_cancel_events(porders, ini, end):
|
||||||
assert ini > end, "ini > end"
|
assert ini <= end, "ini > end"
|
||||||
CANCEL = 'cancel'
|
CANCEL = 'cancel'
|
||||||
REGISTER = 'register'
|
REGISTER = 'register'
|
||||||
changes = {}
|
changes = {}
|
||||||
|
@ -50,21 +50,22 @@ def get_register_or_cancel_events(porders, ini, end):
|
||||||
if cancel > ini and cancel < end:
|
if cancel > ini and cancel < end:
|
||||||
changes.setdefault(cancel, [])
|
changes.setdefault(cancel, [])
|
||||||
changes[cancel].append(CANCEL)
|
changes[cancel].append(CANCEL)
|
||||||
if order.registered_on < ini:
|
if order.registered_on <= ini:
|
||||||
counter += 1
|
counter += 1
|
||||||
elif order.registered_on < end:
|
elif order.registered_on < end:
|
||||||
changes.setdefault(order.registered_on, [])
|
changes.setdefault(order.registered_on, [])
|
||||||
changes[order.registered_on].append(REGISTER)
|
changes[order.registered_on].append(REGISTER)
|
||||||
pointer = ini
|
pointer = ini
|
||||||
total = float((end-ini).days)
|
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]:
|
for change in changes[date]:
|
||||||
if change is CANCEL:
|
if change is CANCEL:
|
||||||
counter -= 1
|
counter -= 1
|
||||||
else:
|
else:
|
||||||
counter += 1
|
counter += 1
|
||||||
yield counter, (date-pointer).days/total
|
|
||||||
pointer = date
|
pointer = date
|
||||||
|
yield counter, (end-pointer).days/total
|
||||||
|
|
||||||
|
|
||||||
def get_register_or_renew_events(handler, porders, ini, end):
|
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):
|
for sini, send in handler.get_pricing_slots(ini, end):
|
||||||
counter = 0
|
counter = 0
|
||||||
for order in porders:
|
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
|
counter += 1
|
||||||
elif order.billed_until > send or order.cancelled_on > send:
|
elif order.billed_until > send or order.cancelled_on > send:
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
|
@ -84,7 +84,7 @@ class Service(models.Model):
|
||||||
metric = models.CharField(_("metric"), max_length=256, blank=True,
|
metric = models.CharField(_("metric"), max_length=256, blank=True,
|
||||||
help_text=_("Metric used to compute the pricing rate. "
|
help_text=_("Metric used to compute the pricing rate. "
|
||||||
"Number of orders is used when left blank."))
|
"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)
|
default=settings.ORDERS_SERVICE_DEFAUL_TAX)
|
||||||
pricing_period = models.CharField(_("pricing period"), max_length=16,
|
pricing_period = models.CharField(_("pricing period"), max_length=16,
|
||||||
help_text=_("Period used for calculating the metric used on the "
|
help_text=_("Period used for calculating the metric used on the "
|
||||||
|
@ -163,6 +163,11 @@ class Service(models.Model):
|
||||||
),
|
),
|
||||||
default=NEVER)
|
default=NEVER)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nominal_price(self):
|
||||||
|
# FIXME delete and make it a model field
|
||||||
|
return 10
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return self.description
|
return self.description
|
||||||
|
|
||||||
|
@ -215,24 +220,29 @@ class Service(models.Model):
|
||||||
msg = "{0} {1}: {2}".format(attr, name, message)
|
msg = "{0} {1}: {2}".format(attr, name, message)
|
||||||
raise ValidationError(msg)
|
raise ValidationError(msg)
|
||||||
|
|
||||||
def get_nominal_price(self, order):
|
def get_pricing_period(self):
|
||||||
""" returns the price of an item """
|
if self.pricing_period == self.BILLING_PERIOD:
|
||||||
|
return self.billing_period
|
||||||
|
return self.pricing_period
|
||||||
|
|
||||||
def get_price(self, order, amount='TODO'):
|
def get_rate(self, order, metric):
|
||||||
pass
|
# TODO implement
|
||||||
|
return 12
|
||||||
|
|
||||||
|
|
||||||
class OrderQuerySet(models.QuerySet):
|
class OrderQuerySet(models.QuerySet):
|
||||||
group_by = queryset.group_by
|
group_by = queryset.group_by
|
||||||
|
|
||||||
def bill(self, **options):
|
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 = []
|
bill_lines = []
|
||||||
for service, orders in services:
|
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)
|
bill_lines.extend(lines)
|
||||||
helpers.create_bills(account, bill_lines)
|
bills += bill_backend.create_bills(account, bill_lines)
|
||||||
|
return bills
|
||||||
|
|
||||||
def get_related(self):
|
def get_related(self):
|
||||||
pass
|
pass
|
||||||
|
@ -259,10 +269,10 @@ class Order(models.Model):
|
||||||
object_id = models.PositiveIntegerField(null=True)
|
object_id = models.PositiveIntegerField(null=True)
|
||||||
service = models.ForeignKey(Service, verbose_name=_("service"),
|
service = models.ForeignKey(Service, verbose_name=_("service"),
|
||||||
related_name='orders')
|
related_name='orders')
|
||||||
registered_on = models.DateTimeField(_("registered on"), auto_now_add=True)
|
registered_on = models.DateField(_("registered on"), auto_now_add=True)
|
||||||
cancelled_on = models.DateTimeField(_("cancelled on"), null=True, blank=True)
|
cancelled_on = models.DateField(_("cancelled on"), null=True, blank=True)
|
||||||
billed_on = models.DateTimeField(_("billed on"), null=True, blank=True)
|
billed_on = models.DateField(_("billed on"), null=True, blank=True)
|
||||||
billed_until = models.DateTimeField(_("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)
|
||||||
|
|
||||||
|
@ -302,16 +312,26 @@ class Order(models.Model):
|
||||||
elif orders:
|
elif orders:
|
||||||
orders.get().cancel()
|
orders.get().cancel()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_bill_backend(cls):
|
||||||
|
# TODO
|
||||||
|
from .backends import BillsBackend
|
||||||
|
return BillsBackend()
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
self.cancelled_on = timezone.now()
|
self.cancelled_on = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
def get_metric(self, ini, end):
|
||||||
|
# TODO implement
|
||||||
|
return 10
|
||||||
|
|
||||||
|
|
||||||
class MetricStorage(models.Model):
|
class MetricStorage(models.Model):
|
||||||
order = models.ForeignKey(Order, verbose_name=_("order"))
|
order = models.ForeignKey(Order, verbose_name=_("order"))
|
||||||
value = models.BigIntegerField(_("value"))
|
value = models.BigIntegerField(_("value"))
|
||||||
created_on = models.DateTimeField(_("created on"), auto_now_add=True)
|
created_on = models.DateField(_("created on"), auto_now_add=True)
|
||||||
updated_on = models.DateTimeField(_("updated on"), auto_now=True)
|
updated_on = models.DateField(_("updated on"), auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
get_latest_by = 'created_on'
|
get_latest_by = 'created_on'
|
||||||
|
|
|
@ -131,7 +131,7 @@ function install_requirements () {
|
||||||
wkhtmltopdf \
|
wkhtmltopdf \
|
||||||
xvfb"
|
xvfb"
|
||||||
|
|
||||||
PIP="django==1.6.1 \
|
PIP="django==1.7 \
|
||||||
django-celery-email==1.0.4 \
|
django-celery-email==1.0.4 \
|
||||||
django-fluent-dashboard==0.3.5 \
|
django-fluent-dashboard==0.3.5 \
|
||||||
https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \
|
https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \
|
||||||
|
|
|
@ -5,19 +5,27 @@ from django.utils.translation import ungettext, ugettext as _
|
||||||
|
|
||||||
|
|
||||||
def pluralize_year(n):
|
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):
|
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):
|
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):
|
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 = (
|
OLDER_CHUNKS = (
|
||||||
|
@ -48,29 +56,34 @@ def naturaldate(date, include_seconds=False):
|
||||||
minutes = delta.seconds / 60
|
minutes = delta.seconds / 60
|
||||||
seconds = delta.seconds
|
seconds = delta.seconds
|
||||||
|
|
||||||
|
ago = ' ago'
|
||||||
|
ahead = ''
|
||||||
if days < 0:
|
if days < 0:
|
||||||
return _('just now')
|
ago = ''
|
||||||
|
ahead = 'in '
|
||||||
|
days = abs(days)
|
||||||
|
|
||||||
if days == 0:
|
if days == 0:
|
||||||
if hours == 0:
|
if hours == 0:
|
||||||
if minutes > 0:
|
if minutes > 0:
|
||||||
minutes += float(seconds)/60
|
minutes += float(seconds)/60
|
||||||
return ungettext(
|
return ungettext(
|
||||||
_('{minutes:.1f} minute ago'),
|
_('{ahead}{minutes:.1f} minute{ago}'),
|
||||||
_('{minutes:.1f} minutes ago'), minutes
|
_('{ahead}{minutes:.1f} minutes{ago}'), minutes
|
||||||
).format(minutes=minutes)
|
).format(minutes=minutes, ago=ago, ahead=ahead)
|
||||||
else:
|
else:
|
||||||
if include_seconds and seconds:
|
if include_seconds and seconds:
|
||||||
return ungettext(
|
return ungettext(
|
||||||
_('{seconds} second ago'),
|
_('{ahead}{seconds} second{ago}'),
|
||||||
_('{seconds} seconds ago'), seconds
|
_('{ahead}{seconds} seconds{ago}'), seconds
|
||||||
).format(seconds=seconds)
|
).format(seconds=seconds, ago=ago, ahead=ahead)
|
||||||
return _('just now')
|
return _('just now')
|
||||||
else:
|
else:
|
||||||
hours += float(minutes)/60
|
hours += float(minutes)/60
|
||||||
return ungettext(
|
return ungettext(
|
||||||
_('{hours:.1f} hour ago'), _('{hours:.1f} hours ago'), hours
|
_('{ahead}{hours:.1f} hour{ago}'),
|
||||||
).format(hours=hours)
|
_('{ahead}{hours:.1f} hours{ago}'), hours
|
||||||
|
).format(hours=hours, ago=ago, ahead=ahead)
|
||||||
|
|
||||||
if delta_midnight.days == 0:
|
if delta_midnight.days == 0:
|
||||||
return _('yesterday at {time}').format(time=date.strftime('%H:%M'))
|
return _('yesterday at {time}').format(time=date.strftime('%H:%M'))
|
||||||
|
@ -80,8 +93,9 @@ def naturaldate(date, include_seconds=False):
|
||||||
if days < 7.0:
|
if days < 7.0:
|
||||||
count = days + float(hours)/24
|
count = days + float(hours)/24
|
||||||
fmt = pluralize_day(count)
|
fmt = pluralize_day(count)
|
||||||
return fmt.format(num=count)
|
return fmt.format(num=count, ago=ago, ahead=ahead)
|
||||||
if days >= chunk:
|
if days >= chunk:
|
||||||
count = (delta_midnight.days + 1) / chunk
|
count = (delta_midnight.days + 1) / chunk
|
||||||
|
count = abs(count)
|
||||||
fmt = pluralizefun(count)
|
fmt = pluralizefun(count)
|
||||||
return fmt.format(num=count)
|
return fmt.format(num=count, ago=ago, ahead=ahead)
|
||||||
|
|
Loading…
Reference in New Issue