From d849ec886787e1121a18e309c184b565a680ff5e Mon Sep 17 00:00:00 2001 From: Marc Aymerich Date: Wed, 6 Apr 2016 19:00:16 +0000 Subject: [PATCH] Fixes on billing --- TODO.md | 24 ++++- orchestra/contrib/accounts/models.py | 9 +- orchestra/contrib/bills/actions.py | 7 +- orchestra/contrib/bills/admin.py | 61 ++++++++++-- orchestra/contrib/bills/models.py | 13 ++- orchestra/contrib/databases/models.py | 4 + orchestra/contrib/mailer/engine.py | 2 +- orchestra/contrib/orchestration/helpers.py | 8 +- orchestra/contrib/orders/admin.py | 3 +- orchestra/contrib/orders/billing.py | 9 +- orchestra/contrib/orders/filters.py | 2 +- orchestra/contrib/orders/models.py | 3 +- orchestra/contrib/payments/admin.py | 2 +- orchestra/contrib/saas/backends/owncloud.py | 1 + .../contrib/saas/backends/wordpressmu.py | 98 ++++++++++++------- orchestra/contrib/services/admin.py | 7 +- orchestra/contrib/services/handlers.py | 23 +++-- .../migrations/0004_auto_20160405_1133.py | 24 +++++ orchestra/contrib/services/models.py | 10 +- orchestra/contrib/services/tasks.py | 13 +++ .../tests/functional_tests/test_mailbox.py | 18 ++++ orchestra/contrib/vps/admin.py | 7 +- .../vps/migrations/0003_vps_is_active.py | 19 ++++ orchestra/contrib/vps/models.py | 6 ++ orchestra/contrib/websites/admin.py | 2 +- orchestra/utils/humanize.py | 29 +++--- 26 files changed, 313 insertions(+), 91 deletions(-) create mode 100644 orchestra/contrib/services/migrations/0004_auto_20160405_1133.py create mode 100644 orchestra/contrib/services/tasks.py create mode 100644 orchestra/contrib/vps/migrations/0003_vps_is_active.py diff --git a/TODO.md b/TODO.md index 7a3ab562..ea35ff7d 100644 --- a/TODO.md +++ b/TODO.md @@ -440,8 +440,24 @@ mkhomedir_helper or create ssh homes with bash.rc and such # Multiple domains wordpress -# TODO: separate ports for fpm version - # Reversion -# implement re-enable account -# Disable/enable saas and VPS +# Disable/enable SaaS and VPS + +# AGO + +# Don't show lines with size 0? +# pending orders with recharge do not show up +# Traffic of disabled accounts doesn't get disabled + +# is_active list filter account dissabled filtering support + +# URL encode "Order description" on clone +# Service CLONE METRIC doesn't work + + +# Show warning when saving order and metricstorage date is inconistent with registered date! + +# Warn user if changes are not saved + +# exclude from change list action, support for multiple exclusion +# support for better edditing bill lines and sublines diff --git a/orchestra/contrib/accounts/models.py b/orchestra/contrib/accounts/models.py index 3fe5327a..54e163a8 100644 --- a/orchestra/contrib/accounts/models.py +++ b/orchestra/contrib/accounts/models.py @@ -2,12 +2,13 @@ from django.contrib.auth import models as auth from django.conf import settings as djsettings from django.core import validators from django.db import models +from django.db.models import signals from django.apps import apps from django.utils import timezone, translation from django.utils.translation import ugettext_lazy as _ -from orchestra.contrib.orchestration.middlewares import OperationsMiddleware -from orchestra.contrib.orchestration import Operation +#from orchestra.contrib.orchestration.middlewares import OperationsMiddleware +#from orchestra.contrib.orchestration import Operation from orchestra.core import services from orchestra.utils.mail import send_email_template @@ -98,7 +99,9 @@ class Account(auth.AbstractBaseUser): def notify_related(self): """ Trigger save() on related objects that depend on this account """ for obj in self.get_services_to_disable(): - OperationsMiddleware.collect(Operation.SAVE, instance=obj, update_fields=()) + signals.pre_save.send(sender=type(obj), instance=obj) + signals.post_save.send(sender=type(obj), instance=obj) +# OperationsMiddleware.collect(Operation.SAVE, instance=obj, update_fields=()) def send_email(self, template, context, email_from=None, contacts=[], attachments=[], html=None): contacts = self.contacts.filter(email_usages=contacts) diff --git a/orchestra/contrib/bills/actions.py b/orchestra/contrib/bills/actions.py index 7692334a..e58baa72 100644 --- a/orchestra/contrib/bills/actions.py +++ b/orchestra/contrib/bills/actions.py @@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db import transaction from django.forms.models import modelformset_factory -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render, redirect from django.utils import translation, timezone from django.utils.safestring import mark_safe @@ -368,3 +368,8 @@ def service_report(modeladmin, request, queryset): 'totals': totals, } return render(request, 'admin/bills/billline/report.html', context) + + +def get_ids(modeladmin, request, queryset): + ids = ','.join(map(str, queryset.values_list('id', flat=True))) + return HttpResponseRedirect('?id__in=%s' % ids) diff --git a/orchestra/contrib/bills/admin.py b/orchestra/contrib/bills/admin.py index 3f6f2217..a16953e4 100644 --- a/orchestra/contrib/bills/admin.py +++ b/orchestra/contrib/bills/admin.py @@ -12,7 +12,7 @@ from django.utils.translation import ugettext_lazy as _ from django.shortcuts import redirect from orchestra.admin import ExtendedModelAdmin -from orchestra.admin.utils import admin_date, insertattr, admin_link +from orchestra.admin.utils import admin_date, insertattr, admin_link, change_url from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin from orchestra.forms.widgets import paddingCheckboxSelectMultiple @@ -21,7 +21,7 @@ from . import settings, actions from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter, PaymentStateListFilter, AmendedListFilter) from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine, - BillContact) + BillSubline, BillContact) PAYMENT_STATE_COLORS = { @@ -36,6 +36,27 @@ PAYMENT_STATE_COLORS = { } +class BillSublineInline(admin.TabularInline): + model = BillSubline + fields = ('description', 'total', 'type') + + def get_readonly_fields(self, request, obj=None): + fields = super().get_readonly_fields(request, obj) + if obj and not obj.bill.is_open: + return self.get_fields(request) + return fields + + def get_max_num(self, request, obj=None): + if obj and not obj.bill.is_open: + return 0 + return super().get_max_num(request, obj) + + def has_delete_permission(self, request, obj=None): + if obj and not obj.bill.is_open: + return False + return super().has_delete_permission(request, obj) + + class BillLineInline(admin.TabularInline): model = BillLine fields = ( @@ -50,11 +71,12 @@ class BillLineInline(admin.TabularInline): if line.pk: total = line.compute_total() sublines = line.sublines.all() + url = change_url(line) if sublines: content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in sublines]) img = static('admin/img/icon_alert.gif') - return '%s ' % (content, total, img) - return total + return '%s ' % (url, content, total, img) + return '%s' % (url, total) display_total.short_description = _("Total") display_total.allow_tags = True @@ -118,12 +140,30 @@ class BillLineAdmin(admin.ModelAdmin): actions = ( actions.undo_billing, actions.move_lines, actions.copy_lines, actions.service_report ) + fieldsets = ( + (None, { + 'fields': ('bill_link', 'description', 'tax', 'start_on', 'end_on', 'amended_line_link') + }), + (_("Totals"), { + 'fields': ('rate', ('quantity', 'verbose_quantity'), 'subtotal', 'display_sublinetotal', + 'display_total'), + }), + (_("Order"), { + 'fields': ('order_link', 'order_billed_on', 'order_billed_until',) + }), + ) + readonly_fields = ( + 'bill_link', 'order_link', 'amended_line_link', 'display_sublinetotal', 'display_total' + ) list_filter = ('tax', 'bill__is_open', 'order__service') list_select_related = ('bill', 'bill__account') search_fields = ('description', 'bill__number') + inlines = (BillSublineInline,) account_link = admin_link('bill__account') bill_link = admin_link('bill') + order_link = admin_link('order') + amended_line_link = admin_link('amended_line') def display_is_open(self, instance): return instance.bill.is_open @@ -140,6 +180,15 @@ class BillLineAdmin(admin.ModelAdmin): display_total.short_description = _("Total") display_total.admin_order_field = 'computed_total' + def get_readonly_fields(self, request, obj=None): + fields = super().get_readonly_fields(request, obj) + if obj and not obj.bill.is_open: + return list(fields) + [ + 'description', 'tax', 'start_on', 'end_on', 'rate', 'quantity', 'verbose_quantity', + 'subtotal', 'order_billed_on', 'order_billed_until' + ] + return fields + def get_queryset(self, request): qs = super().get_queryset(request) qs = qs.annotate( @@ -221,7 +270,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): actions = [ actions.manage_lines, actions.download_bills, actions.close_bills, actions.send_bills, actions.amend_bills, actions.bill_report, actions.service_report, - actions.close_send_download_bills, list_accounts, + actions.close_send_download_bills, list_accounts, actions.get_ids, ] change_readonly_fields = ( 'account_link', 'type', 'is_open', 'amend_of_link', 'amend_links' @@ -326,7 +375,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): else: fieldsets[0][1]['fields'][2] = 'amend_links' if obj.is_open: - fieldsets = (fieldsets[0],) + fieldsets = fieldsets[0:-1] return fieldsets def get_change_view_actions(self, obj=None): diff --git a/orchestra/contrib/bills/models.py b/orchestra/contrib/bills/models.py index d0bc3d6d..1296e977 100644 --- a/orchestra/contrib/bills/models.py +++ b/orchestra/contrib/bills/models.py @@ -11,6 +11,7 @@ from django.utils.encoding import force_text from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ +from orchestra.admin.utils import change_url from orchestra.contrib.accounts.models import Account from orchestra.contrib.contacts.models import Contact from orchestra.core import validators @@ -398,7 +399,7 @@ class BillLine(models.Model): rate = models.DecimalField(_("rate"), blank=True, null=True, max_digits=12, decimal_places=2) quantity = models.DecimalField(_("quantity"), blank=True, null=True, max_digits=12, decimal_places=2) - verbose_quantity = models.CharField(_("Verbose quantity"), max_length=16) + verbose_quantity = models.CharField(_("Verbose quantity"), max_length=16, blank=True) subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2) tax = models.DecimalField(_("tax"), max_digits=4, decimal_places=2) start_on = models.DateField(_("start")) @@ -422,6 +423,13 @@ class BillLine(models.Model): def get_verbose_quantity(self): return self.verbose_quantity or self.quantity + def clean(): + if not self.verbose_quantity: + quantity = str(self.quantity) + # Strip trailing zeros + if quantity.endswith('0'): + self.verbose_quantity = quantity.strip('0').strip('.') + def get_verbose_period(self): from django.template.defaultfilters import date date_format = "N 'y" @@ -448,6 +456,9 @@ class BillLine(models.Model): else: total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0 return round(total, 2) + + def get_absolute_url(self): + return change_url(self) class BillSubline(models.Model): diff --git a/orchestra/contrib/databases/models.py b/orchestra/contrib/databases/models.py index 2270a1f8..2ea924fa 100644 --- a/orchestra/contrib/databases/models.py +++ b/orchestra/contrib/databases/models.py @@ -38,6 +38,10 @@ class Database(models.Model): if user is not None: return user.databaseuser return None + + @property + def active(self): + return self.account.is_active Database.users.through._meta.unique_together = ( diff --git a/orchestra/contrib/mailer/engine.py b/orchestra/contrib/mailer/engine.py index fad98a46..27a649ed 100644 --- a/orchestra/contrib/mailer/engine.py +++ b/orchestra/contrib/mailer/engine.py @@ -27,7 +27,7 @@ def send_message(message, connection=None, bulk=settings.MAILER_BULK_MESSAGES): connection.open() except Exception as err: message.defer() - message.log(error) + message.log(err) return error = None try: diff --git a/orchestra/contrib/orchestration/helpers.py b/orchestra/contrib/orchestration/helpers.py index 430772f1..5300b73a 100644 --- a/orchestra/contrib/orchestration/helpers.py +++ b/orchestra/contrib/orchestration/helpers.py @@ -109,9 +109,11 @@ def message_user(request, logs): async_ids = [] for log in logs: total += 1 - if log.state != log.EXCEPTION: - # EXCEPTION logs are not stored on the database + try: + # Some EXCEPTION logs are not stored on the database ids.append(log.pk) + except AttributeError: + pass if log.is_success: successes += 1 elif not log.has_finished: @@ -160,5 +162,5 @@ def message_user(request, logs): ) messages.success(request, mark_safe(msg + '.')) else: - msg = async_msg.format(url=url, async_url=async_url, async=async) + msg = async_msg.format(url=url, async_url=async_url, async=async, name=log.backend) messages.success(request, mark_safe(msg + '.')) diff --git a/orchestra/contrib/orders/admin.py b/orchestra/contrib/orders/admin.py index f2f3ac70..024cbc5f 100644 --- a/orchestra/contrib/orders/admin.py +++ b/orchestra/contrib/orders/admin.py @@ -136,7 +136,7 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin): make_link = admin_link() for line in order.lines.select_related('bill').distinct('bill'): bills.append(make_link(line.bill)) - return ''.join(bills) bills_links.short_description = _("Bills") bills_links.allow_tags = True @@ -200,6 +200,7 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin): class MetricStorageAdmin(admin.ModelAdmin): list_display = ('order', 'value', 'created_on', 'updated_on') list_filter = ('order__service',) + raw_id_fields = ('order',) admin.site.register(Order, OrderAdmin) diff --git a/orchestra/contrib/orders/billing.py b/orchestra/contrib/orders/billing.py index f199bb44..650f8df5 100644 --- a/orchestra/contrib/orders/billing.py +++ b/orchestra/contrib/orders/billing.py @@ -77,20 +77,15 @@ class BillsBackend(object): return description def get_verbose_quantity(self, line): -# service = line.order.service -# if service.metric and service.billing_period != service.NEVER and service.pricing_period == service.NEVER: metric = format(line.metric, '.2f').rstrip('0').rstrip('.') - if metric.endswith('.00'): - metric = metric.split('.')[0] + metric = metric.strip('0').strip('.') size = format(line.size, '.2f').rstrip('0').rstrip('.') - if size.endswith('.00'): - size = metric.split('.')[0] + size = size.strip('0').strip('.') if metric == '1': return size if size == '1': return metric return "%s×%s" % (metric, size) -# return '' def create_sublines(self, line, discounts): for discount in discounts: diff --git a/orchestra/contrib/orders/filters.py b/orchestra/contrib/orders/filters.py index b11d237b..c0a54332 100644 --- a/orchestra/contrib/orders/filters.py +++ b/orchestra/contrib/orders/filters.py @@ -74,7 +74,7 @@ class BilledOrderListFilter(SimpleListFilter): pending_qs = Q( Q(pk__in=self.get_pending_metric_pks(ignore_qs)) | Q(billed_until__isnull=True) | Q(~Q(service__billing_period=Service.NEVER) & - Q(billed_until__lt=now)) + Q(billed_until__lte=now)) ) if reverse: return queryset.exclude(pending_qs) diff --git a/orchestra/contrib/orders/models.py b/orchestra/contrib/orders/models.py index d2c42613..e055194c 100644 --- a/orchestra/contrib/orders/models.py +++ b/orchestra/contrib/orders/models.py @@ -239,7 +239,7 @@ class Order(models.Model): created = metric.created_on if created > ini: if prev is None: - raise ValueError("Metric storage information is inconsistent.") + raise ValueError("Metric storage information for order %i is inconsistent." % self.id) cini = prev.created_on if not result: cini = ini @@ -297,6 +297,7 @@ class MetricStorage(models.Model): order = models.ForeignKey(Order, verbose_name=_("order"), related_name='metrics') value = models.DecimalField(_("value"), max_digits=16, decimal_places=2) created_on = models.DateField(_("created"), auto_now_add=True) + created_on.editable = True # TODO time field? updated_on = models.DateTimeField(_("updated")) diff --git a/orchestra/contrib/payments/admin.py b/orchestra/contrib/payments/admin.py index 9fb2b722..1c770fc3 100644 --- a/orchestra/contrib/payments/admin.py +++ b/orchestra/contrib/payments/admin.py @@ -165,7 +165,7 @@ class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin): lines.append(','.join(ids)) ids = [] lines.append(','.join(ids)) - transactions = '%s' % (url, transactions) diff --git a/orchestra/contrib/saas/backends/owncloud.py b/orchestra/contrib/saas/backends/owncloud.py index f4c6c6c2..aaf8c616 100644 --- a/orchestra/contrib/saas/backends/owncloud.py +++ b/orchestra/contrib/saas/backends/owncloud.py @@ -123,6 +123,7 @@ class OwnCloudController(OwnClouwAPIMixin, ServiceController): self.api_delete('users/%s' % saas.name) def save(self, saas): + # TODO disable user https://github.com/owncloud/core/issues/12601 self.append(self.update_or_create, saas) def delete(self, saas): diff --git a/orchestra/contrib/saas/backends/wordpressmu.py b/orchestra/contrib/saas/backends/wordpressmu.py index 45c38b56..d6a3ef78 100644 --- a/orchestra/contrib/saas/backends/wordpressmu.py +++ b/orchestra/contrib/saas/backends/wordpressmu.py @@ -1,6 +1,7 @@ import re import sys import textwrap +from functools import partial from urllib.parse import urlparse import requests @@ -45,7 +46,8 @@ class WordpressMuController(ServiceController): def validate_response(self, response): if response.status_code != 200: - errors = re.findall(r'\n\t

