Refactor services out of orders

This commit is contained in:
Marc 2014-09-17 10:32:29 +00:00
parent 821463eb33
commit 7298c9393e
20 changed files with 752 additions and 639 deletions

View file

@ -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')

View file

@ -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),
) )

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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')

View file

@ -0,0 +1 @@

View 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)

View file

@ -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)

View 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

View 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)

View 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)

View 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())

View file

@ -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)
@ -347,7 +341,7 @@ class OrderTests(BaseTestCase):
for rate, result in zip(rates, results): for rate, result in zip(rates, results):
self.assertEqual(rate['price'], result.price) self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity) self.assertEqual(rate['quantity'], result.quantity)
account.plans.create(plan=dupeplan) account.plans.create(plan=dupeplan)
results = service.get_rates(account, cache=False) results = service.get_rates(account, cache=False)
results = service.rate_method(results, 30) results = service.rate_method(results, 30)
@ -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())

View file

@ -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

View file

@ -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

View file

@ -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)