From 7298c9393e97c8fb9f43bc8dfae57182a9a737c2 Mon Sep 17 00:00:00 2001 From: Marc Date: Wed, 17 Sep 2014 10:32:29 +0000 Subject: [PATCH] Refactor services out of orders --- orchestra/admin/menu.py | 6 +- orchestra/admin/utils.py | 9 +- orchestra/apps/orders/admin.py | 85 +---- orchestra/apps/orders/helpers.py | 136 -------- orchestra/apps/orders/models.py | 303 +---------------- orchestra/apps/orders/settings.py | 19 +- orchestra/apps/services/__init__.py | 1 + orchestra/apps/services/admin.py | 90 +++++ .../apps/{orders => services}/handlers.py | 5 +- orchestra/apps/services/helpers.py | 134 ++++++++ orchestra/apps/services/models.py | 311 ++++++++++++++++++ orchestra/apps/{orders => services}/rating.py | 0 orchestra/apps/services/settings.py | 14 + .../{orders => services}/tests/__init__.py | 0 .../tests/functional_tests/__init__.py | 0 .../services/tests/functional_tests/tests.py | 94 ++++++ .../tests/test_handler.py} | 139 +++----- orchestra/conf/base_settings.py | 13 +- orchestra/models/utils.py | 4 +- orchestra/utils/humanize.py | 28 +- 20 files changed, 752 insertions(+), 639 deletions(-) create mode 100644 orchestra/apps/services/__init__.py create mode 100644 orchestra/apps/services/admin.py rename orchestra/apps/{orders => services}/handlers.py (98%) create mode 100644 orchestra/apps/services/helpers.py create mode 100644 orchestra/apps/services/models.py rename orchestra/apps/{orders => services}/rating.py (100%) create mode 100644 orchestra/apps/services/settings.py rename orchestra/apps/{orders => services}/tests/__init__.py (100%) rename orchestra/apps/{orders => services}/tests/functional_tests/__init__.py (100%) create mode 100644 orchestra/apps/services/tests/functional_tests/tests.py rename orchestra/apps/{orders/tests/functional_tests/tests.py => services/tests/test_handler.py} (75%) 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)