Split services into plans
This commit is contained in:
parent
ae7c5b7969
commit
9b87ef5e0d
10
TODO.md
10
TODO.md
|
@ -1,4 +1,4 @@
|
||||||
TODO ====
|
==== TODO ====
|
||||||
|
|
||||||
* scape strings before executing scripts in order to prevent exploits: django templates automatically scapes things. Most important is to ensuer that all escape ' to "
|
* scape strings before executing scripts in order to prevent exploits: django templates automatically scapes things. Most important is to ensuer that all escape ' to "
|
||||||
* Don't store passwords and other service parameters that can be changed by the services i.e. mailman, vps etc. Find an execution mechanism that trigger `change_password()`
|
* Don't store passwords and other service parameters that can be changed by the services i.e. mailman, vps etc. Find an execution mechanism that trigger `change_password()`
|
||||||
|
@ -152,7 +152,6 @@ Remember that, as always with QuerySets, any subsequent chained methods which im
|
||||||
* Secondary user home in /home/secondaryuser and simlink to /home/main/webapps/app so it can have private storage?
|
* Secondary user home in /home/secondaryuser and simlink to /home/main/webapps/app so it can have private storage?
|
||||||
* Grant permissions to systemusers, the problem of creating a related permission model is out of sync with the server-side. evaluate tradeoff
|
* Grant permissions to systemusers, the problem of creating a related permission model is out of sync with the server-side. evaluate tradeoff
|
||||||
|
|
||||||
* Secondaryusers home should be under mainuser home. i.e. /home/mainuser/webapps/seconduser_webapp/
|
|
||||||
* Make one dedicated CGI user for each account only for CGI execution (fpm/fcgid). Different from the files owner, and without W permissions, so attackers can not inject backdors and malware.
|
* Make one dedicated CGI user for each account only for CGI execution (fpm/fcgid). Different from the files owner, and without W permissions, so attackers can not inject backdors and malware.
|
||||||
* In most cases we can prevent the creation of files for the CGI users, preventing attackers to upload and executing PHPShells.
|
* In most cases we can prevent the creation of files for the CGI users, preventing attackers to upload and executing PHPShells.
|
||||||
* Make main systemuser able to write/read everything on its home, including stuff created by the CGI user and secondary users
|
* Make main systemuser able to write/read everything on its home, including stuff created by the CGI user and secondary users
|
||||||
|
@ -169,8 +168,13 @@ Remember that, as always with QuerySets, any subsequent chained methods which im
|
||||||
* Directory Protection on webapp and use webapp path as base path (validate)
|
* Directory Protection on webapp and use webapp path as base path (validate)
|
||||||
* User [Group] webapp/website option (validation) which overrides default mainsystemuser
|
* User [Group] webapp/website option (validation) which overrides default mainsystemuser
|
||||||
|
|
||||||
* validate systemuser.home
|
* validate systemuser.home on server-side
|
||||||
|
|
||||||
* webapp backend option compatibility check?
|
* webapp backend option compatibility check?
|
||||||
|
|
||||||
* admin systemuser home/directory, add default home and empty directory with has_shell on admin
|
* admin systemuser home/directory, add default home and empty directory with has_shell on admin
|
||||||
|
|
||||||
|
|
||||||
|
* Backendlog doesn't show during execution, transaction isolation or what?
|
||||||
|
|
||||||
|
* Resource used_list_display=True, allocated_list_displat=True, allow resources to show up on list_display
|
||||||
|
|
|
@ -43,10 +43,7 @@ def get_services():
|
||||||
|
|
||||||
|
|
||||||
def get_accounts():
|
def get_accounts():
|
||||||
childrens = [
|
childrens=[]
|
||||||
items.MenuItem(_("Accounts"),
|
|
||||||
reverse('admin:accounts_account_changelist'))
|
|
||||||
]
|
|
||||||
if isinstalled('orchestra.apps.payments'):
|
if isinstalled('orchestra.apps.payments'):
|
||||||
url = reverse('admin:payments_transactionprocess_changelist')
|
url = reverse('admin:payments_transactionprocess_changelist')
|
||||||
childrens.append(items.MenuItem(_("Transaction processes"), url))
|
childrens.append(items.MenuItem(_("Transaction processes"), url))
|
||||||
|
@ -68,7 +65,7 @@ def get_administration_items():
|
||||||
if isinstalled('orchestra.apps.services'):
|
if isinstalled('orchestra.apps.services'):
|
||||||
url = reverse('admin:services_service_changelist')
|
url = reverse('admin:services_service_changelist')
|
||||||
childrens.append(items.MenuItem(_("Services"), url))
|
childrens.append(items.MenuItem(_("Services"), url))
|
||||||
url = reverse('admin:services_plan_changelist')
|
url = reverse('admin:plans_plan_changelist')
|
||||||
childrens.append(items.MenuItem(_("Plans"), url))
|
childrens.append(items.MenuItem(_("Plans"), url))
|
||||||
if isinstalled('orchestra.apps.orchestration'):
|
if isinstalled('orchestra.apps.orchestration'):
|
||||||
route = reverse('admin:orchestration_route_changelist')
|
route = reverse('admin:orchestration_route_changelist')
|
||||||
|
|
|
@ -40,7 +40,7 @@ class APIRoot(views.APIView):
|
||||||
if model in services:
|
if model in services:
|
||||||
group = 'services'
|
group = 'services'
|
||||||
menu = services[model].menu
|
menu = services[model].menu
|
||||||
elif model in accounts:
|
if model in accounts:
|
||||||
group = 'accountancy'
|
group = 'accountancy'
|
||||||
menu = accounts[model].menu
|
menu = accounts[model].menu
|
||||||
if group and menu:
|
if group and menu:
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.db.models.loading import get_model
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.core import services
|
from orchestra.core import services, accounts
|
||||||
from orchestra.utils import send_email_template
|
from orchestra.utils import send_email_template
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
|
@ -154,3 +154,4 @@ class Account(auth.AbstractBaseUser):
|
||||||
|
|
||||||
|
|
||||||
services.register(Account, menu=False)
|
services.register(Account, menu=False)
|
||||||
|
accounts.register(Account)
|
||||||
|
|
|
@ -108,18 +108,33 @@ class MysqlDisk(ServiceMonitor):
|
||||||
""" % context
|
""" % context
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
""" slower """
|
||||||
|
self.append(textwrap.dedent("""\
|
||||||
|
function monitor () {
|
||||||
|
{ du -bs "/var/lib/mysql/$1" || echo 0; } | awk {'print $1'}
|
||||||
|
}"""))
|
||||||
|
# Slower way
|
||||||
|
#self.append(textwrap.dedent("""\
|
||||||
|
# function monitor () {
|
||||||
|
# mysql -B -e "
|
||||||
|
# SELECT IFNULL(sum(data_length + index_length), 0) 'Size'
|
||||||
|
# FROM information_schema.TABLES
|
||||||
|
# WHERE table_schema = '$1';
|
||||||
|
# " | tail -n 1
|
||||||
|
# }"""))
|
||||||
|
|
||||||
def monitor(self, db):
|
def monitor(self, db):
|
||||||
if db.type != db.MYSQL:
|
if db.type != db.MYSQL:
|
||||||
return
|
return
|
||||||
context = self.get_context(db)
|
context = self.get_context(db)
|
||||||
self.append(textwrap.dedent("""\
|
self.append("echo %(db_id)s $(monitor %(db_name)s)" % context)
|
||||||
echo %(db_id)s $(mysql -B -e '"
|
|
||||||
SELECT sum( data_length + index_length ) "Size"
|
def monitor(self, db):
|
||||||
FROM information_schema.TABLES
|
if db.type != db.MYSQL:
|
||||||
WHERE table_schema = "gisp"
|
return
|
||||||
GROUP BY table_schema;' | tail -n 1) \
|
context = self.get_context(db)
|
||||||
""" % context
|
self.append('echo %(db_id)s $(monitor "%(db_name)s")' % context)
|
||||||
))
|
|
||||||
|
|
||||||
def get_context(self, db):
|
def get_context(self, db):
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -41,6 +41,7 @@ class ListAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModel
|
||||||
'fields': ('password',),
|
'fields': ('password',),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
search_fields = ('name', 'address_name', 'address_domain__name', 'account__username')
|
||||||
readonly_fields = ('account_link',)
|
readonly_fields = ('account_link',)
|
||||||
change_readonly_fields = ('name',)
|
change_readonly_fields = ('name',)
|
||||||
form = ListChangeForm
|
form = ListChangeForm
|
||||||
|
|
|
@ -206,16 +206,25 @@ class AutoresponseBackend(ServiceController):
|
||||||
|
|
||||||
|
|
||||||
class MaildirDisk(ServiceMonitor):
|
class MaildirDisk(ServiceMonitor):
|
||||||
|
"""
|
||||||
|
Maildir disk usage based on Dovecot maildirsize file
|
||||||
|
|
||||||
|
http://wiki2.dovecot.org/Quota/Maildir
|
||||||
|
"""
|
||||||
model = 'mailboxes.Mailbox'
|
model = 'mailboxes.Mailbox'
|
||||||
resource = ServiceMonitor.DISK
|
resource = ServiceMonitor.DISK
|
||||||
verbose_name = _("Maildir disk usage")
|
verbose_name = _("Maildir disk usage")
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
current_date = self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
self.append(textwrap.dedent("""\
|
||||||
|
function monitor () {
|
||||||
|
awk 'NR>1 {s+=$1} END {print s}' $1 || echo 0
|
||||||
|
}"""))
|
||||||
|
|
||||||
def monitor(self, mailbox):
|
def monitor(self, mailbox):
|
||||||
context = self.get_context(mailbox)
|
context = self.get_context(mailbox)
|
||||||
self.append(
|
self.append("echo %(object_id)s $(monitor %(maildir_path)s)" % context)
|
||||||
"SIZE=$(awk 'NR>1 {s+=$1} END {print s}' %(maildir_path)s)\n"
|
|
||||||
"echo %(object_id)s ${SIZE:-0}" % context
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_context(self, mailbox):
|
def get_context(self, mailbox):
|
||||||
context = {
|
context = {
|
||||||
|
|
|
@ -17,5 +17,5 @@ ORDERS_EXCLUDED_APPS = getattr(settings, 'ORDERS_EXCLUDED_APPS', (
|
||||||
'sessions',
|
'sessions',
|
||||||
'orchestration',
|
'orchestration',
|
||||||
'bills',
|
'bills',
|
||||||
# Do not put services here (plans)
|
'services',
|
||||||
))
|
))
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.admin import ExtendedModelAdmin
|
||||||
|
from orchestra.admin.filters import UsedContentTypeFilter
|
||||||
|
from orchestra.admin.utils import insertattr
|
||||||
|
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||||
|
from orchestra.apps.services.models import Service
|
||||||
|
|
||||||
|
from .models import Plan, ContractedPlan, Rate
|
||||||
|
|
||||||
|
|
||||||
|
class RateInline(admin.TabularInline):
|
||||||
|
model = Rate
|
||||||
|
ordering = ('plan', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
|
class PlanAdmin(ExtendedModelAdmin):
|
||||||
|
list_display = ('name', 'is_default', 'is_combinable', 'allow_multiple')
|
||||||
|
list_filter = ('is_default', 'is_combinable', 'allow_multiple')
|
||||||
|
fields = ('verbose_name', 'name', 'is_default', 'is_combinable', 'allow_multiple')
|
||||||
|
prepopulated_fields = {
|
||||||
|
'name': ('verbose_name',)
|
||||||
|
}
|
||||||
|
change_readonly_fields = ('name',)
|
||||||
|
inlines = [RateInline]
|
||||||
|
|
||||||
|
|
||||||
|
class ContractedPlanAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||||
|
list_display = ('plan', 'account_link')
|
||||||
|
list_filter = ('plan__name',)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Plan, PlanAdmin)
|
||||||
|
admin.site.register(ContractedPlan, ContractedPlanAdmin)
|
||||||
|
|
||||||
|
insertattr(Service, 'inlines', RateInline)
|
|
@ -0,0 +1,90 @@
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
from django.core.validators import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from orchestra.core import services, accounts
|
||||||
|
from orchestra.core.validators import validate_name
|
||||||
|
from orchestra.models import queryset
|
||||||
|
|
||||||
|
from . import rating
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(models.Model):
|
||||||
|
name = models.CharField(_("name"), max_length=32, unique=True, validators=[validate_name])
|
||||||
|
verbose_name = models.CharField(_("verbose_name"), max_length=128, blank=True)
|
||||||
|
is_default = models.BooleanField(_("default"), default=False,
|
||||||
|
help_text=_("Designates whether this plan is used by default or not."))
|
||||||
|
is_combinable = models.BooleanField(_("combinable"), default=True,
|
||||||
|
help_text=_("Designates whether this plan can be combined with other plans or not."))
|
||||||
|
allow_multiple = models.BooleanField(_("allow multiple"), default=False,
|
||||||
|
help_text=_("Designates whether this plan allow for multiple contractions."))
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
self.verbose_name = self.verbose_name.strip()
|
||||||
|
|
||||||
|
def get_verbose_name(self):
|
||||||
|
return self.verbose_name or self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ContractedPlan(models.Model):
|
||||||
|
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='contracts')
|
||||||
|
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
||||||
|
related_name='plans')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = _("plans")
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return str(self.plan)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if not self.pk and not self.plan.allow_multiples:
|
||||||
|
if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists():
|
||||||
|
raise ValidationError("A contracted plan for this account already exists.")
|
||||||
|
|
||||||
|
|
||||||
|
class RateQuerySet(models.QuerySet):
|
||||||
|
group_by = queryset.group_by
|
||||||
|
|
||||||
|
def by_account(self, account):
|
||||||
|
# Default allways selected
|
||||||
|
return self.filter(
|
||||||
|
Q(plan__is_default=True) |
|
||||||
|
Q(plan__contracts__account=account)
|
||||||
|
).order_by('plan', 'quantity').select_related('plan')
|
||||||
|
|
||||||
|
|
||||||
|
class Rate(models.Model):
|
||||||
|
STEP_PRICE = 'STEP_PRICE'
|
||||||
|
MATCH_PRICE = 'MATCH_PRICE'
|
||||||
|
RATE_METHODS = {
|
||||||
|
STEP_PRICE: rating.step_price,
|
||||||
|
MATCH_PRICE: rating.match_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
service = models.ForeignKey('services.Service', verbose_name=_("service"),
|
||||||
|
related_name='rates')
|
||||||
|
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='rates')
|
||||||
|
quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True)
|
||||||
|
price = models.DecimalField(_("price"), max_digits=12, decimal_places=2)
|
||||||
|
|
||||||
|
objects = RateQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('service', 'plan', 'quantity')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return "{}-{}".format(str(self.price), self.quantity)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_methods(self):
|
||||||
|
return self.RATE_METHODS
|
||||||
|
|
||||||
|
|
||||||
|
accounts.register(ContractedPlan)
|
||||||
|
services.register(ContractedPlan, menu=False)
|
|
@ -0,0 +1,80 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import models, migrations
|
||||||
|
import orchestra.core.validators
|
||||||
|
import orchestra.apps.resources.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
import orchestra.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('djcelery', '__first__'),
|
||||||
|
('contenttypes', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MonitorData',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('monitor', models.CharField(max_length=256, verbose_name='monitor', choices=[(b'Apache2Traffic', '[M] Apache 2 Traffic'), (b'MaildirDisk', '[M] Maildir disk usage'), (b'MailmanSubscribers', '[M] Mailman subscribers'), (b'MailmanTraffic', '[M] Mailman traffic'), (b'FTPTraffic', '[M] Main FTP traffic'), (b'SystemUserDisk', '[M] Main user disk'), (b'MysqlDisk', '[M] MySQL disk'), (b'OpenVZTraffic', '[M] OpenVZTraffic')])),
|
||||||
|
('object_id', models.PositiveIntegerField(verbose_name='object id')),
|
||||||
|
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created')),
|
||||||
|
('value', models.DecimalField(verbose_name='value', max_digits=16, decimal_places=2)),
|
||||||
|
('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'get_latest_by': 'id',
|
||||||
|
'verbose_name_plural': 'monitor data',
|
||||||
|
},
|
||||||
|
bases=(models.Model,),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Resource',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('name', models.CharField(help_text='Required. 32 characters or fewer. Lowercase letters, digits and hyphen only.', max_length=32, verbose_name='name', validators=[orchestra.core.validators.validate_name])),
|
||||||
|
('verbose_name', models.CharField(max_length=256, verbose_name='verbose name')),
|
||||||
|
('period', models.CharField(default=b'LAST', help_text='Operation used for aggregating this resource monitored data.', max_length=16, verbose_name='period', choices=[(b'LAST', 'Last'), (b'MONTHLY_SUM', 'Monthly Sum'), (b'MONTHLY_AVG', 'Monthly Average')])),
|
||||||
|
('on_demand', models.BooleanField(default=False, help_text='If enabled the resource will not be pre-allocated, but allocated under the application demand', verbose_name='on demand')),
|
||||||
|
('default_allocation', models.PositiveIntegerField(help_text='Default allocation value used when this is not an on demand resource', null=True, verbose_name='default allocation', blank=True)),
|
||||||
|
('unit', models.CharField(help_text='The unit in which this resource is represented. For example GB, KB or subscribers', max_length=16, verbose_name='unit')),
|
||||||
|
('scale', models.CharField(help_text='Scale in which this resource monitoring resoults should be prorcessed to match with unit. e.g. <tt>10**9</tt>', max_length=32, verbose_name='scale', validators=[orchestra.apps.resources.validators.validate_scale])),
|
||||||
|
('disable_trigger', models.BooleanField(default=False, help_text='Disables monitors exeeded and recovery triggers', verbose_name='disable trigger')),
|
||||||
|
('monitors', orchestra.models.fields.MultiSelectField(blank=True, help_text='Monitor backends used for monitoring this resource.', max_length=256, verbose_name='monitors', choices=[(b'Apache2Traffic', '[M] Apache 2 Traffic'), (b'MaildirDisk', '[M] Maildir disk usage'), (b'MailmanSubscribers', '[M] Mailman subscribers'), (b'MailmanTraffic', '[M] Mailman traffic'), (b'FTPTraffic', '[M] Main FTP traffic'), (b'SystemUserDisk', '[M] Main user disk'), (b'MysqlDisk', '[M] MySQL disk'), (b'OpenVZTraffic', '[M] OpenVZTraffic')])),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='active')),
|
||||||
|
('content_type', models.ForeignKey(help_text='Model where this resource will be hooked.', to='contenttypes.ContentType')),
|
||||||
|
('crontab', models.ForeignKey(blank=True, to='djcelery.CrontabSchedule', help_text='Crontab for periodic execution. Leave it empty to disable periodic monitoring', null=True, verbose_name='crontab')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
},
|
||||||
|
bases=(models.Model,),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ResourceData',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('object_id', models.PositiveIntegerField(verbose_name='object id')),
|
||||||
|
('used', models.DecimalField(verbose_name='used', null=True, editable=False, max_digits=16, decimal_places=2)),
|
||||||
|
('updated_at', models.DateTimeField(verbose_name='updated', null=True, editable=False)),
|
||||||
|
('allocated', models.DecimalField(null=True, verbose_name='allocated', max_digits=8, decimal_places=2, blank=True)),
|
||||||
|
('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType')),
|
||||||
|
('resource', models.ForeignKey(related_name='dataset', verbose_name='resource', to='resources.Resource')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'resource data',
|
||||||
|
},
|
||||||
|
bases=(models.Model,),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='resourcedata',
|
||||||
|
unique_together=set([('resource', 'content_type', 'object_id')]),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='resource',
|
||||||
|
unique_together=set([('name', 'content_type'), ('verbose_name', 'content_type')]),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,20 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import models, migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('resources', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='resourcedata',
|
||||||
|
name='used',
|
||||||
|
field=models.DecimalField(verbose_name='used', null=True, editable=False, max_digits=16, decimal_places=3),
|
||||||
|
preserve_default=True,
|
||||||
|
),
|
||||||
|
]
|
|
@ -4,34 +4,13 @@ from django.core.urlresolvers import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.admin import ChangeViewActionsMixin, ExtendedModelAdmin
|
from orchestra.admin import ChangeViewActionsMixin
|
||||||
from orchestra.admin.filters import UsedContentTypeFilter
|
from orchestra.admin.filters import UsedContentTypeFilter
|
||||||
from orchestra.apps.accounts.admin import AccountAdminMixin
|
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||||
from orchestra.core import services
|
from orchestra.core import services
|
||||||
|
|
||||||
from .actions import update_orders, view_help, clone
|
from .actions import update_orders, view_help, clone
|
||||||
from .models import Plan, ContractedPlan, Rate, Service
|
from .models import Service
|
||||||
|
|
||||||
|
|
||||||
class RateInline(admin.TabularInline):
|
|
||||||
model = Rate
|
|
||||||
ordering = ('plan', 'quantity')
|
|
||||||
|
|
||||||
|
|
||||||
class PlanAdmin(ExtendedModelAdmin):
|
|
||||||
list_display = ('name', 'is_default', 'is_combinable', 'allow_multiple')
|
|
||||||
list_filter = ('is_default', 'is_combinable', 'allow_multiple')
|
|
||||||
fields = ('verbose_name', 'name', 'is_default', 'is_combinable', 'allow_multiple')
|
|
||||||
prepopulated_fields = {
|
|
||||||
'name': ('verbose_name',)
|
|
||||||
}
|
|
||||||
change_readonly_fields = ('name',)
|
|
||||||
inlines = [RateInline]
|
|
||||||
|
|
||||||
|
|
||||||
class ContractedPlanAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|
||||||
list_display = ('plan', 'account_link')
|
|
||||||
list_filter = ('plan__name',)
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
|
class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
|
||||||
|
@ -56,7 +35,6 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
|
||||||
'on_cancel', 'payment_style', 'tax', 'nominal_price')
|
'on_cancel', 'payment_style', 'tax', 'nominal_price')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
inlines = [RateInline]
|
|
||||||
actions = [update_orders, clone]
|
actions = [update_orders, clone]
|
||||||
change_view_actions = actions + [view_help]
|
change_view_actions = actions + [view_help]
|
||||||
|
|
||||||
|
@ -95,6 +73,4 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Plan, PlanAdmin)
|
|
||||||
admin.site.register(ContractedPlan, ContractedPlanAdmin)
|
|
||||||
admin.site.register(Service, ServiceAdmin)
|
admin.site.register(Service, ServiceAdmin)
|
||||||
|
|
|
@ -9,78 +9,14 @@ from django.utils.functional import cached_property
|
||||||
from django.utils.module_loading import autodiscover_modules
|
from django.utils.module_loading import autodiscover_modules
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orchestra.core import caches, services, accounts, validators
|
from orchestra.core import caches, validators
|
||||||
from orchestra.core.validators import validate_name
|
from orchestra.core.validators import validate_name
|
||||||
from orchestra.models import queryset
|
from orchestra.models import queryset
|
||||||
|
|
||||||
from . import settings, rating
|
from . import settings
|
||||||
from .handlers import ServiceHandler
|
from .handlers import ServiceHandler
|
||||||
|
|
||||||
|
|
||||||
class Plan(models.Model):
|
|
||||||
name = models.CharField(_("name"), max_length=32, unique=True, validators=[validate_name])
|
|
||||||
verbose_name = models.CharField(_("verbose_name"), max_length=128, blank=True)
|
|
||||||
is_default = models.BooleanField(_("default"), default=False,
|
|
||||||
help_text=_("Designates whether this plan is used by default or not."))
|
|
||||||
is_combinable = models.BooleanField(_("combinable"), default=True,
|
|
||||||
help_text=_("Designates whether this plan can be combined with other plans or not."))
|
|
||||||
allow_multiple = models.BooleanField(_("allow multiple"), default=False,
|
|
||||||
help_text=_("Designates whether this plan allow for multiple contractions."))
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
self.verbose_name = self.verbose_name.strip()
|
|
||||||
|
|
||||||
def get_verbose_name(self):
|
|
||||||
return self.verbose_name or self.name
|
|
||||||
|
|
||||||
|
|
||||||
class ContractedPlan(models.Model):
|
|
||||||
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='contracts')
|
|
||||||
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
|
|
||||||
related_name='plans')
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = _("plans")
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return str(self.plan)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
if not self.pk and not self.plan.allow_multiples:
|
|
||||||
if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists():
|
|
||||||
raise ValidationError("A contracted plan for this account already exists.")
|
|
||||||
|
|
||||||
|
|
||||||
class RateQuerySet(models.QuerySet):
|
|
||||||
group_by = queryset.group_by
|
|
||||||
|
|
||||||
def by_account(self, account):
|
|
||||||
# Default allways selected
|
|
||||||
return self.filter(
|
|
||||||
Q(plan__is_default=True) |
|
|
||||||
Q(plan__contracts__account=account)
|
|
||||||
).order_by('plan', 'quantity').select_related('plan')
|
|
||||||
|
|
||||||
|
|
||||||
class Rate(models.Model):
|
|
||||||
service = models.ForeignKey('services.Service', verbose_name=_("service"),
|
|
||||||
related_name='rates')
|
|
||||||
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='rates')
|
|
||||||
quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True)
|
|
||||||
price = models.DecimalField(_("price"), max_digits=12, decimal_places=2)
|
|
||||||
|
|
||||||
objects = RateQuerySet.as_manager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ('service', 'plan', 'quantity')
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return "{}-{}".format(str(self.price), self.quantity)
|
|
||||||
|
|
||||||
|
|
||||||
autodiscover_modules('handlers')
|
autodiscover_modules('handlers')
|
||||||
|
|
||||||
|
|
||||||
|
@ -105,12 +41,6 @@ class Service(models.Model):
|
||||||
REFUND = 'REFUND'
|
REFUND = 'REFUND'
|
||||||
PREPAY = 'PREPAY'
|
PREPAY = 'PREPAY'
|
||||||
POSTPAY = 'POSTPAY'
|
POSTPAY = 'POSTPAY'
|
||||||
STEP_PRICE = 'STEP_PRICE'
|
|
||||||
MATCH_PRICE = 'MATCH_PRICE'
|
|
||||||
RATE_METHODS = {
|
|
||||||
STEP_PRICE: rating.step_price,
|
|
||||||
MATCH_PRICE: rating.match_price,
|
|
||||||
}
|
|
||||||
|
|
||||||
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"),
|
||||||
|
@ -197,11 +127,12 @@ class Service(models.Model):
|
||||||
default=BILLING_PERIOD)
|
default=BILLING_PERIOD)
|
||||||
rate_algorithm = models.CharField(_("rate algorithm"), max_length=16,
|
rate_algorithm = models.CharField(_("rate algorithm"), max_length=16,
|
||||||
help_text=_("Algorithm used to interprete the rating table."),
|
help_text=_("Algorithm used to interprete the rating table."),
|
||||||
|
# TODO this should be dynamic, retrieved from rate (plans) app
|
||||||
choices=(
|
choices=(
|
||||||
(STEP_PRICE, _("Step price")),
|
('STEP_PRICE', _("Step price")),
|
||||||
(MATCH_PRICE, _("Match price")),
|
('MATCH_PRICE', _("Match price")),
|
||||||
),
|
),
|
||||||
default=STEP_PRICE)
|
default='STEP_PRICE')
|
||||||
on_cancel = models.CharField(_("on cancel"), max_length=16,
|
on_cancel = models.CharField(_("on cancel"), max_length=16,
|
||||||
help_text=_("Defines the cancellation behaviour of this service."),
|
help_text=_("Defines the cancellation behaviour of this service."),
|
||||||
choices=(
|
choices=(
|
||||||
|
@ -297,7 +228,8 @@ class Service(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rate_method(self):
|
def rate_method(self):
|
||||||
return self.RATE_METHODS[self.rate_algorithm]
|
rate_model = type(self).rates.related.model
|
||||||
|
return rate_model.get_methods()[self.rate_algorithm]
|
||||||
|
|
||||||
def update_orders(self, commit=True):
|
def update_orders(self, commit=True):
|
||||||
order_model = get_model(settings.SERVICES_ORDER_MODEL)
|
order_model = get_model(settings.SERVICES_ORDER_MODEL)
|
||||||
|
@ -306,7 +238,3 @@ class Service(models.Model):
|
||||||
for instance in related_model.objects.all().select_related('account'):
|
for instance in related_model.objects.all().select_related('account'):
|
||||||
updates += order_model.update_orders(instance, service=self, commit=commit)
|
updates += order_model.update_orders(instance, service=self, commit=commit)
|
||||||
return updates
|
return updates
|
||||||
|
|
||||||
|
|
||||||
accounts.register(ContractedPlan)
|
|
||||||
services.register(ContractedPlan, menu=False)
|
|
||||||
|
|
|
@ -31,7 +31,7 @@ class SystemUserSerializer(AccountSerializerMixin, HyperlinkedModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SystemUser
|
model = SystemUser
|
||||||
fields = (
|
fields = (
|
||||||
'url', 'username', 'password', 'home', 'shell', 'groups', 'is_active',
|
'url', 'username', 'password', 'home', 'directory', 'shell', 'groups', 'is_active',
|
||||||
)
|
)
|
||||||
postonly_fields = ('username',)
|
postonly_fields = ('username',)
|
||||||
|
|
||||||
|
|
|
@ -72,10 +72,6 @@ INSTALLED_APPS = (
|
||||||
'orchestra.apps.orchestration',
|
'orchestra.apps.orchestration',
|
||||||
'orchestra.apps.domains',
|
'orchestra.apps.domains',
|
||||||
'orchestra.apps.systemusers',
|
'orchestra.apps.systemusers',
|
||||||
# 'orchestra.apps.users',
|
|
||||||
# 'orchestra.apps.users.roles.mail',
|
|
||||||
# 'orchestra.apps.users.roles.jabber',
|
|
||||||
# 'orchestra.apps.users.roles.posix',
|
|
||||||
'orchestra.apps.mailboxes',
|
'orchestra.apps.mailboxes',
|
||||||
'orchestra.apps.lists',
|
'orchestra.apps.lists',
|
||||||
'orchestra.apps.webapps',
|
'orchestra.apps.webapps',
|
||||||
|
@ -85,6 +81,7 @@ INSTALLED_APPS = (
|
||||||
'orchestra.apps.saas',
|
'orchestra.apps.saas',
|
||||||
'orchestra.apps.issues',
|
'orchestra.apps.issues',
|
||||||
'orchestra.apps.services',
|
'orchestra.apps.services',
|
||||||
|
'orchestra.apps.plans',
|
||||||
'orchestra.apps.orders',
|
'orchestra.apps.orders',
|
||||||
'orchestra.apps.miscellaneous',
|
'orchestra.apps.miscellaneous',
|
||||||
'orchestra.apps.bills',
|
'orchestra.apps.bills',
|
||||||
|
@ -149,7 +146,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
|
||||||
'orchestra.apps.accounts.models.Account',
|
'orchestra.apps.accounts.models.Account',
|
||||||
'orchestra.apps.contacts.models.Contact',
|
'orchestra.apps.contacts.models.Contact',
|
||||||
'orchestra.apps.orders.models.Order',
|
'orchestra.apps.orders.models.Order',
|
||||||
'orchestra.apps.services.models.ContractedPlan',
|
'orchestra.apps.plans.models.ContractedPlan',
|
||||||
'orchestra.apps.bills.models.Bill',
|
'orchestra.apps.bills.models.Bill',
|
||||||
# 'orchestra.apps.payments.models.PaymentSource',
|
# 'orchestra.apps.payments.models.PaymentSource',
|
||||||
'orchestra.apps.payments.models.Transaction',
|
'orchestra.apps.payments.models.Transaction',
|
||||||
|
@ -167,7 +164,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
|
||||||
'orchestra.apps.resources.models.Resource',
|
'orchestra.apps.resources.models.Resource',
|
||||||
'orchestra.apps.resources.models.Monitor',
|
'orchestra.apps.resources.models.Monitor',
|
||||||
'orchestra.apps.services.models.Service',
|
'orchestra.apps.services.models.Service',
|
||||||
'orchestra.apps.services.models.Plan',
|
'orchestra.apps.plans.models.Plan',
|
||||||
'orchestra.apps.miscellaneous.models.MiscService',
|
'orchestra.apps.miscellaneous.models.MiscService',
|
||||||
),
|
),
|
||||||
'collapsible': True,
|
'collapsible': True,
|
||||||
|
@ -195,7 +192,7 @@ FLUENT_DASHBOARD_APP_ICONS = {
|
||||||
'accounts/account': 'Face-monkey.png',
|
'accounts/account': 'Face-monkey.png',
|
||||||
'contacts/contact': 'contact_book.png',
|
'contacts/contact': 'contact_book.png',
|
||||||
'orders/order': 'basket.png',
|
'orders/order': 'basket.png',
|
||||||
'services/contractedplan': 'ContractedPack.png',
|
'plans/contractedplan': 'ContractedPack.png',
|
||||||
'services/service': 'price.png',
|
'services/service': 'price.png',
|
||||||
'bills/bill': 'invoice.png',
|
'bills/bill': 'invoice.png',
|
||||||
'payments/paymentsource': 'card_in_use.png',
|
'payments/paymentsource': 'card_in_use.png',
|
||||||
|
@ -210,7 +207,7 @@ FLUENT_DASHBOARD_APP_ICONS = {
|
||||||
'orchestration/backendlog': 'scriptlog.png',
|
'orchestration/backendlog': 'scriptlog.png',
|
||||||
'resources/resource': "gauge.png",
|
'resources/resource': "gauge.png",
|
||||||
'resources/monitor': "Utilities-system-monitor.png",
|
'resources/monitor': "Utilities-system-monitor.png",
|
||||||
'services/plan': 'Pack.png',
|
'plans/plan': 'Pack.png',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Django-celery
|
# Django-celery
|
||||||
|
|
|
@ -18,7 +18,7 @@ class Register(object):
|
||||||
self._registry[model] = AttrDict(**{
|
self._registry[model] = AttrDict(**{
|
||||||
'verbose_name': kwargs.get('verbose_name', model._meta.verbose_name),
|
'verbose_name': kwargs.get('verbose_name', model._meta.verbose_name),
|
||||||
'verbose_name_plural': plural,
|
'verbose_name_plural': plural,
|
||||||
'menu': kwargs.get('menu', True)
|
'menu': kwargs.get('menu', True),
|
||||||
})
|
})
|
||||||
|
|
||||||
def get(self, *args):
|
def get(self, *args):
|
||||||
|
|
Loading…
Reference in New Issue