(.*)

', response.content.decode('utf8')) + content = response.content.decode('utf8') + errors = re.findall(r'\n\t

(.*)

', content) raise RuntimeError(errors[0] if errors else 'Unknown %i error' % response.status_code) def get_id(self, session, saas): @@ -66,14 +68,7 @@ class WordpressMuController(ServiceController): ids = ids.groups() if len(ids) > 1 and not blog_id: raise ValueError("Multiple matches") - # Get wpnonce - try: - wpnonce = re.search(r'(.*)', content).groups()[0] - except TypeError: - # No search results, try some luck - wpnonce = content - wpnonce = re.search(r'_wpnonce=([^"]*)"', wpnonce).groups()[0] - return blog_id or int(ids[0]), wpnonce + return blog_id or int(ids[0]), content def create_blog(self, saas, server): if saas.data.get('blog_id'): @@ -84,7 +79,7 @@ class WordpressMuController(ServiceController): # Check if blog already exists try: - blog_id, wpnonce = self.get_id(session, saas) + blog_id, content = self.get_id(session, saas) except RuntimeError: url = self.get_main_url() url += '/wp-admin/network/site-new.php' @@ -111,42 +106,69 @@ class WordpressMuController(ServiceController): sys.stdout.write("Created blog ID: %s\n" % blog_id) saas.data['blog_id'] = int(blog_id) saas.save(update_fields=('data',)) + return True else: sys.stdout.write("Retrieved blog ID: %s\n" % blog_id) saas.data['blog_id'] = int(blog_id) saas.save(update_fields=('data',)) - def delete_blog(self, saas, server): + def do_action(self, action, session, id, content, saas): + url_regex = r"""]*)['"]>""" % action + action_url = re.search(url_regex, content).groups()[0].replace("&", '&') + sys.stdout.write("%s confirm URL: %s\n" % (action, action_url)) + + content = session.get(action_url, verify=self.VERIFY).content.decode('utf8') + wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"') + try: + wpnonce = wpnonce.search(content).groups()[0] + except AttributeError: + raise RuntimeError(re.search(r'([^<]+)<', content).groups()[0]) + data = { + 'action': action, + 'id': id, + '_wpnonce': wpnonce, + '_wp_http_referer': '/wp-admin/network/sites.php', + } + action_url = self.get_main_url() + action_url += '/wp-admin/network/sites.php?action=%sblog' % action + sys.stdout.write("%s URL: %s\n" % (action, action_url)) + response = session.post(action_url, data=data, verify=self.VERIFY) + self.validate_response(response) + + def is_active(self, content): + return bool( + re.findall(r""" beyond: cend = comp.end + new_end = cend if only_beyond: cini = beyond elif only_beyond: @@ -334,8 +336,9 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): # Extend billing point a little bit to benefit from a substantial discount elif comp.end > beyond and (comp.end-comp.ini).days > 3*(comp.ini-beyond).days: cend = comp.end + new_end = cend dsize += self.get_price_size(comp.ini, cend) - return dsize, cend + return dsize, new_end def get_register_or_renew_events(self, porders, ini, end): counter = 0 @@ -394,8 +397,10 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): size = self.get_price_size(order.new_billed_until, new_end) price += price*size order.new_billed_until = new_end + ini = order.billed_until or order.registered_on + end = new_end or order.new_billed_until line = self.generate_line( - order, price, ini, new_end or end, discounts=discounts, computed=True) + order, price, ini, end, discounts=discounts, computed=True) lines.append(line) return lines @@ -462,7 +467,6 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): givers = sorted(givers, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on)) orders = sorted(orders, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on)) self.assign_compensations(givers, orders, **options) - rates = self.get_rates(account) has_billing_period = self.billing_period != self.NEVER has_pricing_period = self.get_pricing_period() != self.NEVER @@ -507,6 +511,8 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): for order in orders: prepay_discount = 0 bp = self.get_billing_point(order, bp=bp, **options) + recharged_until = datetime.date.min + if (self.billing_period != self.NEVER and self.get_pricing_period() == self.NEVER and self.payment_style == self.PREPAY and order.billed_on): @@ -543,11 +549,13 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): line = self.generate_line(order, price, cini, cend, metric=metric, computed=True, discounts=discounts) lines.append(line) + recharged_until = cend if order.billed_until and order.cancelled_on and order.cancelled_on >= order.billed_until: # Cancelled order continue if self.billing_period != self.NEVER: ini = order.billed_until or order.registered_on +# ini = max(order.billed_until or order.registered_on, recharged_until) # Periodic billing if bp <= ini: # Already billed @@ -556,6 +564,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): if self.get_pricing_period() == self.NEVER: # Changes (Mailbox disk-like) for cini, cend, metric in order.get_metric(ini, bp, changes=True): + cini = max(recharged_until, cini) price = self.get_price(account, metric) discounts = () # Since the current datamodel can't guarantee to retrieve the exact @@ -566,7 +575,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): # price -= discount # prepay_discount -= discount # discounts = ( -# (self._PREPAY', -discount), +# (self._PREPAY, -discount), # ) if metric > 0: line = self.generate_line(order, price, cini, cend, metric=metric, @@ -575,7 +584,8 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): elif self.get_pricing_period() == self.billing_period: # pricing_slots (Traffic-like) if self.payment_style == self.PREPAY: - raise NotImplementedError + raise NotImplementedError( + "Metric with prepay and pricing_period == billing_period") for cini, cend in self.get_pricing_slots(ini, bp): metric = order.get_metric(cini, cend) price = self.get_price(account, metric) @@ -601,7 +611,8 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount): line = self.generate_line(order, price, cini, cend, metric=metric) lines.append(line) else: - raise NotImplementedError + raise NotImplementedError( + "Metric with postpay and pricing_period in (monthly, anual)") else: raise NotImplementedError else: diff --git a/orchestra/contrib/services/migrations/0004_auto_20160405_1133.py b/orchestra/contrib/services/migrations/0004_auto_20160405_1133.py new file mode 100644 index 00000000..5820f707 --- /dev/null +++ b/orchestra/contrib/services/migrations/0004_auto_20160405_1133.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('services', '0003_auto_20150917_0942'), + ] + + operations = [ + migrations.AddField( + model_name='service', + name='periodic_update', + field=models.BooleanField(default=False, verbose_name='periodic update', help_text='Whether a periodic update of this service orders should be performed or not. Needed for match definitions that depend on complex model interactions.'), + ), + migrations.AlterField( + model_name='service', + name='rate_algorithm', + field=models.CharField(choices=[('orchestra.contrib.plans.ratings.match_price', 'Match price'), ('orchestra.contrib.plans.ratings.best_price', 'Best price'), ('orchestra.contrib.plans.ratings.step_price', 'Step price')], verbose_name='rate algorithm', help_text='Algorithm used to interprete the rating table.
  Match price: Only the rate with a) inmediate inferior metric and b) lower price is applied. Nominal price will be used when initial block is missing.
  Best price: Produces the best possible price given all active rating lines (those with quantity lower or equal to the metric).
  Step price: All rates with a quantity lower or equal than the metric are applied. Nominal price will be used when initial block is missing.', default='orchestra.contrib.plans.ratings.step_price', max_length=64), + ), + ] diff --git a/orchestra/contrib/services/models.py b/orchestra/contrib/services/models.py index 3b77cb50..1d3377e7 100644 --- a/orchestra/contrib/services/models.py +++ b/orchestra/contrib/services/models.py @@ -54,7 +54,8 @@ class Service(models.Model): PREPAY = 'PREPAY' POSTPAY = 'POSTPAY' - _ignore_types = ' and '.join(', '.join(settings.SERVICES_IGNORE_ACCOUNT_TYPE).rsplit(', ', 1)).lower() + _ignore_types = ' and '.join( + ', '.join(settings.SERVICES_IGNORE_ACCOUNT_TYPE).rsplit(', ', 1)).lower() description = models.CharField(_("description"), max_length=256, unique=True) content_type = models.ForeignKey(ContentType, verbose_name=_("content type"), @@ -70,6 +71,10 @@ class Service(models.Model): " miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))
