diff --git a/orchestra/admin/menu.py b/orchestra/admin/menu.py
index 382f6fad..67a34a53 100644
--- a/orchestra/admin/menu.py
+++ b/orchestra/admin/menu.py
@@ -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')
diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py
index 6b542306..0aa40bf2 100644
--- a/orchestra/admin/utils.py
+++ b/orchestra/admin/utils.py
@@ -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 '{1}'.format(
- escape(str(value)), escape(naturaldate(value)),
+ escape(str(value)), escape(natural),
)
diff --git a/orchestra/apps/orders/admin.py b/orchestra/apps/orders/admin.py
index 324bad68..363f2305 100644
--- a/orchestra/apps/orders/admin.py
+++ b/orchestra/apps/orders/admin.py
@@ -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 '%d' % (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)
diff --git a/orchestra/apps/orders/helpers.py b/orchestra/apps/orders/helpers.py
index 75412cb7..241c097a 100644
--- a/orchestra/apps/orders/helpers.py
+++ b/orchestra/apps/orders/helpers.py
@@ -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
diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py
index 560d5533..e21b9d96 100644
--- a/orchestra/apps/orders/models.py
+++ b/orchestra/apps/orders/models.py
@@ -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)
diff --git a/orchestra/apps/orders/settings.py b/orchestra/apps/orders/settings.py
index 42ea3422..beb06d73 100644
--- a/orchestra/apps/orders/settings.py
+++ b/orchestra/apps/orders/settings.py
@@ -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')
diff --git a/orchestra/apps/services/__init__.py b/orchestra/apps/services/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/orchestra/apps/services/__init__.py
@@ -0,0 +1 @@
+
diff --git a/orchestra/apps/services/admin.py b/orchestra/apps/services/admin.py
new file mode 100644
index 00000000..3582fd03
--- /dev/null
+++ b/orchestra/apps/services/admin.py
@@ -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 '%d' % (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)
diff --git a/orchestra/apps/orders/handlers.py b/orchestra/apps/services/handlers.py
similarity index 98%
rename from orchestra/apps/orders/handlers.py
rename to orchestra/apps/services/handlers.py
index 5c9a7453..80bd12cc 100644
--- a/orchestra/apps/orders/handlers.py
+++ b/orchestra/apps/services/handlers.py
@@ -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)
diff --git a/orchestra/apps/services/helpers.py b/orchestra/apps/services/helpers.py
new file mode 100644
index 00000000..46dd8f88
--- /dev/null
+++ b/orchestra/apps/services/helpers.py
@@ -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
diff --git a/orchestra/apps/services/models.py b/orchestra/apps/services/models.py
new file mode 100644
index 00000000..30d64a0f
--- /dev/null
+++ b/orchestra/apps/services/models.py
@@ -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)
diff --git a/orchestra/apps/orders/rating.py b/orchestra/apps/services/rating.py
similarity index 100%
rename from orchestra/apps/orders/rating.py
rename to orchestra/apps/services/rating.py
diff --git a/orchestra/apps/services/settings.py b/orchestra/apps/services/settings.py
new file mode 100644
index 00000000..06f49423
--- /dev/null
+++ b/orchestra/apps/services/settings.py
@@ -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)
diff --git a/orchestra/apps/orders/tests/__init__.py b/orchestra/apps/services/tests/__init__.py
similarity index 100%
rename from orchestra/apps/orders/tests/__init__.py
rename to orchestra/apps/services/tests/__init__.py
diff --git a/orchestra/apps/orders/tests/functional_tests/__init__.py b/orchestra/apps/services/tests/functional_tests/__init__.py
similarity index 100%
rename from orchestra/apps/orders/tests/functional_tests/__init__.py
rename to orchestra/apps/services/tests/functional_tests/__init__.py
diff --git a/orchestra/apps/services/tests/functional_tests/tests.py b/orchestra/apps/services/tests/functional_tests/tests.py
new file mode 100644
index 00000000..04193c0d
--- /dev/null
+++ b/orchestra/apps/services/tests/functional_tests/tests.py
@@ -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())
diff --git a/orchestra/apps/orders/tests/functional_tests/tests.py b/orchestra/apps/services/tests/test_handler.py
similarity index 75%
rename from orchestra/apps/orders/tests/functional_tests/tests.py
rename to orchestra/apps/services/tests/test_handler.py
index 4a91fa7b..8988b90c 100644
--- a/orchestra/apps/orders/tests/functional_tests/tests.py
+++ b/orchestra/apps/services/tests/test_handler.py
@@ -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)
@@ -347,7 +341,7 @@ class OrderTests(BaseTestCase):
for rate, result in zip(rates, results):
self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity)
-
+
account.plans.create(plan=dupeplan)
results = service.get_rates(account, cache=False)
results = service.rate_method(results, 30)
@@ -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
diff --git a/orchestra/conf/base_settings.py b/orchestra/conf/base_settings.py
index 4c0354ca..18fd6d69 100644
--- a/orchestra/conf/base_settings.py
+++ b/orchestra/conf/base_settings.py
@@ -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
diff --git a/orchestra/models/utils.py b/orchestra/models/utils.py
index 10c182f5..e3c1cdc5 100644
--- a/orchestra/models/utils.py
+++ b/orchestra/models/utils.py
@@ -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
diff --git a/orchestra/utils/humanize.py b/orchestra/utils/humanize.py
index 6d8e37b5..b7a5c012 100644
--- a/orchestra/utils/humanize.py
+++ b/orchestra/utils/humanize.py
@@ -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)