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 # Multiple domains wordpress
# TODO: separate ports for fpm version
# Reversion # 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.conf import settings as djsettings
from django.core import validators from django.core import validators
from django.db import models from django.db import models
from django.db.models import signals
from django.apps import apps from django.apps import apps
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.contrib.orchestration.middlewares import OperationsMiddleware #from orchestra.contrib.orchestration.middlewares import OperationsMiddleware
from orchestra.contrib.orchestration import Operation #from orchestra.contrib.orchestration import Operation
from orchestra.core import services from orchestra.core import services
from orchestra.utils.mail import send_email_template from orchestra.utils.mail import send_email_template
@ -98,7 +99,9 @@ class Account(auth.AbstractBaseUser):
def notify_related(self): def notify_related(self):
""" Trigger save() on related objects that depend on this account """ """ Trigger save() on related objects that depend on this account """
for obj in self.get_services_to_disable(): 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): def send_email(self, template, context, email_from=None, contacts=[], attachments=[], html=None):
contacts = self.contacts.filter(email_usages=contacts) 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.core.urlresolvers import reverse
from django.db import transaction from django.db import transaction
from django.forms.models import modelformset_factory 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.shortcuts import render, redirect
from django.utils import translation, timezone from django.utils import translation, timezone
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -368,3 +368,8 @@ def service_report(modeladmin, request, queryset):
'totals': totals, 'totals': totals,
} }
return render(request, 'admin/bills/billline/report.html', context) 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 django.shortcuts import redirect
from orchestra.admin import ExtendedModelAdmin 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.actions import list_accounts
from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin from orchestra.contrib.accounts.admin import AccountAdminMixin, AccountAdmin
from orchestra.forms.widgets import paddingCheckboxSelectMultiple from orchestra.forms.widgets import paddingCheckboxSelectMultiple
@ -21,7 +21,7 @@ from . import settings, actions
from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter, from .filters import (BillTypeListFilter, HasBillContactListFilter, TotalListFilter,
PaymentStateListFilter, AmendedListFilter) PaymentStateListFilter, AmendedListFilter)
from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine, from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, ProForma, BillLine,
BillContact) BillSubline, BillContact)
PAYMENT_STATE_COLORS = { 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): class BillLineInline(admin.TabularInline):
model = BillLine model = BillLine
fields = ( fields = (
@ -50,11 +71,12 @@ class BillLineInline(admin.TabularInline):
if line.pk: if line.pk:
total = line.compute_total() total = line.compute_total()
sublines = line.sublines.all() sublines = line.sublines.all()
url = change_url(line)
if sublines: if sublines:
content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in sublines]) content = '\n'.join(['%s: %s' % (sub.description, sub.total) for sub in sublines])
img = static('admin/img/icon_alert.gif') img = static('admin/img/icon_alert.gif')
return '<span title="%s">%s <img src="%s"></img></span>' % (content, total, img) return '<a href="%s" title="%s">%s <img src="%s"></img></a>' % (url, content, total, img)
return total return '<a href="%s">%s</a>' % (url, total)
display_total.short_description = _("Total") display_total.short_description = _("Total")
display_total.allow_tags = True display_total.allow_tags = True
@ -118,12 +140,30 @@ class BillLineAdmin(admin.ModelAdmin):
actions = ( actions = (
actions.undo_billing, actions.move_lines, actions.copy_lines, actions.service_report 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_filter = ('tax', 'bill__is_open', 'order__service')
list_select_related = ('bill', 'bill__account') list_select_related = ('bill', 'bill__account')
search_fields = ('description', 'bill__number') search_fields = ('description', 'bill__number')
inlines = (BillSublineInline,)
account_link = admin_link('bill__account') account_link = admin_link('bill__account')
bill_link = admin_link('bill') bill_link = admin_link('bill')
order_link = admin_link('order')
amended_line_link = admin_link('amended_line')
def display_is_open(self, instance): def display_is_open(self, instance):
return instance.bill.is_open return instance.bill.is_open
@ -140,6 +180,15 @@ class BillLineAdmin(admin.ModelAdmin):
display_total.short_description = _("Total") display_total.short_description = _("Total")
display_total.admin_order_field = 'computed_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): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
qs = qs.annotate( qs = qs.annotate(
@ -221,7 +270,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
actions = [ actions = [
actions.manage_lines, actions.download_bills, actions.close_bills, actions.send_bills, actions.manage_lines, actions.download_bills, actions.close_bills, actions.send_bills,
actions.amend_bills, actions.bill_report, actions.service_report, 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 = ( change_readonly_fields = (
'account_link', 'type', 'is_open', 'amend_of_link', 'amend_links' 'account_link', 'type', 'is_open', 'amend_of_link', 'amend_links'
@ -326,7 +375,7 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
else: else:
fieldsets[0][1]['fields'][2] = 'amend_links' fieldsets[0][1]['fields'][2] = 'amend_links'
if obj.is_open: if obj.is_open:
fieldsets = (fieldsets[0],) fieldsets = fieldsets[0:-1]
return fieldsets return fieldsets
def get_change_view_actions(self, obj=None): 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.functional import cached_property
from django.utils.translation import ugettext_lazy as _ 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.accounts.models import Account
from orchestra.contrib.contacts.models import Contact from orchestra.contrib.contacts.models import Contact
from orchestra.core import validators 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) 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, quantity = models.DecimalField(_("quantity"), blank=True, null=True, max_digits=12,
decimal_places=2) 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) subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
tax = models.DecimalField(_("tax"), max_digits=4, decimal_places=2) tax = models.DecimalField(_("tax"), max_digits=4, decimal_places=2)
start_on = models.DateField(_("start")) start_on = models.DateField(_("start"))
@ -422,6 +423,13 @@ class BillLine(models.Model):
def get_verbose_quantity(self): def get_verbose_quantity(self):
return self.verbose_quantity or self.quantity 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): def get_verbose_period(self):
from django.template.defaultfilters import date from django.template.defaultfilters import date
date_format = "N 'y" date_format = "N 'y"
@ -449,6 +457,9 @@ class BillLine(models.Model):
total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0 total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0
return round(total, 2) return round(total, 2)
def get_absolute_url(self):
return change_url(self)
class BillSubline(models.Model): class BillSubline(models.Model):
""" Subline used for describing an item discount """ """ Subline used for describing an item discount """

View File

@ -39,6 +39,10 @@ class Database(models.Model):
return user.databaseuser return user.databaseuser
return None return None
@property
def active(self):
return self.account.is_active
Database.users.through._meta.unique_together = ( Database.users.through._meta.unique_together = (
('database', 'databaseuser'), ('database', 'databaseuser'),

View File

@ -27,7 +27,7 @@ def send_message(message, connection=None, bulk=settings.MAILER_BULK_MESSAGES):
connection.open() connection.open()
except Exception as err: except Exception as err:
message.defer() message.defer()
message.log(error) message.log(err)
return return
error = None error = None
try: try:

View File

@ -109,9 +109,11 @@ def message_user(request, logs):
async_ids = [] async_ids = []
for log in logs: for log in logs:
total += 1 total += 1
if log.state != log.EXCEPTION: try:
# EXCEPTION logs are not stored on the database # Some EXCEPTION logs are not stored on the database
ids.append(log.pk) ids.append(log.pk)
except AttributeError:
pass
if log.is_success: if log.is_success:
successes += 1 successes += 1
elif not log.has_finished: elif not log.has_finished:
@ -160,5 +162,5 @@ def message_user(request, logs):
) )
messages.success(request, mark_safe(msg + '.')) messages.success(request, mark_safe(msg + '.'))
else: 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 + '.')) messages.success(request, mark_safe(msg + '.'))

View File

@ -136,7 +136,7 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
make_link = admin_link() make_link = admin_link()
for line in order.lines.select_related('bill').distinct('bill'): for line in order.lines.select_related('bill').distinct('bill'):
bills.append(make_link(line.bill)) bills.append(make_link(line.bill))
return '<br'.join(bills) return '<br>'.join(bills)
bills_links.short_description = _("Bills") bills_links.short_description = _("Bills")
bills_links.allow_tags = True bills_links.allow_tags = True
@ -200,6 +200,7 @@ class OrderAdmin(AccountAdminMixin, ExtendedModelAdmin):
class MetricStorageAdmin(admin.ModelAdmin): class MetricStorageAdmin(admin.ModelAdmin):
list_display = ('order', 'value', 'created_on', 'updated_on') list_display = ('order', 'value', 'created_on', 'updated_on')
list_filter = ('order__service',) list_filter = ('order__service',)
raw_id_fields = ('order',)
admin.site.register(Order, OrderAdmin) admin.site.register(Order, OrderAdmin)

View File

@ -77,20 +77,15 @@ class BillsBackend(object):
return description return description
def get_verbose_quantity(self, line): 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('.') metric = format(line.metric, '.2f').rstrip('0').rstrip('.')
if metric.endswith('.00'): metric = metric.strip('0').strip('.')
metric = metric.split('.')[0]
size = format(line.size, '.2f').rstrip('0').rstrip('.') size = format(line.size, '.2f').rstrip('0').rstrip('.')
if size.endswith('.00'): size = size.strip('0').strip('.')
size = metric.split('.')[0]
if metric == '1': if metric == '1':
return size return size
if size == '1': if size == '1':
return metric return metric
return "%s&times;%s" % (metric, size) return "%s&times;%s" % (metric, size)
# return ''
def create_sublines(self, line, discounts): def create_sublines(self, line, discounts):
for discount in discounts: for discount in discounts:

View File

@ -74,7 +74,7 @@ class BilledOrderListFilter(SimpleListFilter):
pending_qs = Q( pending_qs = Q(
Q(pk__in=self.get_pending_metric_pks(ignore_qs)) | 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__isnull=True) | Q(~Q(service__billing_period=Service.NEVER) &
Q(billed_until__lt=now)) Q(billed_until__lte=now))
) )
if reverse: if reverse:
return queryset.exclude(pending_qs) return queryset.exclude(pending_qs)

View File

@ -239,7 +239,7 @@ class Order(models.Model):
created = metric.created_on created = metric.created_on
if created > ini: if created > ini:
if prev is None: 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 cini = prev.created_on
if not result: if not result:
cini = ini cini = ini
@ -297,6 +297,7 @@ class MetricStorage(models.Model):
order = models.ForeignKey(Order, verbose_name=_("order"), related_name='metrics') order = models.ForeignKey(Order, verbose_name=_("order"), related_name='metrics')
value = models.DecimalField(_("value"), max_digits=16, decimal_places=2) value = models.DecimalField(_("value"), max_digits=16, decimal_places=2)
created_on = models.DateField(_("created"), auto_now_add=True) created_on = models.DateField(_("created"), auto_now_add=True)
created_on.editable = True
# TODO time field? # TODO time field?
updated_on = models.DateTimeField(_("updated")) updated_on = models.DateTimeField(_("updated"))

View File

@ -165,7 +165,7 @@ class TransactionProcessAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
lines.append(','.join(ids)) lines.append(','.join(ids))
ids = [] ids = []
lines.append(','.join(ids)) lines.append(','.join(ids))
transactions = '<br'.join(lines) transactions = '<br>'.join(lines)
url = reverse('admin:payments_transaction_changelist') url = reverse('admin:payments_transaction_changelist')
url += '?process_id=%i' % process.id url += '?process_id=%i' % process.id
return '<a href="%s">%s</a>' % (url, transactions) 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) self.api_delete('users/%s' % saas.name)
def save(self, saas): def save(self, saas):
# TODO disable user https://github.com/owncloud/core/issues/12601
self.append(self.update_or_create, saas) self.append(self.update_or_create, saas)
def delete(self, saas): def delete(self, saas):

