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
|
# 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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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 """
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 + '.'))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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×%s" % (metric, size)
|
return "%s×%s" % (metric, size)
|
||||||
# return ''
|
|
||||||
|
|
||||||
def create_sublines(self, line, discounts):
|
def create_sublines(self, line, discounts):
|
||||||
for discount in discounts:
|
for discount in discounts:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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):
|
||||||
session = requests.Session()
|
url_regex = r"""<span class=["']+%s["']+><a href=["']([^>]*)['"]>""" % action
|
||||||
self.login(session)
|
action_url = re.search(url_regex, content).groups()[0].replace("&", '&')
|
||||||
|
sys.stdout.write("%s confirm URL: %s\n" % (action, action_url))
|
||||||
|
|
||||||
try:
|
content = session.get(action_url, verify=self.VERIFY).content.decode('utf8')
|
||||||
id, wpnonce = 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 = re.compile('name="_wpnonce"\s+value="([^"]*)"')
|
||||||
|
try:
|
||||||
wpnonce = wpnonce.search(content).groups()[0]
|
wpnonce = wpnonce.search(content).groups()[0]
|
||||||
|
except AttributeError:
|
||||||
|
raise RuntimeError(re.search(r'<body id="error-page">([^<]+)<', content).groups()[0])
|
||||||
data = {
|
data = {
|
||||||
'action': 'deleteblog',
|
'action': action,
|
||||||
'id': id,
|
'id': id,
|
||||||
'_wpnonce': wpnonce,
|
'_wpnonce': wpnonce,
|
||||||
'_wp_http_referer': '/wp-admin/network/sites.php',
|
'_wp_http_referer': '/wp-admin/network/sites.php',
|
||||||
}
|
}
|
||||||
delete = self.get_main_url()
|
action_url = self.get_main_url()
|
||||||
delete += '/wp-admin/network/sites.php?action=deleteblog'
|
action_url += '/wp-admin/network/sites.php?action=%sblog' % action
|
||||||
sys.stdout.write("Delete URL: %s\n" % delete)
|
sys.stdout.write("%s URL: %s\n" % (action, action_url))
|
||||||
response = session.post(delete, data=data, verify=self.VERIFY)
|
response = session.post(action_url, data=data, verify=self.VERIFY)
|
||||||
self.validate_response(response)
|
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, content = self.get_id(session, saas)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
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):
|
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)
|
||||||
|
|
||||||
|
|
|
@ -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',),
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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'
|
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> miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))</tt><br>"
|
"<tt> miscellaneous.active and str(miscellaneous.identifier).endswith(('.org', '.net', '.com'))</tt><br>"
|
||||||
"<tt> contractedplan.plan.name == 'association_fee''</tt><br>"
|
"<tt> contractedplan.plan.name == 'association_fee''</tt><br>"
|
||||||
"<tt> instance.active</tt>"))
|
"<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,
|
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
|
||||||
|
|
|
@ -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)):
|
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)
|
||||||
|
|
|
@ -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',),
|
||||||
|
|
|
@ -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."))
|
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
|
||||||
|
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue