Fixes on billing

This commit is contained in:
Marc Aymerich 2016-04-06 19:00:16 +00:00
parent 908a4ca81d
commit d849ec8867
26 changed files with 313 additions and 91 deletions

24
TODO.md
View file

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

View file

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

View file

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

View file

@ -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 '<span title="%s">%s <img src="%s"></img></span>' % (content, total, img)
return total
return '<a href="%s" title="%s">%s <img src="%s"></img></a>' % (url, content, total, img)
return '<a href="%s">%s</a>' % (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):

View file

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

View file

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

View file

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

View file

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

View file

@ -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 '<br'.join(bills)
return '<br>'.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)

View file

@ -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&times;%s" % (metric, size)
# return ''
def create_sublines(self, line, discounts):
for discount in discounts:

View file

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

View file

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

View file

@ -165,7 +165,7 @@ class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
lines.append(','.join(ids))
ids = []
lines.append(','.join(ids))
transactions = '<br'.join(lines)
transactions = '<br>'.join(lines)
url = reverse('admin:payments_transaction_changelist')
url += '?process_id=%i' % process.id
return '<a href="%s">%s</a>' % (url, transactions)

View file

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

View file

@ -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'<body id="error-page">\n\t<p>(.*)</p></body>', response.content.decode('utf8'))
content = response.content.decode('utf8')
errors = re.findall(r'<body id="error-page">\n\t<p>(.*)</p></body>', 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'<span class="delete">(.*)</span>', 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"""<span class=["']+%s["']+><a href=["']([^>]*)['"]>""" % action
action_url = re.search(url_regex, content).groups()[0].replace("&#038;", '&')
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'<body id="error-page">([^<]+)<', 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"""<span class=["']deactivate['"]""", content) and
not re.findall(r"""<span class=["']activate['"]""", content)
)
def activate(self, saas, server):
session = requests.Session()
self.login(session)
try:
id, wpnonce = self.get_id(session, saas)
id, content = self.get_id(session, saas)
except RuntimeError:
pass
else:
delete = self.get_main_url()
delete += '/wp-admin/network/sites.php?action=confirm&action2=deleteblog'
delete += '&id=%d&_wpnonce=%s' % (id, wpnonce)
sys.stdout.write("Search URL: %s\n" % delete)
content = session.get(delete, verify=self.VERIFY).content.decode('utf8')
wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"')
wpnonce = wpnonce.search(content).groups()[0]
data = {
'action': 'deleteblog',
'id': id,
'_wpnonce': wpnonce,
'_wp_http_referer': '/wp-admin/network/sites.php',
}
delete = self.get_main_url()
delete += '/wp-admin/network/sites.php?action=deleteblog'
sys.stdout.write("Delete URL: %s\n" % delete)
response = session.post(delete, data=data, verify=self.VERIFY)
self.validate_response(response)
if not self.is_active(content):
return self.do_action('activate', session, id, content, saas)
def deactivate(self, saas, server):
session = requests.Session()
self.login(session)
try:
id, content = self.get_id(session, saas)
except RuntimeError:
pass
else:
if self.is_active(content):
return self.do_action('deactivate', session, id, content, saas)
def save(self, saas):
self.append(self.create_blog, saas)
created = self.append(self.create_blog, saas)
if saas.active and not created:
self.append(self.activate, saas)
else:
self.append(self.deactivate, saas)
context = self.get_context(saas)
context['IDENT'] = "b.domain = '%(domain)s'" % context
if context['blog_id']:
@ -200,6 +222,16 @@ class WordpressMuController(ServiceController):
fi""") % context
)
def delete_blog(self, saas, server):
session = requests.Session()
self.login(session)
try:
id, content = self.get_id(session, saas)
except RuntimeError:
pass
else:
return self.do_action('delete', session, id, content, saas)
def delete(self, saas):
self.append(self.delete_blog, saas)

View file

@ -19,13 +19,14 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
'description', 'content_type', 'handler_type', 'num_orders', 'is_active'
)
list_filter = (
'is_active', 'handler_type', ('content_type', admin.RelatedOnlyFieldListFilter),
'is_active', 'handler_type', 'is_fee',
('content_type', admin.RelatedOnlyFieldListFilter),
)
fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('description', 'content_type', 'match', 'handler_type',
'ignore_superusers', 'is_active')
'fields': ('description', 'content_type', 'match', 'periodic_update',
'handler_type', 'ignore_superusers', 'is_active')
}),
(_("Billing options"), {
'classes': ('wide',),

View file

@ -320,12 +320,14 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
end = order.new_billed_until
beyond = end
cend = None
new_end = None
for comp in getattr(order, '_compensations', []):
intersect = comp.intersect(helpers.Interval(ini=ini, end=end))
if intersect:
cini, cend = intersect.ini, intersect.end
if comp.end > 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:

View file

@ -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 <tt>match</tt> 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.<br>&nbsp;&nbsp;Match price: Only <b>the rate</b> with a) inmediate inferior metric and b) lower price is applied. Nominal price will be used when initial block is missing.<br>&nbsp;&nbsp;Best price: Produces the best possible price given all active rating lines (those with quantity lower or equal to the metric).<br>&nbsp;&nbsp;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),
),
]

View file

@ -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):
"<tt>&nbsp;miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))</tt><br>"
"<tt>&nbsp;contractedplan.plan.name == 'association_fee''</tt><br>"
"<tt>&nbsp;instance.active</tt>"))
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 <tt>match</tt> definitions that depend on complex model interactions, "
"where <tt>content type</tt> 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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