View File

@ -1,6 +1,7 @@
import re import re
import sys import sys
import textwrap import textwrap
from functools import partial
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
@ -45,7 +46,8 @@ class WordpressMuController(ServiceController):
def validate_response(self, response): def validate_response(self, response):
if response.status_code != 200: 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) raise RuntimeError(errors[0] if errors else 'Unknown %i error' % response.status_code)
def get_id(self, session, saas): def get_id(self, session, saas):
@ -66,14 +68,7 @@ class WordpressMuController(ServiceController):
ids = ids.groups() ids = ids.groups()
if len(ids) > 1 and not blog_id: if len(ids) > 1 and not blog_id:
raise ValueError("Multiple matches") raise ValueError("Multiple matches")
# Get wpnonce return blog_id or int(ids[0]), content
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
def create_blog(self, saas, server): def create_blog(self, saas, server):
if saas.data.get('blog_id'): if saas.data.get('blog_id'):
@ -84,7 +79,7 @@ class WordpressMuController(ServiceController):
# Check if blog already exists # Check if blog already exists
try: try:
blog_id, wpnonce = self.get_id(session, saas) blog_id, content = self.get_id(session, saas)
except RuntimeError: except RuntimeError:
url = self.get_main_url() url = self.get_main_url()
url += '/wp-admin/network/site-new.php' url += '/wp-admin/network/site-new.php'
@ -111,42 +106,69 @@ class WordpressMuController(ServiceController):
sys.stdout.write("Created blog ID: %s\n" % blog_id) sys.stdout.write("Created blog ID: %s\n" % blog_id)
saas.data['blog_id'] = int(blog_id) saas.data['blog_id'] = int(blog_id)
saas.save(update_fields=('data',)) saas.save(update_fields=('data',))
return True
else: else:
sys.stdout.write("Retrieved blog ID: %s\n" % blog_id) sys.stdout.write("Retrieved blog ID: %s\n" % blog_id)
saas.data['blog_id'] = int(blog_id) saas.data['blog_id'] = int(blog_id)
saas.save(update_fields=('data',)) 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() session = requests.Session()
self.login(session) self.login(session)
try: try:
id, wpnonce = self.get_id(session, saas) id, content = self.get_id(session, saas)
except RuntimeError: except RuntimeError:
pass pass
else: else:
delete = self.get_main_url() if not self.is_active(content):
delete += '/wp-admin/network/sites.php?action=confirm&action2=deleteblog' return self.do_action('activate', session, id, content, saas)
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') def deactivate(self, saas, server):
wpnonce = re.compile('name="_wpnonce"\s+value="([^"]*)"') session = requests.Session()
wpnonce = wpnonce.search(content).groups()[0] self.login(session)
data = { try:
'action': 'deleteblog', id, content = self.get_id(session, saas)
'id': id, except RuntimeError:
'_wpnonce': wpnonce, pass
'_wp_http_referer': '/wp-admin/network/sites.php', else:
} if self.is_active(content):
delete = self.get_main_url() return self.do_action('deactivate', session, id, content, saas)
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)
def save(self, 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 = self.get_context(saas)
context['IDENT'] = "b.domain = '%(domain)s'" % context context['IDENT'] = "b.domain = '%(domain)s'" % context
if context['blog_id']: if context['blog_id']:
@ -200,6 +222,16 @@ class WordpressMuController(ServiceController):
fi""") % context 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): def delete(self, saas):
self.append(self.delete_blog, 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' 'description', 'content_type', 'handler_type', 'num_orders', 'is_active'
) )
list_filter = ( list_filter = (
'is_active', 'handler_type', ('content_type', admin.RelatedOnlyFieldListFilter), 'is_active', 'handler_type', 'is_fee',
('content_type', admin.RelatedOnlyFieldListFilter),
) )
fieldsets = ( fieldsets = (
(None, { (None, {
'classes': ('wide',), 'classes': ('wide',),
'fields': ('description', 'content_type', 'match', 'handler_type', 'fields': ('description', 'content_type', 'match', 'periodic_update',
'ignore_superusers', 'is_active') 'handler_type', 'ignore_superusers', 'is_active')
}), }),
(_("Billing options"), { (_("Billing options"), {
'classes': ('wide',), 'classes': ('wide',),

View File

@ -320,12 +320,14 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
end = order.new_billed_until end = order.new_billed_until
beyond = end beyond = end
cend = None cend = None
new_end = None
for comp in getattr(order, '_compensations', []): for comp in getattr(order, '_compensations', []):
intersect = comp.intersect(helpers.Interval(ini=ini, end=end)) intersect = comp.intersect(helpers.Interval(ini=ini, end=end))
if intersect: if intersect:
cini, cend = intersect.ini, intersect.end cini, cend = intersect.ini, intersect.end
if comp.end > beyond: if comp.end > beyond:
cend = comp.end cend = comp.end
new_end = cend
if only_beyond: if only_beyond:
cini = beyond cini = beyond
elif only_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 # 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: elif comp.end > beyond and (comp.end-comp.ini).days > 3*(comp.ini-beyond).days:
cend = comp.end cend = comp.end
new_end = cend
dsize += self.get_price_size(comp.ini, 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): def get_register_or_renew_events(self, porders, ini, end):
counter = 0 counter = 0
@ -394,8 +397,10 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
size = self.get_price_size(order.new_billed_until, new_end) size = self.get_price_size(order.new_billed_until, new_end)
price += price*size price += price*size
order.new_billed_until = new_end 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( 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) lines.append(line)
return lines 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)) 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)) orders = sorted(orders, key=cmp_to_key(helpers.cmp_billed_until_or_registered_on))
self.assign_compensations(givers, orders, **options) self.assign_compensations(givers, orders, **options)
rates = self.get_rates(account) rates = self.get_rates(account)
has_billing_period = self.billing_period != self.NEVER has_billing_period = self.billing_period != self.NEVER
has_pricing_period = self.get_pricing_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: for order in orders:
prepay_discount = 0 prepay_discount = 0
bp = self.get_billing_point(order, bp=bp, **options) bp = self.get_billing_point(order, bp=bp, **options)
recharged_until = datetime.date.min
if (self.billing_period != self.NEVER and if (self.billing_period != self.NEVER and
self.get_pricing_period() == self.NEVER and self.get_pricing_period() == self.NEVER and
self.payment_style == self.PREPAY and order.billed_on): 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, line = self.generate_line(order, price, cini, cend, metric=metric,
computed=True, discounts=discounts) computed=True, discounts=discounts)
lines.append(line) lines.append(line)
recharged_until = cend
if order.billed_until and order.cancelled_on and order.cancelled_on >= order.billed_until: if order.billed_until and order.cancelled_on and order.cancelled_on >= order.billed_until:
# Cancelled order # Cancelled order
continue continue
if self.billing_period != self.NEVER: if self.billing_period != self.NEVER:
ini = order.billed_until or order.registered_on ini = order.billed_until or order.registered_on
# ini = max(order.billed_until or order.registered_on, recharged_until)
# Periodic billing # Periodic billing
if bp <= ini: if bp <= ini:
# Already billed # Already billed
@ -556,6 +564,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
if self.get_pricing_period() == self.NEVER: if self.get_pricing_period() == self.NEVER:
# Changes (Mailbox disk-like) # Changes (Mailbox disk-like)
for cini, cend, metric in order.get_metric(ini, bp, changes=True): for cini, cend, metric in order.get_metric(ini, bp, changes=True):
cini = max(recharged_until, cini)
price = self.get_price(account, metric) price = self.get_price(account, metric)
discounts = () discounts = ()
# Since the current datamodel can't guarantee to retrieve the exact # Since the current datamodel can't guarantee to retrieve the exact
@ -566,7 +575,7 @@ class ServiceHandler(plugins.Plugin, metaclass=plugins.PluginMount):
# price -= discount # price -= discount
# prepay_discount -= discount # prepay_discount -= discount
# discounts = ( # discounts = (
# (self._PREPAY', -discount), # (self._PREPAY, -discount),
# ) # )
if metric > 0: if metric > 0:
line = self.generate_line(order, price, cini, cend, metric=metric, 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: elif self.get_pricing_period() == self.billing_period:
# pricing_slots (Traffic-like) # pricing_slots (Traffic-like)
if self.payment_style == self.PREPAY: 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): for cini, cend in self.get_pricing_slots(ini, bp):
metric = order.get_metric(cini, cend) metric = order.get_metric(cini, cend)
price = self.get_price(account, metric) 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) line = self.generate_line(order, price, cini, cend, metric=metric)
lines.append(line) lines.append(line)
else: else:
raise NotImplementedError raise NotImplementedError(
"Metric with postpay and pricing_period in (monthly, anual)")
else: else:
raise NotImplementedError raise NotImplementedError
else: 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' PREPAY = 'PREPAY'
POSTPAY = 'POSTPAY' 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) description = models.CharField(_("description"), max_length=256, unique=True)
content_type = models.ForeignKey(ContentType, verbose_name=_("content type"), 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;miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))</tt><br>"
"<tt>&nbsp;contractedplan.plan.name == 'association_fee''</tt><br>" "<tt>&nbsp;contractedplan.plan.name == 'association_fee''</tt><br>"
"<tt>&nbsp;instance.active</tt>")) "<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, handler_type = models.CharField(_("handler"), max_length=256, blank=True,
help_text=_("Handler used for processing this Service. A handler enables customized " help_text=_("Handler used for processing this Service. A handler enables customized "
"behaviour far beyond what options here allow to."), "behaviour far beyond what options here allow to."),
@ -248,11 +253,12 @@ class Service(models.Model):
def update_orders(self, commit=True): def update_orders(self, commit=True):
order_model = apps.get_model(settings.SERVICES_ORDER_MODEL) order_model = apps.get_model(settings.SERVICES_ORDER_MODEL)
manager = order_model.objects
related_model = self.content_type.model_class() related_model = self.content_type.model_class()
updates = [] updates = []
queryset = related_model.objects.all() queryset = related_model.objects.all()
if related_model._meta.model_name != 'account': if related_model._meta.model_name != 'account':
queryset = queryset.select_related('account').all() queryset = queryset.select_related('account').all()
for instance in queryset: 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 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)): with freeze_time(now+relativedelta(months=6)):
bills = service.orders.bill(new_open=True, **options) bills = service.orders.bill(new_open=True, **options)
self.assertEqual([], bills) 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.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
from orchestra.contrib.accounts.actions import list_accounts from orchestra.contrib.accounts.actions import list_accounts
from orchestra.contrib.accounts.admin import AccountAdminMixin from orchestra.contrib.accounts.admin import AccountAdminMixin
from orchestra.contrib.accounts.filters import IsActiveListFilter
from orchestra.forms import UserCreationForm, NonStoredUserChangeForm from orchestra.forms import UserCreationForm, NonStoredUserChangeForm
from .models import VPS from .models import VPS
class VPSAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin): class VPSAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin):
list_display = ('hostname', 'type', 'template', 'account_link') list_display = ('hostname', 'type', 'template', 'display_active', 'account_link')
list_filter = ('type', 'template') list_filter = ('type', IsActiveListFilter, 'template')
form = NonStoredUserChangeForm form = NonStoredUserChangeForm
add_form = UserCreationForm add_form = UserCreationForm
readonly_fields = ('account_link',) readonly_fields = ('account_link',)
@ -20,7 +21,7 @@ class VPSAdmin(ChangePasswordAdminMixin, AccountAdminMixin, ExtendedModelAdmin):
fieldsets = ( fieldsets = (
(None, { (None, {
'classes': ('wide',), 'classes': ('wide',),
'fields': ('account_link', 'hostname', 'type', 'template') 'fields': ('account_link', 'hostname', 'type', 'template', 'is_active')
}), }),
(_("Login"), { (_("Login"), {
'classes': ('wide',), '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.")) help_text=_("Initial template."))
account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), account = models.ForeignKey('accounts.Account', verbose_name=_("Account"),
related_name='vpss') related_name='vpss')
is_active = models.BooleanField(_("active"), default=True)
class Meta: class Meta:
verbose_name = "VPS" verbose_name = "VPS"
@ -38,3 +39,8 @@ class VPS(models.Model):
def enable(self): def enable(self):
self.is_active = False self.is_active = False
self.save(update_fields=('is_active',)) 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 'protocol', IsActiveListFilter, HasWebAppsListFilter, HasDomainsFilter
) )
change_readonly_fields = ('name',) change_readonly_fields = ('name',)
inlines = [ContentInline, WebsiteDirectiveInline] inlines = (ContentInline, WebsiteDirectiveInline)
filter_horizontal = ['domains'] filter_horizontal = ['domains']
fieldsets = ( fieldsets = (
(None, { (None, {

View File

@ -4,13 +4,13 @@ from django.utils import timezone
from django.utils.translation import ungettext, ugettext as _ from django.utils.translation import ungettext, ugettext as _
def verbose_time(n, units): def verbose_time(n, units, ago='ago'):
if n >= 5: 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( return ungettext(
_("{n:.1f} {s_units} ago"), _("{n:.1f} {s_units} {ago}"),
_("{n:.1f} {units} ago"), n _("{n:.1f} {units} {ago}"), n
).format(n=n, units=units, s_units=units[:-1]) ).format(n=n, units=units, s_units=units[:-1], ago=ago)
OLDER_CHUNKS = ( OLDER_CHUNKS = (
@ -42,15 +42,18 @@ def naturaldatetime(date, show_seconds=False):
seconds = delta.seconds seconds = delta.seconds
days = abs(days) days = abs(days)
ago = ''
if right_now > date:
ago = 'ago'
if days == 0: if days == 0:
if int(hours) == 0: if int(hours) == 0:
if minutes >= 1 or not show_seconds: if minutes >= 1 or not show_seconds:
return verbose_time(minutes, 'minutes') return verbose_time(minutes, 'minutes', ago=ago)
else: else:
return verbose_time(seconds, 'seconds') return verbose_time(seconds, 'seconds', ago=ago)
else: else:
return verbose_time(hours, 'hours') return verbose_time(hours, 'hours', ago=ago)
if delta_midnight.days == 0: if delta_midnight.days == 0:
date = timezone.localtime(date) date = timezone.localtime(date)
@ -60,11 +63,11 @@ def naturaldatetime(date, show_seconds=False):
for chunk, units in OLDER_CHUNKS: for chunk, units in OLDER_CHUNKS:
if days < 7.0: if days < 7.0:
count = days + float(hours)/24 count = days + float(hours)/24
return verbose_time(count, 'days') return verbose_time(count, 'days', ago=ago)
if days >= chunk: if days >= chunk:
count = (delta_midnight.days + 1) / chunk count = (delta_midnight.days + 1) / chunk
count = abs(count) count = abs(count)
return verbose_time(count, units) return verbose_time(count, units, ago=ago)
def naturaldate(date): def naturaldate(date):
@ -80,7 +83,7 @@ def naturaldate(date):
elif days == 1: elif days == 1:
return _('yesterday') return _('yesterday')
ago = ' ago' ago = ' ago'
if days < 0: if days < 0 or today < date:
ago = '' ago = ''
days = abs(days) days = abs(days)
delta_midnight = today - date delta_midnight = today - date
@ -89,12 +92,12 @@ def naturaldate(date):
for chunk, units in OLDER_CHUNKS: for chunk, units in OLDER_CHUNKS:
if days < 7.0: if days < 7.0:
count = days count = days
fmt = verbose_time(count, 'days') fmt = verbose_time(count, 'days', ago=ago)
return fmt.format(num=count, ago=ago) return fmt.format(num=count, ago=ago)
if days >= chunk: if days >= chunk:
count = (delta_midnight.days + 1) / chunk count = (delta_midnight.days + 1) / chunk
count = abs(count) count = abs(count)
fmt = verbose_time(count, units) fmt = verbose_time(count, units, ago=ago)
return fmt.format(num=count, ago=ago) return fmt.format(num=count, ago=ago)