Refactor services out of orders
This commit is contained in:
parent
821463eb33
commit
7298c9393e
|
@ -65,10 +65,10 @@ def get_accounts():
|
||||||
|
|
||||||
def get_administration_items():
|
def get_administration_items():
|
||||||
childrens = []
|
childrens = []
|
||||||
if isinstalled('orchestra.apps.orders'):
|
if isinstalled('orchestra.apps.services'):
|
||||||
url = reverse('admin:orders_service_changelist')
|
url = reverse('admin:services_service_changelist')
|
||||||
childrens.append(items.MenuItem(_("Services"), url))
|
childrens.append(items.MenuItem(_("Services"), url))
|
||||||
url = reverse('admin:orders_plan_changelist')
|
url = reverse('admin:services_plan_changelist')
|
||||||
childrens.append(items.MenuItem(_("Plans"), url))
|
childrens.append(items.MenuItem(_("Plans"), url))
|
||||||
if isinstalled('orchestra.apps.orchestration'):
|
if isinstalled('orchestra.apps.orchestration'):
|
||||||
route = reverse('admin:orchestration_route_changelist')
|
route = reverse('admin:orchestration_route_changelist')
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -11,7 +12,7 @@ from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.models.utils import get_field_value
|
from orchestra.models.utils import get_field_value
|
||||||
from orchestra.utils.humanize import naturaldate
|
from orchestra.utils import humanize
|
||||||
|
|
||||||
from .decorators import admin_field
|
from .decorators import admin_field
|
||||||
|
|
||||||
|
@ -131,8 +132,12 @@ def admin_date(*args, **kwargs):
|
||||||
value = get_field_value(instance, kwargs['field'])
|
value = get_field_value(instance, kwargs['field'])
|
||||||
if not value:
|
if not value:
|
||||||
return kwargs.get('default', '')
|
return kwargs.get('default', '')
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
natural = humanize.naturaldatetime(value)
|
||||||
|
else:
|
||||||
|
natural = humanize.naturaldate(value)
|
||||||
return '<span title="{0}">{1}</span>'.format(
|
return '<span title="{0}">{1}</span>'.format(
|
||||||
escape(str(value)), escape(naturaldate(value)),
|
escape(str(value)), escape(natural),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,95 +1,16 @@
|
||||||
from django import forms
|
|
||||||
from django.db import models
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.core.urlresolvers import reverse
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.admin import ChangeListDefaultFilter
|
from orchestra.admin import ChangeListDefaultFilter
|
||||||
from orchestra.admin.filters import UsedContentTypeFilter
|
|
||||||
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 orchestra.core import services
|
|
||||||
from orchestra.utils.humanize import naturaldate
|
from orchestra.utils.humanize import naturaldate
|
||||||
|
|
||||||
from .actions import BillSelectedOrders
|
from .actions import BillSelectedOrders
|
||||||
from .filters import ActiveOrderListFilter, BilledOrderListFilter
|
from .filters import ActiveOrderListFilter, BilledOrderListFilter
|
||||||
from .models import Plan, ContractedPlan, Rate, Service, Order, MetricStorage
|
from .models import Order, MetricStorage
|
||||||
|
|
||||||
|
|
||||||
class RateInline(admin.TabularInline):
|
|
||||||
model = Rate
|
|
||||||
ordering = ('plan', 'quantity')
|
|
||||||
|
|
||||||
|
|
||||||
class PlanAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('name', 'is_default', 'is_combinable', 'allow_multiple')
|
|
||||||
list_filter = ('is_default', 'is_combinable', 'allow_multiple')
|
|
||||||
inlines = [RateInline]
|
|
||||||
|
|
||||||
|
|
||||||
class ContractedPlanAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|
||||||
list_display = ('plan', 'account_link')
|
|
||||||
list_filter = ('plan__name',)
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceAdmin(admin.ModelAdmin):
|
|
||||||
list_display = (
|
|
||||||
'description', 'content_type', 'handler_type', 'num_orders', 'is_active'
|
|
||||||
)
|
|
||||||
list_filter = ('is_active', 'handler_type', UsedContentTypeFilter)
|
|
||||||
fieldsets = (
|
|
||||||
(None, {
|
|
||||||
'classes': ('wide',),
|
|
||||||
'fields': ('description', 'content_type', 'match', 'handler_type',
|
|
||||||
'is_active')
|
|
||||||
}),
|
|
||||||
(_("Billing options"), {
|
|
||||||
'classes': ('wide',),
|
|
||||||
'fields': ('billing_period', 'billing_point', 'is_fee')
|
|
||||||
}),
|
|
||||||
(_("Pricing options"), {
|
|
||||||
'classes': ('wide',),
|
|
||||||
'fields': ('metric', 'pricing_period', 'rate_algorithm',
|
|
||||||
'on_cancel', 'payment_style', 'tax', 'nominal_price')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
inlines = [RateInline]
|
|
||||||
|
|
||||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
|
||||||
""" Improve performance of account field and filter by account """
|
|
||||||
if db_field.name == 'content_type':
|
|
||||||
models = [model._meta.model_name for model in services.get()]
|
|
||||||
queryset = db_field.rel.to.objects
|
|
||||||
kwargs['queryset'] = queryset.filter(model__in=models)
|
|
||||||
if db_field.name in ['match', 'metric']:
|
|
||||||
kwargs['widget'] = forms.TextInput(attrs={'size':'160'})
|
|
||||||
return super(ServiceAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
|
||||||
|
|
||||||
def num_orders(self, service):
|
|
||||||
num = service.orders__count
|
|
||||||
url = reverse('admin:orders_order_changelist')
|
|
||||||
url += '?service=%i&is_active=True' % service.pk
|
|
||||||
return '<a href="%s">%d</a>' % (url, num)
|
|
||||||
num_orders.short_description = _("Orders")
|
|
||||||
num_orders.admin_order_field = 'orders__count'
|
|
||||||
num_orders.allow_tags = True
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
|
||||||
qs = super(ServiceAdmin, self).get_queryset(request)
|
|
||||||
# Count active orders
|
|
||||||
qs = qs.extra(select={
|
|
||||||
'orders__count': (
|
|
||||||
"SELECT COUNT(*) "
|
|
||||||
"FROM orders_order "
|
|
||||||
"WHERE orders_order.service_id = orders_service.id AND ("
|
|
||||||
" orders_order.cancelled_on IS NULL OR"
|
|
||||||
" orders_order.cancelled_on > '%s' "
|
|
||||||
")" % timezone.now()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return qs
|
|
||||||
|
|
||||||
|
|
||||||
class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
||||||
|
@ -126,14 +47,10 @@ class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
||||||
return qs.select_related('service').prefetch_related('content_object')
|
return qs.select_related('service').prefetch_related('content_object')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MetricStorageAdmin(admin.ModelAdmin):
|
class MetricStorageAdmin(admin.ModelAdmin):
|
||||||
list_display = ('order', 'value', 'created_on', 'updated_on')
|
list_display = ('order', 'value', 'created_on', 'updated_on')
|
||||||
list_filter = ('order__service',)
|
list_filter = ('order__service',)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Plan, PlanAdmin)
|
|
||||||
admin.site.register(ContractedPlan, ContractedPlanAdmin)
|
|
||||||
admin.site.register(Service, ServiceAdmin)
|
|
||||||
admin.site.register(Order, OrderAdmin)
|
admin.site.register(Order, OrderAdmin)
|
||||||
admin.site.register(MetricStorage, MetricStorageAdmin)
|
admin.site.register(MetricStorage, MetricStorageAdmin)
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
import inspect
|
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from orchestra.apps.accounts.models import Account
|
from orchestra.apps.accounts.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,135 +33,3 @@ def get_related_objects(origin, max_depth=2):
|
||||||
new_models = list(models)
|
new_models = list(models)
|
||||||
new_models.append(related)
|
new_models.append(related)
|
||||||
queue.append(new_models)
|
queue.append(new_models)
|
||||||
|
|
||||||
|
|
||||||
def get_chunks(porders, ini, end, ix=0):
|
|
||||||
if ix >= len(porders):
|
|
||||||
return [[ini, end, []]]
|
|
||||||
order = porders[ix]
|
|
||||||
ix += 1
|
|
||||||
bu = getattr(order, 'new_billed_until', order.billed_until)
|
|
||||||
if not bu or bu <= ini or order.registered_on >= end:
|
|
||||||
return get_chunks(porders, ini, end, ix=ix)
|
|
||||||
result = []
|
|
||||||
if order.registered_on < end and order.registered_on > ini:
|
|
||||||
ro = order.registered_on
|
|
||||||
result = get_chunks(porders, ini, ro, ix=ix)
|
|
||||||
ini = ro
|
|
||||||
if bu < end:
|
|
||||||
result += get_chunks(porders, bu, end, ix=ix)
|
|
||||||
end = bu
|
|
||||||
chunks = get_chunks(porders, ini, end, ix=ix)
|
|
||||||
for chunk in chunks:
|
|
||||||
chunk[2].insert(0, order)
|
|
||||||
result.append(chunk)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def cmp_billed_until_or_registered_on(a, b):
|
|
||||||
"""
|
|
||||||
1) billed_until greater first
|
|
||||||
2) registered_on smaller first
|
|
||||||
"""
|
|
||||||
if a.billed_until == b.billed_until:
|
|
||||||
return (a.registered_on-b.registered_on).days
|
|
||||||
elif a.billed_until and b.billed_until:
|
|
||||||
return (b.billed_until-a.billed_until).days
|
|
||||||
elif a.billed_until:
|
|
||||||
return (b.registered_on-a.billed_until).days
|
|
||||||
return (b.billed_until-a.registered_on).days
|
|
||||||
|
|
||||||
|
|
||||||
class Interval(object):
|
|
||||||
def __init__(self, ini, end, order=None):
|
|
||||||
self.ini = ini
|
|
||||||
self.end = end
|
|
||||||
self.order = order
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return max((self.end-self.ini).days, 0)
|
|
||||||
|
|
||||||
def __sub__(self, other):
|
|
||||||
remaining = []
|
|
||||||
if self.ini < other.ini:
|
|
||||||
remaining.append(Interval(self.ini, min(self.end, other.ini), self.order))
|
|
||||||
if self.end > other.end:
|
|
||||||
remaining.append(Interval(max(self.ini,other.end), self.end, self.order))
|
|
||||||
return remaining
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
now = timezone.now()
|
|
||||||
return "Start: %s End: %s" % ((self.ini-now).days, (self.end-now).days)
|
|
||||||
|
|
||||||
def intersect(self, other, remaining_self=None, remaining_other=None):
|
|
||||||
if remaining_self is not None:
|
|
||||||
remaining_self += (self - other)
|
|
||||||
if remaining_other is not None:
|
|
||||||
remaining_other += (other - self)
|
|
||||||
result = Interval(max(self.ini, other.ini), min(self.end, other.end), self.order)
|
|
||||||
if len(result)>0:
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def intersect_set(self, others, remaining_self=None, remaining_other=None):
|
|
||||||
intersections = []
|
|
||||||
for interval in others:
|
|
||||||
intersection = self.intersect(interval, remaining_self, remaining_other)
|
|
||||||
if intersection:
|
|
||||||
intersections.append(intersection)
|
|
||||||
return intersections
|
|
||||||
|
|
||||||
|
|
||||||
def get_intersections(order_intervals, compensations):
|
|
||||||
intersections = []
|
|
||||||
for compensation in compensations:
|
|
||||||
intersection = compensation.intersect_set(order_intervals)
|
|
||||||
length = 0
|
|
||||||
for intersection_interval in intersection:
|
|
||||||
length += len(intersection_interval)
|
|
||||||
intersections.append((length, compensation))
|
|
||||||
intersections.sort()
|
|
||||||
return intersections
|
|
||||||
|
|
||||||
|
|
||||||
def intersect(compensation, order_intervals):
|
|
||||||
# Intervals should not overlap
|
|
||||||
compensated = []
|
|
||||||
not_compensated = []
|
|
||||||
unused_compensation = []
|
|
||||||
for interval in order_intervals:
|
|
||||||
compensated.append(compensation.intersect(interval, unused_compensation, not_compensated))
|
|
||||||
return (compensated, not_compensated, unused_compensation)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_compensation(order, compensation):
|
|
||||||
remaining_order = []
|
|
||||||
remaining_compensation = []
|
|
||||||
applied_compensation = compensation.intersect_set(order, remaining_compensation, remaining_order)
|
|
||||||
return applied_compensation, remaining_order, remaining_compensation
|
|
||||||
|
|
||||||
|
|
||||||
def update_intersections(not_compensated, compensations):
|
|
||||||
# TODO can be optimized
|
|
||||||
compensation_intervals = []
|
|
||||||
for __, compensation in compensations:
|
|
||||||
compensation_intervals.append(compensation)
|
|
||||||
return get_intersections(not_compensated, compensation_intervals)
|
|
||||||
|
|
||||||
|
|
||||||
def compensate(order, compensations):
|
|
||||||
remaining_interval = [order]
|
|
||||||
ordered_intersections = get_intersections(remaining_interval, compensations)
|
|
||||||
applied_compensations = []
|
|
||||||
remaining_compensations = []
|
|
||||||
while ordered_intersections and ordered_intersections[len(ordered_intersections)-1][0]>0:
|
|
||||||
# Apply the first compensation:
|
|
||||||
__, compensation = ordered_intersections.pop()
|
|
||||||
(applied_compensation, remaining_interval, remaining_compensation) = apply_compensation(remaining_interval, compensation)
|
|
||||||
remaining_compensations += remaining_compensation
|
|
||||||
applied_compensations += applied_compensation
|
|
||||||
ordered_intersections = update_intersections(remaining_interval, ordered_intersections)
|
|
||||||
for __, compensation in ordered_intersections:
|
|
||||||
remaining_compensations.append(compensation)
|
|
||||||
return remaining_compensations, applied_compensations
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.migrations.recorder import MigrationRecorder
|
from django.db.migrations.recorder import MigrationRecorder
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
from django.db.models.loading import get_model
|
||||||
from django.db.models.signals import pre_delete, post_delete, post_save
|
from django.db.models.signals import pre_delete, post_delete, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.contrib.admin.models import LogEntry
|
from django.contrib.admin.models import LogEntry
|
||||||
|
@ -19,296 +20,10 @@ from orchestra.models import queryset
|
||||||
from orchestra.utils.apps import autodiscover
|
from orchestra.utils.apps import autodiscover
|
||||||
from orchestra.utils.python import import_class
|
from orchestra.utils.python import import_class
|
||||||
|
|
||||||
from . import helpers, settings, rating
|
from . import helpers, settings
|
||||||
from .handlers import ServiceHandler
|
from .handlers import ServiceHandler
|
||||||
|
|
||||||
|
|
||||||
class Plan(models.Model):
|
|
||||||
name = models.CharField(_("plan"), max_length=128)
|
|
||||||
is_default = models.BooleanField(_("is default"), default=False)
|
|
||||||
is_combinable = models.BooleanField(_("is combinable"), default=True)
|
|
||||||
allow_multiple = models.BooleanField(_("allow multipls"), default=False)
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class ContractedPlan(models.Model):
|
|
||||||
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='contracts')
|
|
||||||
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
|
||||||
related_name='plans')
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return str(self.plan)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
if not self.pk and not self.plan.allow_multipls:
|
|
||||||
if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists():
|
|
||||||
raise ValidationError("A contracted plan for this account already exists")
|
|
||||||
|
|
||||||
|
|
||||||
class RateQuerySet(models.QuerySet):
|
|
||||||
group_by = queryset.group_by
|
|
||||||
|
|
||||||
def by_account(self, account):
|
|
||||||
# Default allways selected
|
|
||||||
return self.filter(
|
|
||||||
Q(plan__is_default=True) |
|
|
||||||
Q(plan__contracts__account=account)
|
|
||||||
).order_by('plan', 'quantity').select_related('plan')
|
|
||||||
|
|
||||||
|
|
||||||
class Rate(models.Model):
|
|
||||||
service = models.ForeignKey('orders.Service', verbose_name=_("service"),
|
|
||||||
related_name='rates')
|
|
||||||
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='rates')
|
|
||||||
quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True)
|
|
||||||
price = models.DecimalField(_("price"), max_digits=12, decimal_places=2)
|
|
||||||
|
|
||||||
objects = RateQuerySet.as_manager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ('service', 'plan', 'quantity')
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return "{}-{}".format(str(self.price), self.quantity)
|
|
||||||
|
|
||||||
|
|
||||||
autodiscover('handlers')
|
|
||||||
|
|
||||||
|
|
||||||
class Service(models.Model):
|
|
||||||
NEVER = ''
|
|
||||||
MONTHLY = 'MONTHLY'
|
|
||||||
ANUAL = 'ANUAL'
|
|
||||||
TEN_DAYS = 'TEN_DAYS'
|
|
||||||
ONE_MONTH = 'ONE_MONTH'
|
|
||||||
ALWAYS = 'ALWAYS'
|
|
||||||
ON_REGISTER = 'ON_REGISTER'
|
|
||||||
FIXED_DATE = 'ON_FIXED_DATE'
|
|
||||||
BILLING_PERIOD = 'BILLING_PERIOD'
|
|
||||||
REGISTER_OR_RENEW = 'REGISTER_OR_RENEW'
|
|
||||||
CONCURRENT = 'CONCURRENT'
|
|
||||||
NOTHING = 'NOTHING'
|
|
||||||
DISCOUNT = 'DISCOUNT'
|
|
||||||
REFOUND = 'REFOUND'
|
|
||||||
COMPENSATE = 'COMPENSATE'
|
|
||||||
PREPAY = 'PREPAY'
|
|
||||||
POSTPAY = 'POSTPAY'
|
|
||||||
STEP_PRICE = 'STEP_PRICE'
|
|
||||||
MATCH_PRICE = 'MATCH_PRICE'
|
|
||||||
RATE_METHODS = {
|
|
||||||
STEP_PRICE: rating.step_price,
|
|
||||||
MATCH_PRICE: rating.match_price,
|
|
||||||
}
|
|
||||||
|
|
||||||
description = models.CharField(_("description"), max_length=256, unique=True)
|
|
||||||
content_type = models.ForeignKey(ContentType, verbose_name=_("content type"))
|
|
||||||
match = models.CharField(_("match"), max_length=256, blank=True)
|
|
||||||
handler_type = models.CharField(_("handler"), max_length=256, blank=True,
|
|
||||||
help_text=_("Handler used for processing this Service. A handler "
|
|
||||||
"enables customized behaviour far beyond what options "
|
|
||||||
"here allow to."),
|
|
||||||
choices=ServiceHandler.get_plugin_choices())
|
|
||||||
is_active = models.BooleanField(_("is active"), default=True)
|
|
||||||
# Billing
|
|
||||||
billing_period = models.CharField(_("billing period"), max_length=16,
|
|
||||||
help_text=_("Renewal period for recurring invoicing"),
|
|
||||||
choices=(
|
|
||||||
(NEVER, _("One time service")),
|
|
||||||
(MONTHLY, _("Monthly billing")),
|
|
||||||
(ANUAL, _("Anual billing")),
|
|
||||||
),
|
|
||||||
default=ANUAL, blank=True)
|
|
||||||
billing_point = models.CharField(_("billing point"), max_length=16,
|
|
||||||
help_text=_("Reference point for calculating the renewal date "
|
|
||||||
"on recurring invoices"),
|
|
||||||
choices=(
|
|
||||||
(ON_REGISTER, _("Registration date")),
|
|
||||||
(FIXED_DATE, _("Fixed billing date")),
|
|
||||||
),
|
|
||||||
default=FIXED_DATE)
|
|
||||||
# delayed_billing = models.CharField(_("delayed billing"), max_length=16,
|
|
||||||
# help_text=_("Period in which this service will be ignored for billing"),
|
|
||||||
# choices=(
|
|
||||||
# (NEVER, _("No delay (inmediate billing)")),
|
|
||||||
# (TEN_DAYS, _("Ten days")),
|
|
||||||
# (ONE_MONTH, _("One month")),
|
|
||||||
# ),
|
|
||||||
# default=ONE_MONTH, blank=True)
|
|
||||||
is_fee = models.BooleanField(_("is fee"), default=False,
|
|
||||||
help_text=_("Designates whether this service should be billed as "
|
|
||||||
" membership fee or not"))
|
|
||||||
# Pricing
|
|
||||||
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."))
|
|
||||||
nominal_price = models.DecimalField(_("nominal price"), max_digits=12,
|
|
||||||
decimal_places=2)
|
|
||||||
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 "
|
|
||||||
"pricing rate"),
|
|
||||||
choices=(
|
|
||||||
(BILLING_PERIOD, _("Same as billing period")),
|
|
||||||
(MONTHLY, _("Monthly data")),
|
|
||||||
(ANUAL, _("Anual data")),
|
|
||||||
),
|
|
||||||
default=BILLING_PERIOD)
|
|
||||||
rate_algorithm = models.CharField(_("rate algorithm"), max_length=16,
|
|
||||||
help_text=_("Algorithm used to interprete the rating table"),
|
|
||||||
choices=(
|
|
||||||
(STEP_PRICE, _("Step price")),
|
|
||||||
(MATCH_PRICE, _("Match price")),
|
|
||||||
),
|
|
||||||
default=STEP_PRICE)
|
|
||||||
# orders_effect = models.CharField(_("orders effect"), max_length=16,
|
|
||||||
# help_text=_("Defines the lookup behaviour when using orders for "
|
|
||||||
# "the pricing rate computation of this service."),
|
|
||||||
# choices=(
|
|
||||||
# (REGISTER_OR_RENEW, _("Register or renew events")),
|
|
||||||
# (CONCURRENT, _("Active at every given time")),
|
|
||||||
# ),
|
|
||||||
# default=CONCURRENT)
|
|
||||||
on_cancel = models.CharField(_("on cancel"), max_length=16,
|
|
||||||
help_text=_("Defines the cancellation behaviour of this service"),
|
|
||||||
choices=(
|
|
||||||
(NOTHING, _("Nothing")),
|
|
||||||
(DISCOUNT, _("Discount")),
|
|
||||||
(COMPENSATE, _("Discount and compensate")),
|
|
||||||
),
|
|
||||||
default=DISCOUNT)
|
|
||||||
payment_style = models.CharField(_("payment style"), max_length=16,
|
|
||||||
help_text=_("Designates whether this service should be paid after "
|
|
||||||
"consumtion (postpay/on demand) or prepaid"),
|
|
||||||
choices=(
|
|
||||||
(PREPAY, _("Prepay")),
|
|
||||||
(POSTPAY, _("Postpay (on demand)")),
|
|
||||||
),
|
|
||||||
default=PREPAY)
|
|
||||||
# trial_period = models.CharField(_("trial period"), max_length=16, blank=True,
|
|
||||||
# help_text=_("Period in which no charge will be issued"),
|
|
||||||
# choices=(
|
|
||||||
# (NEVER, _("No trial")),
|
|
||||||
# (TEN_DAYS, _("Ten days")),
|
|
||||||
# (ONE_MONTH, _("One month")),
|
|
||||||
# ),
|
|
||||||
# default=NEVER)
|
|
||||||
# refound_period = models.CharField(_("refound period"), max_length=16,
|
|
||||||
# help_text=_("Period in which automatic refound will be performed on "
|
|
||||||
# "service cancellation"),
|
|
||||||
# choices=(
|
|
||||||
# (NEVER, _("Never refound")),
|
|
||||||
# (TEN_DAYS, _("Ten days")),
|
|
||||||
# (ONE_MONTH, _("One month")),
|
|
||||||
# (ALWAYS, _("Always refound")),
|
|
||||||
# ),
|
|
||||||
# default=NEVER, blank=True)
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return self.description
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_services(cls, instance):
|
|
||||||
cache = caches.get_request_cache()
|
|
||||||
ct = ContentType.objects.get_for_model(instance)
|
|
||||||
services = cache.get(ct)
|
|
||||||
if services is None:
|
|
||||||
services = cls.objects.filter(content_type=ct, is_active=True)
|
|
||||||
cache.set(ct, services)
|
|
||||||
return services
|
|
||||||
|
|
||||||
# FIXME some times caching is nasty, do we really have to? make get_plugin more efficient?
|
|
||||||
# @property
|
|
||||||
@cached_property
|
|
||||||
def handler(self):
|
|
||||||
""" Accessor of this service handler instance """
|
|
||||||
if self.handler_type:
|
|
||||||
return ServiceHandler.get_plugin(self.handler_type)(self)
|
|
||||||
return ServiceHandler(self)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
content_type = self.handler.get_content_type()
|
|
||||||
if self.content_type != content_type:
|
|
||||||
msg =_("Content type must be equal to '%s'.") % str(content_type)
|
|
||||||
raise ValidationError(msg)
|
|
||||||
if not self.match:
|
|
||||||
msg =_("Match should be provided")
|
|
||||||
raise ValidationError(msg)
|
|
||||||
try:
|
|
||||||
obj = content_type.model_class().objects.all()[0]
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
attr = None
|
|
||||||
try:
|
|
||||||
bool(self.handler.matches(obj))
|
|
||||||
except Exception as exception:
|
|
||||||
attr = "Matches"
|
|
||||||
try:
|
|
||||||
metric = self.handler.get_metric(obj)
|
|
||||||
if metric is not None:
|
|
||||||
int(metric)
|
|
||||||
except Exception as exception:
|
|
||||||
attr = "Get metric"
|
|
||||||
if attr is not None:
|
|
||||||
name = type(exception).__name__
|
|
||||||
message = exception.message
|
|
||||||
msg = "{0} {1}: {2}".format(attr, name, message)
|
|
||||||
raise ValidationError(msg)
|
|
||||||
|
|
||||||
def get_pricing_period(self):
|
|
||||||
if self.pricing_period == self.BILLING_PERIOD:
|
|
||||||
return self.billing_period
|
|
||||||
return self.pricing_period
|
|
||||||
|
|
||||||
def get_price(self, account, metric, rates=None, position=None):
|
|
||||||
"""
|
|
||||||
if position is provided an specific price for that position is returned,
|
|
||||||
accumulated price is returned otherwise
|
|
||||||
"""
|
|
||||||
if rates is None:
|
|
||||||
rates = self.get_rates(account)
|
|
||||||
if not rates:
|
|
||||||
rates = [{
|
|
||||||
'quantity': metric,
|
|
||||||
'price': self.nominal_price,
|
|
||||||
}]
|
|
||||||
else:
|
|
||||||
rates = self.rate_method(rates, metric)
|
|
||||||
counter = 0
|
|
||||||
if position is None:
|
|
||||||
ant_counter = 0
|
|
||||||
accumulated = 0
|
|
||||||
for rate in rates:
|
|
||||||
counter += rate['quantity']
|
|
||||||
if counter >= metric:
|
|
||||||
counter = metric
|
|
||||||
accumulated += (counter - ant_counter) * rate['price']
|
|
||||||
return float(accumulated)
|
|
||||||
ant_counter = counter
|
|
||||||
accumulated += rate['price'] * rate['quantity']
|
|
||||||
else:
|
|
||||||
for rate in rates:
|
|
||||||
counter += rate['quantity']
|
|
||||||
if counter >= position:
|
|
||||||
return float(rate['price'])
|
|
||||||
|
|
||||||
def get_rates(self, account, cache=True):
|
|
||||||
# rates are cached per account
|
|
||||||
if not cache:
|
|
||||||
return self.rates.by_account(account)
|
|
||||||
if not hasattr(self, '__cached_rates'):
|
|
||||||
self.__cached_rates = {}
|
|
||||||
rates = self.__cached_rates.get(account.id, self.rates.by_account(account))
|
|
||||||
return rates
|
|
||||||
|
|
||||||
@property
|
|
||||||
def rate_method(self):
|
|
||||||
return self.RATE_METHODS[self.rate_algorithm]
|
|
||||||
|
|
||||||
|
|
||||||
class OrderQuerySet(models.QuerySet):
|
class OrderQuerySet(models.QuerySet):
|
||||||
group_by = queryset.group_by
|
group_by = queryset.group_by
|
||||||
|
|
||||||
|
@ -358,10 +73,10 @@ class Order(models.Model):
|
||||||
related_name='orders')
|
related_name='orders')
|
||||||
content_type = models.ForeignKey(ContentType)
|
content_type = models.ForeignKey(ContentType)
|
||||||
object_id = models.PositiveIntegerField(null=True)
|
object_id = models.PositiveIntegerField(null=True)
|
||||||
service = models.ForeignKey(Service, verbose_name=_("service"),
|
service = models.ForeignKey(settings.ORDERS_SERVICE_MODEL,
|
||||||
related_name='orders')
|
verbose_name=_("service"), related_name='orders')
|
||||||
registered_on = models.DateField(_("registered on"), auto_now_add=True) # TODO datetime field?
|
registered_on = models.DateField(_("registered"), auto_now_add=True) # TODO datetime field?
|
||||||
cancelled_on = models.DateField(_("cancelled on"), null=True, blank=True)
|
cancelled_on = models.DateField(_("cancelled"), null=True, blank=True)
|
||||||
billed_on = models.DateField(_("billed 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)
|
billed_until = models.DateField(_("billed until"), null=True, blank=True)
|
||||||
ignore = models.BooleanField(_("ignore"), default=False)
|
ignore = models.BooleanField(_("ignore"), default=False)
|
||||||
|
@ -387,6 +102,7 @@ class Order(models.Model):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_orders(cls, instance):
|
def update_orders(cls, instance):
|
||||||
|
Service = get_model(*settings.ORDERS_SERVICE_MODEL.split('.'))
|
||||||
for service in Service.get_services(instance):
|
for service in Service.get_services(instance):
|
||||||
orders = Order.objects.by_object(instance, service=service).active()
|
orders = Order.objects.by_object(instance, service=service).active()
|
||||||
if service.handler.matches(instance):
|
if service.handler.matches(instance):
|
||||||
|
@ -447,6 +163,7 @@ class MetricStorage(models.Model):
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# TODO If this happens to be very costly then, consider an additional
|
# TODO If this happens to be very costly then, consider an additional
|
||||||
# implementation when runnning within a request/Response cycle, more efficient :)
|
# implementation when runnning within a request/Response cycle, more efficient :)
|
||||||
@receiver(pre_delete, dispatch_uid="orders.cancel_orders")
|
@receiver(pre_delete, dispatch_uid="orders.cancel_orders")
|
||||||
|
@ -461,7 +178,7 @@ def cancel_orders(sender, **kwargs):
|
||||||
@receiver(post_delete, dispatch_uid="orders.update_orders_post_delete")
|
@receiver(post_delete, dispatch_uid="orders.update_orders_post_delete")
|
||||||
def update_orders(sender, **kwargs):
|
def update_orders(sender, **kwargs):
|
||||||
exclude = (
|
exclude = (
|
||||||
MetricStorage, LogEntry, Order, Service, ContentType, MigrationRecorder.Migration
|
MetricStorage, LogEntry, Order, ContentType, MigrationRecorder.Migration
|
||||||
)
|
)
|
||||||
if sender not in exclude:
|
if sender not in exclude:
|
||||||
instance = kwargs['instance']
|
instance = kwargs['instance']
|
||||||
|
@ -474,5 +191,3 @@ def update_orders(sender, **kwargs):
|
||||||
|
|
||||||
|
|
||||||
accounts.register(Order)
|
accounts.register(Order)
|
||||||
accounts.register(ContractedPlan)
|
|
||||||
services.register(ContractedPlan, menu=False)
|
|
||||||
|
|
|
@ -2,25 +2,8 @@ from django.conf import settings
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
ORDERS_SERVICE_TAXES = getattr(settings, 'ORDERS_SERVICE_TAXES', (
|
|
||||||
(0, _("Duty free")),
|
|
||||||
(7, _("7%")),
|
|
||||||
(21, _("21%")),
|
|
||||||
))
|
|
||||||
|
|
||||||
ORDERS_SERVICE_DEFAUL_TAX = getattr(settings, 'ORDERS_SERVICE_DFAULT_TAX', 0)
|
|
||||||
|
|
||||||
|
|
||||||
ORDERS_SERVICE_ANUAL_BILLING_MONTH = getattr(settings, 'ORDERS_SERVICE_ANUAL_BILLING_MONTH', 4)
|
|
||||||
|
|
||||||
|
|
||||||
ORDERS_BILLING_BACKEND = getattr(settings, 'ORDERS_BILLING_BACKEND',
|
ORDERS_BILLING_BACKEND = getattr(settings, 'ORDERS_BILLING_BACKEND',
|
||||||
'orchestra.apps.orders.billing.BillsBackend')
|
'orchestra.apps.orders.billing.BillsBackend')
|
||||||
|
|
||||||
|
|
||||||
ORDERS_PLANS = getattr(settings, 'ORDERS_PLANS', (
|
ORDERS_SERVICE_MODEL = getattr(settings, 'ORDERS_SERVICE_MODEL', 'services.Service')
|
||||||
('basic', _("Basic")),
|
|
||||||
('advanced', _("Advanced")),
|
|
||||||
))
|
|
||||||
|
|
||||||
ORDERS_DEFAULT_PLAN = getattr(settings, 'ORDERS_DEFAULT_PLAN', 'basic')
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.admin.filters import UsedContentTypeFilter
|
||||||
|
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||||
|
from orchestra.core import services
|
||||||
|
|
||||||
|
from .models import Plan, ContractedPlan, Rate, Service
|
||||||
|
|
||||||
|
|
||||||
|
class RateInline(admin.TabularInline):
|
||||||
|
model = Rate
|
||||||
|
ordering = ('plan', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
|
class PlanAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'is_default', 'is_combinable', 'allow_multiple')
|
||||||
|
list_filter = ('is_default', 'is_combinable', 'allow_multiple')
|
||||||
|
inlines = [RateInline]
|
||||||
|
|
||||||
|
|
||||||
|
class ContractedPlanAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||||
|
list_display = ('plan', 'account_link')
|
||||||
|
list_filter = ('plan__name',)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
'description', 'content_type', 'handler_type', 'num_orders', 'is_active'
|
||||||
|
)
|
||||||
|
list_filter = ('is_active', 'handler_type', UsedContentTypeFilter)
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('description', 'content_type', 'match', 'handler_type',
|
||||||
|
'is_active')
|
||||||
|
}),
|
||||||
|
(_("Billing options"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('billing_period', 'billing_point', 'is_fee')
|
||||||
|
}),
|
||||||
|
(_("Pricing options"), {
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('metric', 'pricing_period', 'rate_algorithm',
|
||||||
|
'on_cancel', 'payment_style', 'tax', 'nominal_price')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
inlines = [RateInline]
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
""" Improve performance of account field and filter by account """
|
||||||
|
if db_field.name == 'content_type':
|
||||||
|
models = [model._meta.model_name for model in services.get()]
|
||||||
|
queryset = db_field.rel.to.objects
|
||||||
|
kwargs['queryset'] = queryset.filter(model__in=models)
|
||||||
|
if db_field.name in ['match', 'metric']:
|
||||||
|
kwargs['widget'] = forms.TextInput(attrs={'size':'160'})
|
||||||
|
return super(ServiceAdmin, self).formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
|
||||||
|
def num_orders(self, service):
|
||||||
|
num = service.orders__count
|
||||||
|
url = reverse('admin:orders_order_changelist')
|
||||||
|
url += '?service=%i&is_active=True' % service.pk
|
||||||
|
return '<a href="%s">%d</a>' % (url, num)
|
||||||
|
num_orders.short_description = _("Orders")
|
||||||
|
num_orders.admin_order_field = 'orders__count'
|
||||||
|
num_orders.allow_tags = True
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
qs = super(ServiceAdmin, self).get_queryset(request)
|
||||||
|
# Count active orders
|
||||||
|
qs = qs.extra(select={
|
||||||
|
'orders__count': (
|
||||||
|
"SELECT COUNT(*) "
|
||||||
|
"FROM orders_order "
|
||||||
|
"WHERE orders_order.service_id = services_service.id AND ("
|
||||||
|
" orders_order.cancelled_on IS NULL OR"
|
||||||
|
" orders_order.cancelled_on > '%s' "
|
||||||
|
")" % timezone.now()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Plan, PlanAdmin)
|
||||||
|
admin.site.register(ContractedPlan, ContractedPlanAdmin)
|
||||||
|
admin.site.register(Service, ServiceAdmin)
|
|
@ -78,7 +78,7 @@ class ServiceHandler(plugins.Plugin):
|
||||||
month = order.registered_on.month
|
month = order.registered_on.month
|
||||||
day = order.registered_on.day
|
day = order.registered_on.day
|
||||||
elif self.billing_point == self.FIXED_DATE:
|
elif self.billing_point == self.FIXED_DATE:
|
||||||
month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
|
month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH
|
||||||
day = 1
|
day = 1
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(msg)
|
raise NotImplementedError(msg)
|
||||||
|
@ -276,8 +276,7 @@ class ServiceHandler(plugins.Plugin):
|
||||||
order.new_billed_until = bp
|
order.new_billed_until = bp
|
||||||
ini = min(ini, cini)
|
ini = min(ini, cini)
|
||||||
end = max(end, bp)
|
end = max(end, bp)
|
||||||
from .models import Order
|
related_orders = account.orders.filter(service=self.service)
|
||||||
related_orders = Order.objects.filter(service=self.service, account=account)
|
|
||||||
if self.on_cancel == self.COMPENSATE:
|
if self.on_cancel == self.COMPENSATE:
|
||||||
# Get orders pending for compensation
|
# Get orders pending for compensation
|
||||||
givers = related_orders.filter_givers(ini, end)
|
givers = related_orders.filter_givers(ini, end)
|
|
@ -0,0 +1,134 @@
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def get_chunks(porders, ini, end, ix=0):
|
||||||
|
if ix >= len(porders):
|
||||||
|
return [[ini, end, []]]
|
||||||
|
order = porders[ix]
|
||||||
|
ix += 1
|
||||||
|
bu = getattr(order, 'new_billed_until', order.billed_until)
|
||||||
|
if not bu or bu <= ini or order.registered_on >= end:
|
||||||
|
return get_chunks(porders, ini, end, ix=ix)
|
||||||
|
result = []
|
||||||
|
if order.registered_on < end and order.registered_on > ini:
|
||||||
|
ro = order.registered_on
|
||||||
|
result = get_chunks(porders, ini, ro, ix=ix)
|
||||||
|
ini = ro
|
||||||
|
if bu < end:
|
||||||
|
result += get_chunks(porders, bu, end, ix=ix)
|
||||||
|
end = bu
|
||||||
|
chunks = get_chunks(porders, ini, end, ix=ix)
|
||||||
|
for chunk in chunks:
|
||||||
|
chunk[2].insert(0, order)
|
||||||
|
result.append(chunk)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cmp_billed_until_or_registered_on(a, b):
|
||||||
|
"""
|
||||||
|
1) billed_until greater first
|
||||||
|
2) registered_on smaller first
|
||||||
|
"""
|
||||||
|
if a.billed_until == b.billed_until:
|
||||||
|
# Use pk which is more reliable than registered_on date
|
||||||
|
return a.id-b.id
|
||||||
|
elif a.billed_until and b.billed_until:
|
||||||
|
return (b.billed_until-a.billed_until).days
|
||||||
|
elif a.billed_until:
|
||||||
|
return (b.registered_on-a.billed_until).days
|
||||||
|
return (b.billed_until-a.registered_on).days
|
||||||
|
|
||||||
|
|
||||||
|
class Interval(object):
|
||||||
|
def __init__(self, ini, end, order=None):
|
||||||
|
self.ini = ini
|
||||||
|
self.end = end
|
||||||
|
self.order = order
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return max((self.end-self.ini).days, 0)
|
||||||
|
|
||||||
|
def __sub__(self, other):
|
||||||
|
remaining = []
|
||||||
|
if self.ini < other.ini:
|
||||||
|
remaining.append(Interval(self.ini, min(self.end, other.ini), self.order))
|
||||||
|
if self.end > other.end:
|
||||||
|
remaining.append(Interval(max(self.ini,other.end), self.end, self.order))
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
now = timezone.now()
|
||||||
|
return "Start: %s End: %s" % ((self.ini-now).days, (self.end-now).days)
|
||||||
|
|
||||||
|
def intersect(self, other, remaining_self=None, remaining_other=None):
|
||||||
|
if remaining_self is not None:
|
||||||
|
remaining_self += (self - other)
|
||||||
|
if remaining_other is not None:
|
||||||
|
remaining_other += (other - self)
|
||||||
|
result = Interval(max(self.ini, other.ini), min(self.end, other.end), self.order)
|
||||||
|
if len(result)>0:
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def intersect_set(self, others, remaining_self=None, remaining_other=None):
|
||||||
|
intersections = []
|
||||||
|
for interval in others:
|
||||||
|
intersection = self.intersect(interval, remaining_self, remaining_other)
|
||||||
|
if intersection:
|
||||||
|
intersections.append(intersection)
|
||||||
|
return intersections
|
||||||
|
|
||||||
|
|
||||||
|
def get_intersections(order_intervals, compensations):
|
||||||
|
intersections = []
|
||||||
|
for compensation in compensations:
|
||||||
|
intersection = compensation.intersect_set(order_intervals)
|
||||||
|
length = 0
|
||||||
|
for intersection_interval in intersection:
|
||||||
|
length += len(intersection_interval)
|
||||||
|
intersections.append((length, compensation))
|
||||||
|
intersections.sort()
|
||||||
|
return intersections
|
||||||
|
|
||||||
|
|
||||||
|
def intersect(compensation, order_intervals):
|
||||||
|
# Intervals should not overlap
|
||||||
|
compensated = []
|
||||||
|
not_compensated = []
|
||||||
|
unused_compensation = []
|
||||||
|
for interval in order_intervals:
|
||||||
|
compensated.append(compensation.intersect(interval, unused_compensation, not_compensated))
|
||||||
|
return (compensated, not_compensated, unused_compensation)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_compensation(order, compensation):
|
||||||
|
remaining_order = []
|
||||||
|
remaining_compensation = []
|
||||||
|
applied_compensation = compensation.intersect_set(order, remaining_compensation, remaining_order)
|
||||||
|
return applied_compensation, remaining_order, remaining_compensation
|
||||||
|
|
||||||
|
|
||||||
|
def update_intersections(not_compensated, compensations):
|
||||||
|
# TODO can be optimized
|
||||||
|
compensation_intervals = []
|
||||||
|
for __, compensation in compensations:
|
||||||
|
compensation_intervals.append(compensation)
|
||||||
|
return get_intersections(not_compensated, compensation_intervals)
|
||||||
|
|
||||||
|
|
||||||
|
def compensate(order, compensations):
|
||||||
|
remaining_interval = [order]
|
||||||
|
ordered_intersections = get_intersections(remaining_interval, compensations)
|
||||||
|
applied_compensations = []
|
||||||
|
remaining_compensations = []
|
||||||
|
while ordered_intersections and ordered_intersections[len(ordered_intersections)-1][0]>0:
|
||||||
|
# Apply the first compensation:
|
||||||
|
__, compensation = ordered_intersections.pop()
|
||||||
|
(applied_compensation, remaining_interval, remaining_compensation) = apply_compensation(remaining_interval, compensation)
|
||||||
|
remaining_compensations += remaining_compensation
|
||||||
|
applied_compensations += applied_compensation
|
||||||
|
ordered_intersections = update_intersections(remaining_interval, ordered_intersections)
|
||||||
|
for __, compensation in ordered_intersections:
|
||||||
|
remaining_compensations.append(compensation)
|
||||||
|
return remaining_compensations, applied_compensations
|
|
@ -0,0 +1,311 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import F, Q
|
||||||
|
from django.db.models.signals import pre_delete, post_delete, post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.contrib.contenttypes import generic
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.validators import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core import caches, services, accounts
|
||||||
|
from orchestra.models import queryset
|
||||||
|
from orchestra.utils.apps import autodiscover
|
||||||
|
from orchestra.utils.python import import_class
|
||||||
|
|
||||||
|
from . import helpers, settings, rating
|
||||||
|
from .handlers import ServiceHandler
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(models.Model):
|
||||||
|
name = models.CharField(_("plan"), max_length=128)
|
||||||
|
is_default = models.BooleanField(_("is default"), default=False)
|
||||||
|
is_combinable = models.BooleanField(_("is combinable"), default=True)
|
||||||
|
allow_multiple = models.BooleanField(_("allow multipls"), default=False)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ContractedPlan(models.Model):
|
||||||
|
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='contracts')
|
||||||
|
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
||||||
|
related_name='plans')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return str(self.plan)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if not self.pk and not self.plan.allow_multipls:
|
||||||
|
if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists():
|
||||||
|
raise ValidationError("A contracted plan for this account already exists")
|
||||||
|
|
||||||
|
|
||||||
|
class RateQuerySet(models.QuerySet):
|
||||||
|
group_by = queryset.group_by
|
||||||
|
|
||||||
|
def by_account(self, account):
|
||||||
|
# Default allways selected
|
||||||
|
return self.filter(
|
||||||
|
Q(plan__is_default=True) |
|
||||||
|
Q(plan__contracts__account=account)
|
||||||
|
).order_by('plan', 'quantity').select_related('plan')
|
||||||
|
|
||||||
|
|
||||||
|
class Rate(models.Model):
|
||||||
|
service = models.ForeignKey('services.Service', verbose_name=_("service"),
|
||||||
|
related_name='rates')
|
||||||
|
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='rates')
|
||||||
|
quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True)
|
||||||
|
price = models.DecimalField(_("price"), max_digits=12, decimal_places=2)
|
||||||
|
|
||||||
|
objects = RateQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('service', 'plan', 'quantity')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return "{}-{}".format(str(self.price), self.quantity)
|
||||||
|
|
||||||
|
|
||||||
|
autodiscover('handlers')
|
||||||
|
|
||||||
|
|
||||||
|
class Service(models.Model):
|
||||||
|
NEVER = ''
|
||||||
|
MONTHLY = 'MONTHLY'
|
||||||
|
ANUAL = 'ANUAL'
|
||||||
|
TEN_DAYS = 'TEN_DAYS'
|
||||||
|
ONE_MONTH = 'ONE_MONTH'
|
||||||
|
ALWAYS = 'ALWAYS'
|
||||||
|
ON_REGISTER = 'ON_REGISTER'
|
||||||
|
FIXED_DATE = 'ON_FIXED_DATE'
|
||||||
|
BILLING_PERIOD = 'BILLING_PERIOD'
|
||||||
|
REGISTER_OR_RENEW = 'REGISTER_OR_RENEW'
|
||||||
|
CONCURRENT = 'CONCURRENT'
|
||||||
|
NOTHING = 'NOTHING'
|
||||||
|
DISCOUNT = 'DISCOUNT'
|
||||||
|
REFOUND = 'REFOUND'
|
||||||
|
COMPENSATE = 'COMPENSATE'
|
||||||
|
PREPAY = 'PREPAY'
|
||||||
|
POSTPAY = 'POSTPAY'
|
||||||
|
STEP_PRICE = 'STEP_PRICE'
|
||||||
|
MATCH_PRICE = 'MATCH_PRICE'
|
||||||
|
RATE_METHODS = {
|
||||||
|
STEP_PRICE: rating.step_price,
|
||||||
|
MATCH_PRICE: rating.match_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
description = models.CharField(_("description"), max_length=256, unique=True)
|
||||||
|
content_type = models.ForeignKey(ContentType, verbose_name=_("content type"))
|
||||||
|
match = models.CharField(_("match"), max_length=256, blank=True)
|
||||||
|
handler_type = models.CharField(_("handler"), max_length=256, blank=True,
|
||||||
|
help_text=_("Handler used for processing this Service. A handler "
|
||||||
|
"enables customized behaviour far beyond what options "
|
||||||
|
"here allow to."),
|
||||||
|
choices=ServiceHandler.get_plugin_choices())
|
||||||
|
is_active = models.BooleanField(_("is active"), default=True)
|
||||||
|
# Billing
|
||||||
|
billing_period = models.CharField(_("billing period"), max_length=16,
|
||||||
|
help_text=_("Renewal period for recurring invoicing"),
|
||||||
|
choices=(
|
||||||
|
(NEVER, _("One time service")),
|
||||||
|
(MONTHLY, _("Monthly billing")),
|
||||||
|
(ANUAL, _("Anual billing")),
|
||||||
|
),
|
||||||
|
default=ANUAL, blank=True)
|
||||||
|
billing_point = models.CharField(_("billing point"), max_length=16,
|
||||||
|
help_text=_("Reference point for calculating the renewal date "
|
||||||
|
"on recurring invoices"),
|
||||||
|
choices=(
|
||||||
|
(ON_REGISTER, _("Registration date")),
|
||||||
|
(FIXED_DATE, _("Fixed billing date")),
|
||||||
|
),
|
||||||
|
default=FIXED_DATE)
|
||||||
|
# delayed_billing = models.CharField(_("delayed billing"), max_length=16,
|
||||||
|
# help_text=_("Period in which this service will be ignored for billing"),
|
||||||
|
# choices=(
|
||||||
|
# (NEVER, _("No delay (inmediate billing)")),
|
||||||
|
# (TEN_DAYS, _("Ten days")),
|
||||||
|
# (ONE_MONTH, _("One month")),
|
||||||
|
# ),
|
||||||
|
# default=ONE_MONTH, blank=True)
|
||||||
|
is_fee = models.BooleanField(_("is fee"), default=False,
|
||||||
|
help_text=_("Designates whether this service should be billed as "
|
||||||
|
" membership fee or not"))
|
||||||
|
# Pricing
|
||||||
|
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."))
|
||||||
|
nominal_price = models.DecimalField(_("nominal price"), max_digits=12,
|
||||||
|
decimal_places=2)
|
||||||
|
tax = models.PositiveIntegerField(_("tax"), choices=settings.SERVICES_SERVICE_TAXES,
|
||||||
|
default=settings.SERVICES_SERVICE_DEFAUL_TAX)
|
||||||
|
pricing_period = models.CharField(_("pricing period"), max_length=16,
|
||||||
|
help_text=_("Period used for calculating the metric used on the "
|
||||||
|
"pricing rate"),
|
||||||
|
choices=(
|
||||||
|
(BILLING_PERIOD, _("Same as billing period")),
|
||||||
|
(MONTHLY, _("Monthly data")),
|
||||||
|
(ANUAL, _("Anual data")),
|
||||||
|
),
|
||||||
|
default=BILLING_PERIOD)
|
||||||
|
rate_algorithm = models.CharField(_("rate algorithm"), max_length=16,
|
||||||
|
help_text=_("Algorithm used to interprete the rating table"),
|
||||||
|
choices=(
|
||||||
|
(STEP_PRICE, _("Step price")),
|
||||||
|
(MATCH_PRICE, _("Match price")),
|
||||||
|
),
|
||||||
|
default=STEP_PRICE)
|
||||||
|
# orders_effect = models.CharField(_("orders effect"), max_length=16,
|
||||||
|
# help_text=_("Defines the lookup behaviour when using orders for "
|
||||||
|
# "the pricing rate computation of this service."),
|
||||||
|
# choices=(
|
||||||
|
# (REGISTER_OR_RENEW, _("Register or renew events")),
|
||||||
|
# (CONCURRENT, _("Active at every given time")),
|
||||||
|
# ),
|
||||||
|
# default=CONCURRENT)
|
||||||
|
on_cancel = models.CharField(_("on cancel"), max_length=16,
|
||||||
|
help_text=_("Defines the cancellation behaviour of this service"),
|
||||||
|
choices=(
|
||||||
|
(NOTHING, _("Nothing")),
|
||||||
|
(DISCOUNT, _("Discount")),
|
||||||
|
(COMPENSATE, _("Discount and compensate")),
|
||||||
|
),
|
||||||
|
default=DISCOUNT)
|
||||||
|
payment_style = models.CharField(_("payment style"), max_length=16,
|
||||||
|
help_text=_("Designates whether this service should be paid after "
|
||||||
|
"consumtion (postpay/on demand) or prepaid"),
|
||||||
|
choices=(
|
||||||
|
(PREPAY, _("Prepay")),
|
||||||
|
(POSTPAY, _("Postpay (on demand)")),
|
||||||
|
),
|
||||||
|
default=PREPAY)
|
||||||
|
# trial_period = models.CharField(_("trial period"), max_length=16, blank=True,
|
||||||
|
# help_text=_("Period in which no charge will be issued"),
|
||||||
|
# choices=(
|
||||||
|
# (NEVER, _("No trial")),
|
||||||
|
# (TEN_DAYS, _("Ten days")),
|
||||||
|
# (ONE_MONTH, _("One month")),
|
||||||
|
# ),
|
||||||
|
# default=NEVER)
|
||||||
|
# refound_period = models.CharField(_("refound period"), max_length=16,
|
||||||
|
# help_text=_("Period in which automatic refound will be performed on "
|
||||||
|
# "service cancellation"),
|
||||||
|
# choices=(
|
||||||
|
# (NEVER, _("Never refound")),
|
||||||
|
# (TEN_DAYS, _("Ten days")),
|
||||||
|
# (ONE_MONTH, _("One month")),
|
||||||
|
# (ALWAYS, _("Always refound")),
|
||||||
|
# ),
|
||||||
|
# default=NEVER, blank=True)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.description
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_services(cls, instance):
|
||||||
|
cache = caches.get_request_cache()
|
||||||
|
ct = ContentType.objects.get_for_model(instance)
|
||||||
|
services = cache.get(ct)
|
||||||
|
if services is None:
|
||||||
|
services = cls.objects.filter(content_type=ct, is_active=True)
|
||||||
|
cache.set(ct, services)
|
||||||
|
return services
|
||||||
|
|
||||||
|
# FIXME some times caching is nasty, do we really have to? make get_plugin more efficient?
|
||||||
|
# @property
|
||||||
|
@cached_property
|
||||||
|
def handler(self):
|
||||||
|
""" Accessor of this service handler instance """
|
||||||
|
if self.handler_type:
|
||||||
|
return ServiceHandler.get_plugin(self.handler_type)(self)
|
||||||
|
return ServiceHandler(self)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
content_type = self.handler.get_content_type()
|
||||||
|
if self.content_type != content_type:
|
||||||
|
msg =_("Content type must be equal to '%s'.") % str(content_type)
|
||||||
|
raise ValidationError(msg)
|
||||||
|
if not self.match:
|
||||||
|
msg =_("Match should be provided")
|
||||||
|
raise ValidationError(msg)
|
||||||
|
try:
|
||||||
|
obj = content_type.model_class().objects.all()[0]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
attr = None
|
||||||
|
try:
|
||||||
|
bool(self.handler.matches(obj))
|
||||||
|
except Exception as exception:
|
||||||
|
attr = "Matches"
|
||||||
|
try:
|
||||||
|
metric = self.handler.get_metric(obj)
|
||||||
|
if metric is not None:
|
||||||
|
int(metric)
|
||||||
|
except Exception as exception:
|
||||||
|
attr = "Get metric"
|
||||||
|
if attr is not None:
|
||||||
|
name = type(exception).__name__
|
||||||
|
message = exception.message
|
||||||
|
msg = "{0} {1}: {2}".format(attr, name, message)
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
def get_pricing_period(self):
|
||||||
|
if self.pricing_period == self.BILLING_PERIOD:
|
||||||
|
return self.billing_period
|
||||||
|
return self.pricing_period
|
||||||
|
|
||||||
|
def get_price(self, account, metric, rates=None, position=None):
|
||||||
|
"""
|
||||||
|
if position is provided an specific price for that position is returned,
|
||||||
|
accumulated price is returned otherwise
|
||||||
|
"""
|
||||||
|
if rates is None:
|
||||||
|
rates = self.get_rates(account)
|
||||||
|
if not rates:
|
||||||
|
rates = [{
|
||||||
|
'quantity': metric,
|
||||||
|
'price': self.nominal_price,
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
rates = self.rate_method(rates, metric)
|
||||||
|
counter = 0
|
||||||
|
if position is None:
|
||||||
|
ant_counter = 0
|
||||||
|
accumulated = 0
|
||||||
|
for rate in rates:
|
||||||
|
counter += rate['quantity']
|
||||||
|
if counter >= metric:
|
||||||
|
counter = metric
|
||||||
|
accumulated += (counter - ant_counter) * rate['price']
|
||||||
|
return float(accumulated)
|
||||||
|
ant_counter = counter
|
||||||
|
accumulated += rate['price'] * rate['quantity']
|
||||||
|
else:
|
||||||
|
for rate in rates:
|
||||||
|
counter += rate['quantity']
|
||||||
|
if counter >= position:
|
||||||
|
return float(rate['price'])
|
||||||
|
|
||||||
|
def get_rates(self, account, cache=True):
|
||||||
|
# rates are cached per account
|
||||||
|
if not cache:
|
||||||
|
return self.rates.by_account(account)
|
||||||
|
if not hasattr(self, '__cached_rates'):
|
||||||
|
self.__cached_rates = {}
|
||||||
|
rates = self.__cached_rates.get(account.id, self.rates.by_account(account))
|
||||||
|
return rates
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rate_method(self):
|
||||||
|
return self.RATE_METHODS[self.rate_algorithm]
|
||||||
|
|
||||||
|
|
||||||
|
accounts.register(ContractedPlan)
|
||||||
|
services.register(ContractedPlan, menu=False)
|
|
@ -0,0 +1,14 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
SERVICES_SERVICE_TAXES = getattr(settings, 'SERVICES_SERVICE_TAXES', (
|
||||||
|
(0, _("Duty free")),
|
||||||
|
(7, _("7%")),
|
||||||
|
(21, _("21%")),
|
||||||
|
))
|
||||||
|
|
||||||
|
SERVICES_SERVICE_DEFAUL_TAX = getattr(settings, 'ORDERS_SERVICE_DFAULT_TAX', 0)
|
||||||
|
|
||||||
|
|
||||||
|
SERVICES_SERVICE_ANUAL_BILLING_MONTH = getattr(settings, 'SERVICES_SERVICE_ANUAL_BILLING_MONTH', 4)
|
|
@ -0,0 +1,94 @@
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dateutil import relativedelta
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from orchestra.apps.accounts.models import Account
|
||||||
|
from orchestra.apps.users.models import User
|
||||||
|
from orchestra.utils.tests import BaseTestCase, random_ascii
|
||||||
|
|
||||||
|
from ... import settings
|
||||||
|
from ...models import Service
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTests(BaseTestCase):
|
||||||
|
DEPENDENCIES = (
|
||||||
|
'orchestra.apps.orders',
|
||||||
|
'orchestra.apps.users',
|
||||||
|
'orchestra.apps.users.roles.posix',
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_account(self):
|
||||||
|
account = Account.objects.create()
|
||||||
|
user = User.objects.create_user(username='rata_palida', account=account)
|
||||||
|
account.user = user
|
||||||
|
account.save()
|
||||||
|
return account
|
||||||
|
|
||||||
|
def create_ftp_service(self):
|
||||||
|
service = Service.objects.create(
|
||||||
|
description="FTP Account",
|
||||||
|
content_type=ContentType.objects.get_for_model(User),
|
||||||
|
match='not user.is_main and user.has_posix()',
|
||||||
|
billing_period=Service.ANUAL,
|
||||||
|
billing_point=Service.FIXED_DATE,
|
||||||
|
is_fee=False,
|
||||||
|
metric='',
|
||||||
|
pricing_period=Service.BILLING_PERIOD,
|
||||||
|
rate_algorithm=Service.STEP_PRICE,
|
||||||
|
on_cancel=Service.DISCOUNT,
|
||||||
|
payment_style=Service.PREPAY,
|
||||||
|
tax=0,
|
||||||
|
nominal_price=10,
|
||||||
|
)
|
||||||
|
return service
|
||||||
|
|
||||||
|
def create_ftp(self, account=None):
|
||||||
|
username = '%s_ftp' % random_ascii(10)
|
||||||
|
if not account:
|
||||||
|
account = self.create_account()
|
||||||
|
user = User.objects.create_user(username=username, account=account)
|
||||||
|
POSIX = user._meta.get_field_by_name('posix')[0].model
|
||||||
|
POSIX.objects.create(user=user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def test_ftp_account_1_year_fiexed(self):
|
||||||
|
service = self.create_ftp_service()
|
||||||
|
user = self.create_ftp()
|
||||||
|
bp = timezone.now().date() + relativedelta.relativedelta(years=1)
|
||||||
|
bills = service.orders.bill(billing_point=bp, fixed_point=True)
|
||||||
|
self.assertEqual(10, bills[0].get_total())
|
||||||
|
|
||||||
|
def test_ftp_account_2_year_fiexed(self):
|
||||||
|
service = self.create_ftp_service()
|
||||||
|
user = self.create_ftp()
|
||||||
|
bp = timezone.now().date() + relativedelta.relativedelta(years=2)
|
||||||
|
bills = service.orders.bill(billing_point=bp, fixed_point=True)
|
||||||
|
self.assertEqual(20, bills[0].get_total())
|
||||||
|
|
||||||
|
def test_ftp_account_6_month_fixed(self):
|
||||||
|
service = self.create_ftp_service()
|
||||||
|
self.create_ftp()
|
||||||
|
bp = timezone.now().date() + relativedelta.relativedelta(months=6)
|
||||||
|
bills = service.orders.bill(billing_point=bp, fixed_point=True)
|
||||||
|
self.assertEqual(5, bills[0].get_total())
|
||||||
|
|
||||||
|
def test_ftp_account_next_billing_point(self):
|
||||||
|
service = self.create_ftp_service()
|
||||||
|
self.create_ftp()
|
||||||
|
now = timezone.now()
|
||||||
|
bp_month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH
|
||||||
|
if now.month > bp_month:
|
||||||
|
bp = datetime.datetime(year=now.year+1, month=bp_month,
|
||||||
|
day=1, tzinfo=timezone.get_current_timezone())
|
||||||
|
else:
|
||||||
|
bp = datetime.datetime(year=now.year, month=bp_month,
|
||||||
|
day=1, tzinfo=timezone.get_current_timezone())
|
||||||
|
bills = service.orders.bill(billing_point=now, fixed_point=False)
|
||||||
|
size = decimal.Decimal((bp - now).days)/365
|
||||||
|
error = decimal.Decimal(0.05)
|
||||||
|
self.assertGreater(10*size+error*(10*size), bills[0].get_total())
|
||||||
|
self.assertLess(10*size-error*(10*size), bills[0].get_total())
|
|
@ -10,11 +10,24 @@ from orchestra.apps.accounts.models import Account
|
||||||
from orchestra.apps.users.models import User
|
from orchestra.apps.users.models import User
|
||||||
from orchestra.utils.tests import BaseTestCase, random_ascii
|
from orchestra.utils.tests import BaseTestCase, random_ascii
|
||||||
|
|
||||||
from ... import settings, helpers
|
from .. import settings, helpers
|
||||||
from ...models import Plan, Service, Order
|
from ..models import Service, Plan, Rate
|
||||||
|
|
||||||
|
|
||||||
class OrderTests(BaseTestCase):
|
class Order(object):
|
||||||
|
""" Fake order for testing """
|
||||||
|
last_id = 0
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.registered_on = kwargs.get('registered_on', timezone.now().date())
|
||||||
|
self.billed_until = kwargs.get('billed_until', None)
|
||||||
|
self.cancelled_on = kwargs.get('cancelled_on', None)
|
||||||
|
type(self).last_id += 1
|
||||||
|
self.id = self.last_id
|
||||||
|
self.pk = self.id
|
||||||
|
|
||||||
|
|
||||||
|
class HandlerTests(BaseTestCase):
|
||||||
DEPENDENCIES = (
|
DEPENDENCIES = (
|
||||||
'orchestra.apps.orders',
|
'orchestra.apps.orders',
|
||||||
'orchestra.apps.users',
|
'orchestra.apps.users',
|
||||||
|
@ -46,62 +59,50 @@ class OrderTests(BaseTestCase):
|
||||||
)
|
)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
def create_ftp(self, account=None):
|
|
||||||
username = '%s_ftp' % random_ascii(10)
|
|
||||||
if not account:
|
|
||||||
account = self.create_account()
|
|
||||||
user = User.objects.create_user(username=username, account=account)
|
|
||||||
POSIX = user._meta.get_field_by_name('posix')[0].model
|
|
||||||
POSIX.objects.create(user=user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
def test_get_chunks(self):
|
def test_get_chunks(self):
|
||||||
service = self.create_ftp_service()
|
service = self.create_ftp_service()
|
||||||
handler = service.handler
|
handler = service.handler
|
||||||
porders = []
|
porders = []
|
||||||
now = timezone.now().date()
|
now = timezone.now().date()
|
||||||
ct = ContentType.objects.get_for_model(User)
|
|
||||||
account = self.create_account()
|
|
||||||
|
|
||||||
ftp = self.create_ftp(account=account)
|
order = Order()
|
||||||
order = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
|
||||||
porders.append(order)
|
porders.append(order)
|
||||||
end = handler.get_billing_point(order)
|
end = handler.get_billing_point(order)
|
||||||
chunks = helpers.get_chunks(porders, now, end)
|
chunks = helpers.get_chunks(porders, now, end)
|
||||||
self.assertEqual(1, len(chunks))
|
self.assertEqual(1, len(chunks))
|
||||||
self.assertIn([now, end, []], chunks)
|
self.assertIn([now, end, []], chunks)
|
||||||
|
|
||||||
ftp = self.create_ftp(account=account)
|
order1 = Order(
|
||||||
order1 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
billed_until=now+datetime.timedelta(days=2)
|
||||||
order1.billed_until = now+datetime.timedelta(days=2)
|
)
|
||||||
porders.append(order1)
|
porders.append(order1)
|
||||||
chunks = helpers.get_chunks(porders, now, end)
|
chunks = helpers.get_chunks(porders, now, end)
|
||||||
self.assertEqual(2, len(chunks))
|
self.assertEqual(2, len(chunks))
|
||||||
self.assertIn([order1.registered_on, order1.billed_until, [order1]], chunks)
|
self.assertIn([order1.registered_on, order1.billed_until, [order1]], chunks)
|
||||||
self.assertIn([order1.billed_until, end, []], chunks)
|
self.assertIn([order1.billed_until, end, []], chunks)
|
||||||
|
|
||||||
ftp = self.create_ftp(account=account)
|
order2 = Order(
|
||||||
order2 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
billed_until = now+datetime.timedelta(days=700)
|
||||||
order2.billed_until = now+datetime.timedelta(days=700)
|
)
|
||||||
porders.append(order2)
|
porders.append(order2)
|
||||||
chunks = helpers.get_chunks(porders, now, end)
|
chunks = helpers.get_chunks(porders, now, end)
|
||||||
self.assertEqual(2, len(chunks))
|
self.assertEqual(2, len(chunks))
|
||||||
self.assertIn([order.registered_on, order1.billed_until, [order1, order2]], chunks)
|
self.assertIn([order.registered_on, order1.billed_until, [order1, order2]], chunks)
|
||||||
self.assertIn([order1.billed_until, end, [order2]], chunks)
|
self.assertIn([order1.billed_until, end, [order2]], chunks)
|
||||||
|
|
||||||
ftp = self.create_ftp(account=account)
|
order3 = Order(
|
||||||
order3 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
billed_until = now+datetime.timedelta(days=700)
|
||||||
order3.billed_until = now+datetime.timedelta(days=700)
|
)
|
||||||
porders.append(order3)
|
porders.append(order3)
|
||||||
chunks = helpers.get_chunks(porders, now, end)
|
chunks = helpers.get_chunks(porders, now, end)
|
||||||
self.assertEqual(2, len(chunks))
|
self.assertEqual(2, len(chunks))
|
||||||
self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks)
|
self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks)
|
||||||
self.assertIn([order1.billed_until, end, [order2, order3]], chunks)
|
self.assertIn([order1.billed_until, end, [order2, order3]], chunks)
|
||||||
|
|
||||||
ftp = self.create_ftp(account=account)
|
order4 = Order(
|
||||||
order4 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
registered_on=now+datetime.timedelta(days=5),
|
||||||
order4.registered_on = now+datetime.timedelta(days=5)
|
billed_until = now+datetime.timedelta(days=10)
|
||||||
order4.billed_until = now+datetime.timedelta(days=10)
|
)
|
||||||
porders.append(order4)
|
porders.append(order4)
|
||||||
chunks = helpers.get_chunks(porders, now, end)
|
chunks = helpers.get_chunks(porders, now, end)
|
||||||
self.assertEqual(4, len(chunks))
|
self.assertEqual(4, len(chunks))
|
||||||
|
@ -110,10 +111,10 @@ class OrderTests(BaseTestCase):
|
||||||
self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks)
|
self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks)
|
||||||
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
|
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
|
||||||
|
|
||||||
ftp = self.create_ftp(account=account)
|
order5 = Order(
|
||||||
order5 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
registered_on=now+datetime.timedelta(days=700),
|
||||||
order5.registered_on = now+datetime.timedelta(days=700)
|
billed_until=now+datetime.timedelta(days=780)
|
||||||
order5.billed_until = now+datetime.timedelta(days=780)
|
)
|
||||||
porders.append(order5)
|
porders.append(order5)
|
||||||
chunks = helpers.get_chunks(porders, now, end)
|
chunks = helpers.get_chunks(porders, now, end)
|
||||||
self.assertEqual(4, len(chunks))
|
self.assertEqual(4, len(chunks))
|
||||||
|
@ -122,10 +123,10 @@ class OrderTests(BaseTestCase):
|
||||||
self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks)
|
self.assertIn([order4.registered_on, order4.billed_until, [order2, order3, order4]], chunks)
|
||||||
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
|
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
|
||||||
|
|
||||||
ftp = self.create_ftp(account=account)
|
order6 = Order(
|
||||||
order6 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
registered_on=now+datetime.timedelta(days=780),
|
||||||
order6.registered_on = now-datetime.timedelta(days=780)
|
billed_until=now+datetime.timedelta(days=700)
|
||||||
order6.billed_until = now-datetime.timedelta(days=700)
|
)
|
||||||
porders.append(order6)
|
porders.append(order6)
|
||||||
chunks = helpers.get_chunks(porders, now, end)
|
chunks = helpers.get_chunks(porders, now, end)
|
||||||
self.assertEqual(4, len(chunks))
|
self.assertEqual(4, len(chunks))
|
||||||
|
@ -135,32 +136,23 @@ class OrderTests(BaseTestCase):
|
||||||
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
|
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
|
||||||
|
|
||||||
def test_sort_billed_until_or_registered_on(self):
|
def test_sort_billed_until_or_registered_on(self):
|
||||||
service = self.create_ftp_service()
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
order = Order(
|
order = Order(
|
||||||
service=service,
|
|
||||||
registered_on=now,
|
|
||||||
billed_until=now+datetime.timedelta(days=200))
|
billed_until=now+datetime.timedelta(days=200))
|
||||||
order1 = Order(
|
order1 = Order(
|
||||||
service=service,
|
|
||||||
registered_on=now+datetime.timedelta(days=5),
|
registered_on=now+datetime.timedelta(days=5),
|
||||||
billed_until=now+datetime.timedelta(days=200))
|
billed_until=now+datetime.timedelta(days=200))
|
||||||
order2 = Order(
|
order2 = Order(
|
||||||
service=service,
|
|
||||||
registered_on=now+datetime.timedelta(days=6),
|
registered_on=now+datetime.timedelta(days=6),
|
||||||
billed_until=now+datetime.timedelta(days=200))
|
billed_until=now+datetime.timedelta(days=200))
|
||||||
order3 = Order(
|
order3 = Order(
|
||||||
service=service,
|
|
||||||
registered_on=now+datetime.timedelta(days=6),
|
registered_on=now+datetime.timedelta(days=6),
|
||||||
billed_until=now+datetime.timedelta(days=201))
|
billed_until=now+datetime.timedelta(days=201))
|
||||||
order4 = Order(
|
order4 = Order(
|
||||||
service=service,
|
|
||||||
registered_on=now+datetime.timedelta(days=6))
|
registered_on=now+datetime.timedelta(days=6))
|
||||||
order5 = Order(
|
order5 = Order(
|
||||||
service=service,
|
|
||||||
registered_on=now+datetime.timedelta(days=7))
|
registered_on=now+datetime.timedelta(days=7))
|
||||||
order6 = Order(
|
order6 = Order(
|
||||||
service=service,
|
|
||||||
registered_on=now+datetime.timedelta(days=8))
|
registered_on=now+datetime.timedelta(days=8))
|
||||||
orders = [order3, order, order1, order2, order4, order5, order6]
|
orders = [order3, order, order1, order2, order4, order5, order6]
|
||||||
self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on))
|
self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on))
|
||||||
|
@ -169,7 +161,6 @@ class OrderTests(BaseTestCase):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
order = Order(
|
order = Order(
|
||||||
description='0',
|
description='0',
|
||||||
registered_on=now,
|
|
||||||
billed_until=now+datetime.timedelta(days=220),
|
billed_until=now+datetime.timedelta(days=220),
|
||||||
cancelled_on=now+datetime.timedelta(days=100))
|
cancelled_on=now+datetime.timedelta(days=100))
|
||||||
order1 = Order(
|
order1 = Order(
|
||||||
|
@ -213,7 +204,6 @@ class OrderTests(BaseTestCase):
|
||||||
])
|
])
|
||||||
porders = [order3, order, order1, order2, order4, order5, order6]
|
porders = [order3, order, order1, order2, order4, order5, order6]
|
||||||
porders = sorted(porders, cmp=helpers.cmp_billed_until_or_registered_on)
|
porders = sorted(porders, cmp=helpers.cmp_billed_until_or_registered_on)
|
||||||
service = self.create_ftp_service()
|
|
||||||
compensations = []
|
compensations = []
|
||||||
receivers = []
|
receivers = []
|
||||||
for order in porders:
|
for order in porders:
|
||||||
|
@ -234,7 +224,8 @@ class OrderTests(BaseTestCase):
|
||||||
def test_rates(self):
|
def test_rates(self):
|
||||||
service = self.create_ftp_service()
|
service = self.create_ftp_service()
|
||||||
account = self.create_account()
|
account = self.create_account()
|
||||||
superplan = Plan.objects.create(name='SUPER', allow_multiple=False, is_combinable=True)
|
superplan = Plan.objects.create(
|
||||||
|
name='SUPER', allow_multiple=False, is_combinable=True)
|
||||||
service.rates.create(plan=superplan, quantity=1, price=0)
|
service.rates.create(plan=superplan, quantity=1, price=0)
|
||||||
service.rates.create(plan=superplan, quantity=3, price=10)
|
service.rates.create(plan=superplan, quantity=3, price=10)
|
||||||
service.rates.create(plan=superplan, quantity=4, price=9)
|
service.rates.create(plan=superplan, quantity=4, price=9)
|
||||||
|
@ -252,7 +243,8 @@ class OrderTests(BaseTestCase):
|
||||||
self.assertEqual(rate['price'], result.price)
|
self.assertEqual(rate['price'], result.price)
|
||||||
self.assertEqual(rate['quantity'], result.quantity)
|
self.assertEqual(rate['quantity'], result.quantity)
|
||||||
|
|
||||||
dupeplan = Plan.objects.create(name='DUPE', allow_multiple=True, is_combinable=True)
|
dupeplan = Plan.objects.create(
|
||||||
|
name='DUPE', allow_multiple=True, is_combinable=True)
|
||||||
service.rates.create(plan=dupeplan, quantity=1, price=0)
|
service.rates.create(plan=dupeplan, quantity=1, price=0)
|
||||||
service.rates.create(plan=dupeplan, quantity=3, price=9)
|
service.rates.create(plan=dupeplan, quantity=3, price=9)
|
||||||
results = service.get_rates(account, cache=False)
|
results = service.get_rates(account, cache=False)
|
||||||
|
@ -273,7 +265,8 @@ class OrderTests(BaseTestCase):
|
||||||
self.assertEqual(rate['price'], result.price)
|
self.assertEqual(rate['price'], result.price)
|
||||||
self.assertEqual(rate['quantity'], result.quantity)
|
self.assertEqual(rate['quantity'], result.quantity)
|
||||||
|
|
||||||
hyperplan = Plan.objects.create(name='HYPER', allow_multiple=False, is_combinable=False)
|
hyperplan = Plan.objects.create(
|
||||||
|
name='HYPER', allow_multiple=False, is_combinable=False)
|
||||||
service.rates.create(plan=hyperplan, quantity=1, price=0)
|
service.rates.create(plan=hyperplan, quantity=1, price=0)
|
||||||
service.rates.create(plan=hyperplan, quantity=20, price=5)
|
service.rates.create(plan=hyperplan, quantity=20, price=5)
|
||||||
account.plans.create(plan=hyperplan)
|
account.plans.create(plan=hyperplan)
|
||||||
|
@ -323,7 +316,8 @@ class OrderTests(BaseTestCase):
|
||||||
def test_rates_allow_multiple(self):
|
def test_rates_allow_multiple(self):
|
||||||
service = self.create_ftp_service()
|
service = self.create_ftp_service()
|
||||||
account = self.create_account()
|
account = self.create_account()
|
||||||
dupeplan = Plan.objects.create(name='DUPE', allow_multiple=True, is_combinable=True)
|
dupeplan = Plan.objects.create(
|
||||||
|
name='DUPE', allow_multiple=True, is_combinable=True)
|
||||||
account.plans.create(plan=dupeplan)
|
account.plans.create(plan=dupeplan)
|
||||||
service.rates.create(plan=dupeplan, quantity=1, price=0)
|
service.rates.create(plan=dupeplan, quantity=1, price=0)
|
||||||
service.rates.create(plan=dupeplan, quantity=3, price=9)
|
service.rates.create(plan=dupeplan, quantity=3, price=9)
|
||||||
|
@ -359,40 +353,5 @@ class OrderTests(BaseTestCase):
|
||||||
self.assertEqual(rate['price'], result.price)
|
self.assertEqual(rate['price'], result.price)
|
||||||
self.assertEqual(rate['quantity'], result.quantity)
|
self.assertEqual(rate['quantity'], result.quantity)
|
||||||
|
|
||||||
def test_ftp_account_1_year_fiexed(self):
|
def test_compensations(self):
|
||||||
service = self.create_ftp_service()
|
pass
|
||||||
user = self.create_ftp()
|
|
||||||
bp = timezone.now().date() + relativedelta.relativedelta(years=1)
|
|
||||||
bills = service.orders.bill(billing_point=bp, fixed_point=True)
|
|
||||||
self.assertEqual(10, bills[0].get_total())
|
|
||||||
|
|
||||||
def test_ftp_account_2_year_fiexed(self):
|
|
||||||
service = self.create_ftp_service()
|
|
||||||
user = self.create_ftp()
|
|
||||||
bp = timezone.now().date() + relativedelta.relativedelta(years=2)
|
|
||||||
bills = service.orders.bill(billing_point=bp, fixed_point=True)
|
|
||||||
self.assertEqual(20, bills[0].get_total())
|
|
||||||
|
|
||||||
def test_ftp_account_6_month_fixed(self):
|
|
||||||
service = self.create_ftp_service()
|
|
||||||
self.create_ftp()
|
|
||||||
bp = timezone.now().date() + relativedelta.relativedelta(months=6)
|
|
||||||
bills = service.orders.bill(billing_point=bp, fixed_point=True)
|
|
||||||
self.assertEqual(5, bills[0].get_total())
|
|
||||||
|
|
||||||
def test_ftp_account_next_billing_point(self):
|
|
||||||
service = self.create_ftp_service()
|
|
||||||
self.create_ftp()
|
|
||||||
now = timezone.now()
|
|
||||||
bp_month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
|
|
||||||
if now.month > bp_month:
|
|
||||||
bp = datetime.datetime(year=now.year+1, month=bp_month,
|
|
||||||
day=1, tzinfo=timezone.get_current_timezone())
|
|
||||||
else:
|
|
||||||
bp = datetime.datetime(year=now.year, month=bp_month,
|
|
||||||
day=1, tzinfo=timezone.get_current_timezone())
|
|
||||||
bills = service.orders.bill(billing_point=now, fixed_point=False)
|
|
||||||
size = decimal.Decimal((bp - now).days)/365
|
|
||||||
error = decimal.Decimal(0.05)
|
|
||||||
self.assertGreater(10*size+error*(10*size), bills[0].get_total())
|
|
||||||
self.assertLess(10*size-error*(10*size), bills[0].get_total())
|
|
|
@ -79,6 +79,7 @@ INSTALLED_APPS = (
|
||||||
'orchestra.apps.databases',
|
'orchestra.apps.databases',
|
||||||
'orchestra.apps.vps',
|
'orchestra.apps.vps',
|
||||||
'orchestra.apps.issues',
|
'orchestra.apps.issues',
|
||||||
|
'orchestra.apps.services',
|
||||||
'orchestra.apps.orders',
|
'orchestra.apps.orders',
|
||||||
'orchestra.apps.miscellaneous',
|
'orchestra.apps.miscellaneous',
|
||||||
'orchestra.apps.bills',
|
'orchestra.apps.bills',
|
||||||
|
@ -144,7 +145,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
|
||||||
'orchestra.apps.contacts.models.Contact',
|
'orchestra.apps.contacts.models.Contact',
|
||||||
'orchestra.apps.users.models.User',
|
'orchestra.apps.users.models.User',
|
||||||
'orchestra.apps.orders.models.Order',
|
'orchestra.apps.orders.models.Order',
|
||||||
'orchestra.apps.orders.models.ContractedPlan',
|
'orchestra.apps.services.models.ContractedPlan',
|
||||||
'orchestra.apps.bills.models.Bill',
|
'orchestra.apps.bills.models.Bill',
|
||||||
# 'orchestra.apps.payments.models.PaymentSource',
|
# 'orchestra.apps.payments.models.PaymentSource',
|
||||||
'orchestra.apps.payments.models.Transaction',
|
'orchestra.apps.payments.models.Transaction',
|
||||||
|
@ -160,8 +161,8 @@ FLUENT_DASHBOARD_APP_GROUPS = (
|
||||||
'orchestra.apps.orchestration.models.Server',
|
'orchestra.apps.orchestration.models.Server',
|
||||||
'orchestra.apps.resources.models.Resource',
|
'orchestra.apps.resources.models.Resource',
|
||||||
'orchestra.apps.resources.models.Monitor',
|
'orchestra.apps.resources.models.Monitor',
|
||||||
'orchestra.apps.orders.models.Service',
|
'orchestra.apps.services.models.Service',
|
||||||
'orchestra.apps.orders.models.Plan',
|
'orchestra.apps.services.models.Plan',
|
||||||
),
|
),
|
||||||
'collapsible': True,
|
'collapsible': True,
|
||||||
}),
|
}),
|
||||||
|
@ -186,8 +187,8 @@ FLUENT_DASHBOARD_APP_ICONS = {
|
||||||
'accounts/account': 'Face-monkey.png',
|
'accounts/account': 'Face-monkey.png',
|
||||||
'contacts/contact': 'contact_book.png',
|
'contacts/contact': 'contact_book.png',
|
||||||
'orders/order': 'basket.png',
|
'orders/order': 'basket.png',
|
||||||
'orders/service': 'price.png',
|
'services/contractedplan': 'Pack.png',
|
||||||
'orders/contractedplan': 'Pack.png',
|
'services/service': 'price.png',
|
||||||
'bills/bill': 'invoice.png',
|
'bills/bill': 'invoice.png',
|
||||||
'payments/paymentsource': 'card_in_use.png',
|
'payments/paymentsource': 'card_in_use.png',
|
||||||
'payments/transaction': 'transaction.png',
|
'payments/transaction': 'transaction.png',
|
||||||
|
@ -200,7 +201,7 @@ FLUENT_DASHBOARD_APP_ICONS = {
|
||||||
'orchestration/backendlog': 'scriptlog.png',
|
'orchestration/backendlog': 'scriptlog.png',
|
||||||
'resources/resource': "gauge.png",
|
'resources/resource': "gauge.png",
|
||||||
'resources/monitor': "Utilities-system-monitor.png",
|
'resources/monitor': "Utilities-system-monitor.png",
|
||||||
'orders/plan': 'Pack.png',
|
'services/plan': 'Pack.png',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Django-celery
|
# Django-celery
|
||||||
|
|
|
@ -35,8 +35,8 @@ def get_model_field_path(origin, target):
|
||||||
while queue:
|
while queue:
|
||||||
model, path = queue.pop(0)
|
model, path = queue.pop(0)
|
||||||
if len(model) > 4:
|
if len(model) > 4:
|
||||||
msg = "maximum recursion depth exceeded while looking for %s"
|
msg = "maximum recursion depth exceeded while looking for %s from %s"
|
||||||
raise RuntimeError(msg % target)
|
raise RuntimeError(msg % (target, origin))
|
||||||
node = model[-1]
|
node = model[-1]
|
||||||
if node == target:
|
if node == target:
|
||||||
return path
|
return path
|
||||||
|
|
|
@ -40,7 +40,7 @@ def _un(singular__plural, n=None):
|
||||||
return ungettext(singular, plural, n)
|
return ungettext(singular, plural, n)
|
||||||
|
|
||||||
|
|
||||||
def naturaldate(date, include_seconds=False):
|
def naturaldatetime(date, include_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 ''
|
||||||
|
@ -97,3 +97,29 @@ def naturaldate(date, include_seconds=False):
|
||||||
count = abs(count)
|
count = abs(count)
|
||||||
fmt = pluralizefun(count)
|
fmt = pluralizefun(count)
|
||||||
return fmt.format(num=count, ago=ago)
|
return fmt.format(num=count, ago=ago)
|
||||||
|
|
||||||
|
|
||||||
|
def naturaldate(date):
|
||||||
|
if not date:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
today = timezone.now().date()
|
||||||
|
delta = today - date
|
||||||
|
days = delta.days
|
||||||
|
|
||||||
|
if days == 0:
|
||||||
|
return _('today')
|
||||||
|
elif days == 1:
|
||||||
|
return _('yesterday')
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for chunk, pluralizefun in OLDER_CHUNKS:
|
||||||
|
if days < 7.0:
|
||||||
|
count = days + float(hours)/24
|
||||||
|
fmt = pluralize_day(count)
|
||||||
|
return fmt.format(num=count, ago=ago)
|
||||||
|
if days >= chunk:
|
||||||
|
count = (delta_midnight.days + 1) / chunk
|
||||||
|
count = abs(count)
|
||||||
|
fmt = pluralizefun(count)
|
||||||
|
return fmt.format(num=count, ago=ago)
|
||||||
|
|
Loading…
Reference in New Issue