Fixes on billing
This commit is contained in:
parent
908a4ca81d
commit
d849ec8867
24
TODO.md
24
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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 + '.'))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"))
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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("&", '&')
|
||||
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)
|
||||
|
||||
|
|
|
@ -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',),
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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> 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> Best price: Produces the best possible price given all active rating lines (those with quantity lower or equal to the metric).<br> 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),
|
||||
),
|
||||
]
|
|
@ -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> miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))</tt><br>"
|
||||
"<tt> contractedplan.plan.name == 'association_fee''</tt><br>"
|
||||
"<tt> 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
|
||||
|
|
13
orchestra/contrib/services/tasks.py
Normal file
13
orchestra/contrib/services/tasks.py
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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',),
|
||||
|
|
19
orchestra/contrib/vps/migrations/0003_vps_is_active.py
Normal file
19
orchestra/contrib/vps/migrations/0003_vps_is_active.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue