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():
|
||||
childrens = []
|
||||
if isinstalled('orchestra.apps.orders'):
|
||||
url = reverse('admin:orders_service_changelist')
|
||||
if isinstalled('orchestra.apps.services'):
|
||||
url = reverse('admin:services_service_changelist')
|
||||
childrens.append(items.MenuItem(_("Services"), url))
|
||||
url = reverse('admin:orders_plan_changelist')
|
||||
url = reverse('admin:services_plan_changelist')
|
||||
childrens.append(items.MenuItem(_("Plans"), url))
|
||||
if isinstalled('orchestra.apps.orchestration'):
|
||||
route = reverse('admin:orchestration_route_changelist')
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import datetime
|
||||
from functools import wraps
|
||||
|
||||
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 orchestra.models.utils import get_field_value
|
||||
from orchestra.utils.humanize import naturaldate
|
||||
from orchestra.utils import humanize
|
||||
|
||||
from .decorators import admin_field
|
||||
|
||||
|
@ -131,8 +132,12 @@ def admin_date(*args, **kwargs):
|
|||
value = get_field_value(instance, kwargs['field'])
|
||||
if not value:
|
||||
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(
|
||||
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.core.urlresolvers import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orchestra.admin import ChangeListDefaultFilter
|
||||
from orchestra.admin.filters import UsedContentTypeFilter
|
||||
from orchestra.admin.utils import admin_link, admin_date
|
||||
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||
from orchestra.core import services
|
||||
from orchestra.utils.humanize import naturaldate
|
||||
|
||||
from .actions import BillSelectedOrders
|
||||
from .filters import ActiveOrderListFilter, BilledOrderListFilter
|
||||
from .models import Plan, ContractedPlan, Rate, Service, 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
|
||||
from .models import Order, MetricStorage
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
|
||||
class MetricStorageAdmin(admin.ModelAdmin):
|
||||
list_display = ('order', 'value', 'created_on', 'updated_on')
|
||||
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(MetricStorage, MetricStorageAdmin)
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
import inspect
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
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.append(related)
|
||||
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.migrations.recorder import MigrationRecorder
|
||||
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.dispatch import receiver
|
||||
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.python import import_class
|
||||
|
||||
from . import helpers, settings, rating
|
||||
from . import helpers, settings
|
||||
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):
|
||||
group_by = queryset.group_by
|
||||
|
||||
|
@ -358,10 +73,10 @@ class Order(models.Model):
|
|||
related_name='orders')
|
||||
content_type = models.ForeignKey(ContentType)
|
||||
object_id = models.PositiveIntegerField(null=True)
|
||||
service = models.ForeignKey(Service, verbose_name=_("service"),
|
||||
related_name='orders')
|
||||
registered_on = models.DateField(_("registered on"), auto_now_add=True) # TODO datetime field?
|
||||
cancelled_on = models.DateField(_("cancelled on"), null=True, blank=True)
|
||||
service = models.ForeignKey(settings.ORDERS_SERVICE_MODEL,
|
||||
verbose_name=_("service"), related_name='orders')
|
||||
registered_on = models.DateField(_("registered"), auto_now_add=True) # TODO datetime field?
|
||||
cancelled_on = models.DateField(_("cancelled"), null=True, blank=True)
|
||||
billed_on = models.DateField(_("billed on"), null=True, blank=True)
|
||||
billed_until = models.DateField(_("billed until"), null=True, blank=True)
|
||||
ignore = models.BooleanField(_("ignore"), default=False)
|
||||
|
@ -387,6 +102,7 @@ class Order(models.Model):
|
|||
|
||||
@classmethod
|
||||
def update_orders(cls, instance):
|
||||
Service = get_model(*settings.ORDERS_SERVICE_MODEL.split('.'))
|
||||
for service in Service.get_services(instance):
|
||||
orders = Order.objects.by_object(instance, service=service).active()
|
||||
if service.handler.matches(instance):
|
||||
|
@ -447,6 +163,7 @@ class MetricStorage(models.Model):
|
|||
except cls.DoesNotExist:
|
||||
return 0
|
||||
|
||||
|
||||
# TODO If this happens to be very costly then, consider an additional
|
||||
# implementation when runnning within a request/Response cycle, more efficient :)
|
||||
@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")
|
||||
def update_orders(sender, **kwargs):
|
||||
exclude = (
|
||||
MetricStorage, LogEntry, Order, Service, ContentType, MigrationRecorder.Migration
|
||||
MetricStorage, LogEntry, Order, ContentType, MigrationRecorder.Migration
|
||||
)
|
||||
if sender not in exclude:
|
||||
instance = kwargs['instance']
|
||||
|
@ -474,5 +191,3 @@ def update_orders(sender, **kwargs):
|
|||
|
||||
|
||||
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 _
|
||||
|
||||
|
||||
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',
|
||||
'orchestra.apps.orders.billing.BillsBackend')
|
||||
|
||||
|
||||
ORDERS_PLANS = getattr(settings, 'ORDERS_PLANS', (
|
||||
('basic', _("Basic")),
|
||||
('advanced', _("Advanced")),
|
||||
))
|
||||
|
||||
ORDERS_DEFAULT_PLAN = getattr(settings, 'ORDERS_DEFAULT_PLAN', 'basic')
|
||||
ORDERS_SERVICE_MODEL = getattr(settings, 'ORDERS_SERVICE_MODEL', 'services.Service')
|
||||
|
|
1
orchestra/apps/services/__init__.py
Normal file
1
orchestra/apps/services/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
|
90
orchestra/apps/services/admin.py
Normal file
90
orchestra/apps/services/admin.py
Normal file
|
@ -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
|
||||
day = order.registered_on.day
|
||||
elif self.billing_point == self.FIXED_DATE:
|
||||
month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
|
||||
month = settings.SERVICES_SERVICE_ANUAL_BILLING_MONTH
|
||||
day = 1
|
||||
else:
|
||||
raise NotImplementedError(msg)
|
||||
|
@ -276,8 +276,7 @@ class ServiceHandler(plugins.Plugin):
|
|||
order.new_billed_until = bp
|
||||
ini = min(ini, cini)
|
||||
end = max(end, bp)
|
||||
from .models import Order
|
||||
related_orders = Order.objects.filter(service=self.service, account=account)
|
||||
related_orders = account.orders.filter(service=self.service)
|
||||
if self.on_cancel == self.COMPENSATE:
|
||||
# Get orders pending for compensation
|
||||
givers = related_orders.filter_givers(ini, end)
|
134
orchestra/apps/services/helpers.py
Normal file
134
orchestra/apps/services/helpers.py
Normal file
|
@ -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
|
311
orchestra/apps/services/models.py
Normal file
311
orchestra/apps/services/models.py
Normal file
|
@ -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)
|
14
orchestra/apps/services/settings.py
Normal file
14
orchestra/apps/services/settings.py
Normal file
|
@ -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)
|
94
orchestra/apps/services/tests/functional_tests/tests.py
Normal file
94
orchestra/apps/services/tests/functional_tests/tests.py
Normal file
|
@ -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.utils.tests import BaseTestCase, random_ascii
|
||||
|
||||
from ... import settings, helpers
|
||||
from ...models import Plan, Service, Order
|
||||
from .. import settings, helpers
|
||||
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 = (
|
||||
'orchestra.apps.orders',
|
||||
'orchestra.apps.users',
|
||||
|
@ -46,62 +59,50 @@ class OrderTests(BaseTestCase):
|
|||
)
|
||||
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):
|
||||
service = self.create_ftp_service()
|
||||
handler = service.handler
|
||||
porders = []
|
||||
now = timezone.now().date()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
account = self.create_account()
|
||||
|
||||
ftp = self.create_ftp(account=account)
|
||||
order = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
||||
order = Order()
|
||||
porders.append(order)
|
||||
end = handler.get_billing_point(order)
|
||||
chunks = helpers.get_chunks(porders, now, end)
|
||||
self.assertEqual(1, len(chunks))
|
||||
self.assertIn([now, end, []], chunks)
|
||||
|
||||
ftp = self.create_ftp(account=account)
|
||||
order1 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
||||
order1.billed_until = now+datetime.timedelta(days=2)
|
||||
order1 = Order(
|
||||
billed_until=now+datetime.timedelta(days=2)
|
||||
)
|
||||
porders.append(order1)
|
||||
chunks = helpers.get_chunks(porders, now, end)
|
||||
self.assertEqual(2, len(chunks))
|
||||
self.assertIn([order1.registered_on, order1.billed_until, [order1]], chunks)
|
||||
self.assertIn([order1.billed_until, end, []], chunks)
|
||||
|
||||
ftp = self.create_ftp(account=account)
|
||||
order2 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
||||
order2.billed_until = now+datetime.timedelta(days=700)
|
||||
order2 = Order(
|
||||
billed_until = now+datetime.timedelta(days=700)
|
||||
)
|
||||
porders.append(order2)
|
||||
chunks = helpers.get_chunks(porders, now, end)
|
||||
self.assertEqual(2, len(chunks))
|
||||
self.assertIn([order.registered_on, order1.billed_until, [order1, order2]], chunks)
|
||||
self.assertIn([order1.billed_until, end, [order2]], chunks)
|
||||
|
||||
ftp = self.create_ftp(account=account)
|
||||
order3 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
||||
order3.billed_until = now+datetime.timedelta(days=700)
|
||||
order3 = Order(
|
||||
billed_until = now+datetime.timedelta(days=700)
|
||||
)
|
||||
porders.append(order3)
|
||||
chunks = helpers.get_chunks(porders, now, end)
|
||||
self.assertEqual(2, len(chunks))
|
||||
self.assertIn([order.registered_on, order1.billed_until, [order1, order2, order3]], chunks)
|
||||
self.assertIn([order1.billed_until, end, [order2, order3]], chunks)
|
||||
|
||||
ftp = self.create_ftp(account=account)
|
||||
order4 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
||||
order4.registered_on = now+datetime.timedelta(days=5)
|
||||
order4.billed_until = now+datetime.timedelta(days=10)
|
||||
order4 = Order(
|
||||
registered_on=now+datetime.timedelta(days=5),
|
||||
billed_until = now+datetime.timedelta(days=10)
|
||||
)
|
||||
porders.append(order4)
|
||||
chunks = helpers.get_chunks(porders, now, end)
|
||||
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.billed_until, end, [order2, order3]], chunks)
|
||||
|
||||
ftp = self.create_ftp(account=account)
|
||||
order5 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
||||
order5.registered_on = now+datetime.timedelta(days=700)
|
||||
order5.billed_until = now+datetime.timedelta(days=780)
|
||||
order5 = Order(
|
||||
registered_on=now+datetime.timedelta(days=700),
|
||||
billed_until=now+datetime.timedelta(days=780)
|
||||
)
|
||||
porders.append(order5)
|
||||
chunks = helpers.get_chunks(porders, now, end)
|
||||
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.billed_until, end, [order2, order3]], chunks)
|
||||
|
||||
ftp = self.create_ftp(account=account)
|
||||
order6 = Order.objects.get(content_type=ct, object_id=ftp.pk)
|
||||
order6.registered_on = now-datetime.timedelta(days=780)
|
||||
order6.billed_until = now-datetime.timedelta(days=700)
|
||||
order6 = Order(
|
||||
registered_on=now+datetime.timedelta(days=780),
|
||||
billed_until=now+datetime.timedelta(days=700)
|
||||
)
|
||||
porders.append(order6)
|
||||
chunks = helpers.get_chunks(porders, now, end)
|
||||
self.assertEqual(4, len(chunks))
|
||||
|
@ -135,32 +136,23 @@ class OrderTests(BaseTestCase):
|
|||
self.assertIn([order4.billed_until, end, [order2, order3]], chunks)
|
||||
|
||||
def test_sort_billed_until_or_registered_on(self):
|
||||
service = self.create_ftp_service()
|
||||
now = timezone.now()
|
||||
order = Order(
|
||||
service=service,
|
||||
registered_on=now,
|
||||
billed_until=now+datetime.timedelta(days=200))
|
||||
order1 = Order(
|
||||
service=service,
|
||||
registered_on=now+datetime.timedelta(days=5),
|
||||
billed_until=now+datetime.timedelta(days=200))
|
||||
order2 = Order(
|
||||
service=service,
|
||||
registered_on=now+datetime.timedelta(days=6),
|
||||
billed_until=now+datetime.timedelta(days=200))
|
||||
order3 = Order(
|
||||
service=service,
|
||||
registered_on=now+datetime.timedelta(days=6),
|
||||
billed_until=now+datetime.timedelta(days=201))
|
||||
order4 = Order(
|
||||
service=service,
|
||||
registered_on=now+datetime.timedelta(days=6))
|
||||
order5 = Order(
|
||||
service=service,
|
||||
registered_on=now+datetime.timedelta(days=7))
|
||||
order6 = Order(
|
||||
service=service,
|
||||
registered_on=now+datetime.timedelta(days=8))
|
||||
orders = [order3, order, order1, order2, order4, order5, order6]
|
||||
self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on))
|
||||
|
@ -169,7 +161,6 @@ class OrderTests(BaseTestCase):
|
|||
now = timezone.now()
|
||||
order = Order(
|
||||
description='0',
|
||||
registered_on=now,
|
||||
billed_until=now+datetime.timedelta(days=220),
|
||||
cancelled_on=now+datetime.timedelta(days=100))
|
||||
order1 = Order(
|
||||
|
@ -213,7 +204,6 @@ class OrderTests(BaseTestCase):
|
|||
])
|
||||
porders = [order3, order, order1, order2, order4, order5, order6]
|
||||
porders = sorted(porders, cmp=helpers.cmp_billed_until_or_registered_on)
|
||||
service = self.create_ftp_service()
|
||||
compensations = []
|
||||
receivers = []
|
||||
for order in porders:
|
||||
|
@ -234,7 +224,8 @@ class OrderTests(BaseTestCase):
|
|||
def test_rates(self):
|
||||
service = self.create_ftp_service()
|
||||
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=3, price=10)
|
||||
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['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=3, price=9)
|
||||
results = service.get_rates(account, cache=False)
|
||||
|
@ -273,7 +265,8 @@ class OrderTests(BaseTestCase):
|
|||
self.assertEqual(rate['price'], result.price)
|
||||
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=20, price=5)
|
||||
account.plans.create(plan=hyperplan)
|
||||
|
@ -323,7 +316,8 @@ class OrderTests(BaseTestCase):
|
|||
def test_rates_allow_multiple(self):
|
||||
service = self.create_ftp_service()
|
||||
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)
|
||||
service.rates.create(plan=dupeplan, quantity=1, price=0)
|
||||
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['quantity'], result.quantity)
|
||||
|
||||
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.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())
|
||||
def test_compensations(self):
|
||||
pass
|
|
@ -79,6 +79,7 @@ INSTALLED_APPS = (
|
|||
'orchestra.apps.databases',
|
||||
'orchestra.apps.vps',
|
||||
'orchestra.apps.issues',
|
||||
'orchestra.apps.services',
|
||||
'orchestra.apps.orders',
|
||||
'orchestra.apps.miscellaneous',
|
||||
'orchestra.apps.bills',
|
||||
|
@ -144,7 +145,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
|
|||
'orchestra.apps.contacts.models.Contact',
|
||||
'orchestra.apps.users.models.User',
|
||||
'orchestra.apps.orders.models.Order',
|
||||
'orchestra.apps.orders.models.ContractedPlan',
|
||||
'orchestra.apps.services.models.ContractedPlan',
|
||||
'orchestra.apps.bills.models.Bill',
|
||||
# 'orchestra.apps.payments.models.PaymentSource',
|
||||
'orchestra.apps.payments.models.Transaction',
|
||||
|
@ -160,8 +161,8 @@ FLUENT_DASHBOARD_APP_GROUPS = (
|
|||
'orchestra.apps.orchestration.models.Server',
|
||||
'orchestra.apps.resources.models.Resource',
|
||||
'orchestra.apps.resources.models.Monitor',
|
||||
'orchestra.apps.orders.models.Service',
|
||||
'orchestra.apps.orders.models.Plan',
|
||||
'orchestra.apps.services.models.Service',
|
||||
'orchestra.apps.services.models.Plan',
|
||||
),
|
||||
'collapsible': True,
|
||||
}),
|
||||
|
@ -186,8 +187,8 @@ FLUENT_DASHBOARD_APP_ICONS = {
|
|||
'accounts/account': 'Face-monkey.png',
|
||||
'contacts/contact': 'contact_book.png',
|
||||
'orders/order': 'basket.png',
|
||||
'orders/service': 'price.png',
|
||||
'orders/contractedplan': 'Pack.png',
|
||||
'services/contractedplan': 'Pack.png',
|
||||
'services/service': 'price.png',
|
||||
'bills/bill': 'invoice.png',
|
||||
'payments/paymentsource': 'card_in_use.png',
|
||||
'payments/transaction': 'transaction.png',
|
||||
|
@ -200,7 +201,7 @@ FLUENT_DASHBOARD_APP_ICONS = {
|
|||
'orchestration/backendlog': 'scriptlog.png',
|
||||
'resources/resource': "gauge.png",
|
||||
'resources/monitor': "Utilities-system-monitor.png",
|
||||
'orders/plan': 'Pack.png',
|
||||
'services/plan': 'Pack.png',
|
||||
}
|
||||
|
||||
# Django-celery
|
||||
|
|
|
@ -35,8 +35,8 @@ def get_model_field_path(origin, target):
|
|||
while queue:
|
||||
model, path = queue.pop(0)
|
||||
if len(model) > 4:
|
||||
msg = "maximum recursion depth exceeded while looking for %s"
|
||||
raise RuntimeError(msg % target)
|
||||
msg = "maximum recursion depth exceeded while looking for %s from %s"
|
||||
raise RuntimeError(msg % (target, origin))
|
||||
node = model[-1]
|
||||
if node == target:
|
||||
return path
|
||||
|
|
|
@ -40,7 +40,7 @@ def _un(singular__plural, n=None):
|
|||
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."""
|
||||
if not date:
|
||||
return ''
|
||||
|
@ -97,3 +97,29 @@ def naturaldate(date, include_seconds=False):
|
|||
count = abs(count)
|
||||
fmt = pluralizefun(count)
|
||||
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 a new issue