" " contractedplan.plan.name == 'association_fee''
" " instance.active")) + periodic_update = models.BooleanField(_("periodic update"), default=False, + help_text=_("Whether a periodic update of this service orders should be performed or not. " + "Needed for match definitions that depend on complex model interactions, " + "where content type model save and delete operations are not enought.")) 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."), @@ -248,11 +253,12 @@ class Service(models.Model): def update_orders(self, commit=True): order_model = apps.get_model(settings.SERVICES_ORDER_MODEL) + manager = order_model.objects related_model = self.content_type.model_class() updates = [] queryset = related_model.objects.all() if related_model._meta.model_name != 'account': queryset = queryset.select_related('account').all() for instance in queryset: - updates += order_model.objects.update_by_instance(instance, service=self, commit=commit) + updates += manager.update_by_instance(instance, service=self, commit=commit) return updates diff --git a/orchestra/contrib/services/tasks.py b/orchestra/contrib/services/tasks.py new file mode 100644 index 00000000..87bf7bbb --- /dev/null +++ b/orchestra/contrib/services/tasks.py @@ -0,0 +1,13 @@ +from celery.task.schedules import crontab + +from orchestra.contrib.tasks import periodic_task + +from .models import Service + + +@periodic_task(run_every=crontab(hour=5, minute=30)) +def update_service_orders(): + updates = [] + for service in Service.objects.filter(periodic_update=True): + updates += service.update_orders(commit=True) + return updates diff --git a/orchestra/contrib/services/tests/functional_tests/test_mailbox.py b/orchestra/contrib/services/tests/functional_tests/test_mailbox.py index f822240c..32bd8afc 100644 --- a/orchestra/contrib/services/tests/functional_tests/test_mailbox.py +++ b/orchestra/contrib/services/tests/functional_tests/test_mailbox.py @@ -155,3 +155,21 @@ class MailboxBillingTest(BaseTestCase): with freeze_time(now+relativedelta(months=6)): bills = service.orders.bill(new_open=True, **options) self.assertEqual([], bills) + + def test_mailbox_second_billing(self): + service = self.create_mailbox_disk_service() + self.create_disk_resource() + account = self.create_account() + mailbox = self.create_mailbox(account=account) + now = timezone.now() + bp = now.date() + relativedelta(years=1) + options = dict(billing_point=bp, fixed_point=True) + bills = service.orders.bill(**options) + + with freeze_time(now+relativedelta(years=1, months=1)): + mailbox = self.create_mailbox(account=account) + alt_now = timezone.now() + bp = alt_now.date() + relativedelta(years=1) + options = dict(billing_point=bp, fixed_point=True) + bills = service.orders.bill(**options) + print(bills) diff --git a/orchestra/contrib/vps/admin.py b/orchestra/contrib/vps/admin.py index d1b31107..b512fd8b 100644 --- a/orchestra/contrib/vps/admin.py +++ b/orchestra/contrib/vps/admin.py @@ -4,14 +4,15 @@ from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.admin import AccountAdminMixin +from orchestra.contrib.accounts.filters import IsActiveListFilter from orchestra.forms import UserCreationForm, NonStoredUserChangeForm from .models import VPS class VPSAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): - list_display = ('hostname', 'type', 'template', 'account_link') - list_filter = ('type', 'template') + list_display = ('hostname', 'type', 'template', 'display_active', 'account_link') + list_filter = ('type', IsActiveListFilter, 'template') form = NonStoredUserChangeForm add_form = UserCreationForm readonly_fields = ('account_link',) @@ -20,7 +21,7 @@ class VPSAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('account_link', 'hostname', 'type', 'template') + 'fields': ('account_link', 'hostname', 'type', 'template', 'is_active') }), (_("Login"), { 'classes': ('wide',), diff --git a/orchestra/contrib/vps/migrations/0003_vps_is_active.py b/orchestra/contrib/vps/migrations/0003_vps_is_active.py new file mode 100644 index 00000000..e9206793 --- /dev/null +++ b/orchestra/contrib/vps/migrations/0003_vps_is_active.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vps', '0002_auto_20150804_1524'), + ] + + operations = [ + migrations.AddField( + model_name='vps', + name='is_active', + field=models.BooleanField(default=True, verbose_name='active'), + ), + ] diff --git a/orchestra/contrib/vps/models.py b/orchestra/contrib/vps/models.py index d0a69f85..4c7cc155 100644 --- a/orchestra/contrib/vps/models.py +++ b/orchestra/contrib/vps/models.py @@ -17,6 +17,7 @@ class VPS(models.Model): help_text=_("Initial template.")) account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), related_name='vpss') + is_active = models.BooleanField(_("active"), default=True) class Meta: verbose_name = "VPS" @@ -38,3 +39,8 @@ class VPS(models.Model): def enable(self): self.is_active = False self.save(update_fields=('is_active',)) + + @property + def active(self): + return self.is_active and self.account.is_active + diff --git a/orchestra/contrib/websites/admin.py b/orchestra/contrib/websites/admin.py index 9ce94df8..fed31707 100644 --- a/orchestra/contrib/websites/admin.py +++ b/orchestra/contrib/websites/admin.py @@ -64,7 +64,7 @@ class WebsiteAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): 'protocol', IsActiveListFilter, HasWebAppsListFilter, HasDomainsFilter ) change_readonly_fields = ('name',) - inlines = [ContentInline, WebsiteDirectiveInline] + inlines = (ContentInline, WebsiteDirectiveInline) filter_horizontal = ['domains'] fieldsets = ( (None, { diff --git a/orchestra/utils/humanize.py b/orchestra/utils/humanize.py index a908b33b..d905b883 100644 --- a/orchestra/utils/humanize.py +++ b/orchestra/utils/humanize.py @@ -4,13 +4,13 @@ from django.utils import timezone from django.utils.translation import ungettext, ugettext as _ -def verbose_time(n, units): +def verbose_time(n, units, ago='ago'): if n >= 5: - return _("{n} {units} ago").format(n=int(n), units=units) + return _("{n} {units} {ago}").format(n=int(n), units=units, ago=ago) return ungettext( - _("{n:.1f} {s_units} ago"), - _("{n:.1f} {units} ago"), n - ).format(n=n, units=units, s_units=units[:-1]) + _("{n:.1f} {s_units} {ago}"), + _("{n:.1f} {units} {ago}"), n + ).format(n=n, units=units, s_units=units[:-1], ago=ago) OLDER_CHUNKS = ( @@ -42,15 +42,18 @@ def naturaldatetime(date, show_seconds=False): seconds = delta.seconds days = abs(days) + ago = '' + if right_now > date: + ago = 'ago' if days == 0: if int(hours) == 0: if minutes >= 1 or not show_seconds: - return verbose_time(minutes, 'minutes') + return verbose_time(minutes, 'minutes', ago=ago) else: - return verbose_time(seconds, 'seconds') + return verbose_time(seconds, 'seconds', ago=ago) else: - return verbose_time(hours, 'hours') + return verbose_time(hours, 'hours', ago=ago) if delta_midnight.days == 0: date = timezone.localtime(date) @@ -60,11 +63,11 @@ def naturaldatetime(date, show_seconds=False): for chunk, units in OLDER_CHUNKS: if days < 7.0: count = days + float(hours)/24 - return verbose_time(count, 'days') + return verbose_time(count, 'days', ago=ago) if days >= chunk: count = (delta_midnight.days + 1) / chunk count = abs(count) - return verbose_time(count, units) + return verbose_time(count, units, ago=ago) def naturaldate(date): @@ -80,7 +83,7 @@ def naturaldate(date): elif days == 1: return _('yesterday') ago = ' ago' - if days < 0: + if days < 0 or today < date: ago = '' days = abs(days) delta_midnight = today - date @@ -89,12 +92,12 @@ def naturaldate(date): for chunk, units in OLDER_CHUNKS: if days < 7.0: count = days - fmt = verbose_time(count, 'days') + fmt = verbose_time(count, 'days', ago=ago) return fmt.format(num=count, ago=ago) if days >= chunk: count = (delta_midnight.days + 1) / chunk count = abs(count) - fmt = verbose_time(count, units) + fmt = verbose_time(count, units, ago=ago) return fmt.format(num=count, ago=ago)