Random improvements

This commit is contained in:
Marc 2014-10-23 15:38:46 +00:00
parent 5619141514
commit 9534e6e571
44 changed files with 332 additions and 122 deletions

20
TODO.md
View File

@ -12,7 +12,6 @@ TODO
* enforce an emergency email contact and account to contact contacts about problems when mailserver is down
* add `BackendLog` retry action
* move invoice contact to invoices app?
* PHPbBckendMiixin with get_php_ini
* Apache: `IncludeOptional /etc/apache2/extra-vhos[t]/account-site-custom.con[f]`
* webmail identities and addresses
@ -143,7 +142,7 @@ Remember that, as always with QuerySets, any subsequent chained methods which im
* based on a merge set of save(update_fields)
textwrap.dedent( \\)
* textwrap.dedent( \\)
* accounts
* short name / long name, account name really needed? address? only minimal info..
@ -159,7 +158,22 @@ textwrap.dedent( \\)
* better modeling of the interdependency between webapps and websites (settings)
* webapp options cfig agnostic
* Disable menu on tests, fucking overlapping
* service.name / verbose_name instead of .description ?
* miscellaneous.name / verbose_name
* service.invoice_name
* Bills can have sublines?
* proforma without billing contact?
* remove contact addresss, and use invoice contact for it (maybe move to contacts app again)
* env ORCHESTRA_MASTER_SERVER='test1.orchestra.lan' ORCHESTRA_SECOND_SERVER='test2.orchestra.lan' ORCHESTRA_SLAVE_SERVER='test3.orchestra.lan' python manage.py test orchestra.apps.domains.tests.functional_tests.tests:AdminBind9BackendDomainTest
* Pangea modifications: domain registered/non-registered list_display and field with register link: inconsistent, what happen to related objects with a domain that is converted to register-only?
* ForeignKey.swappable
* Field.editable
* ManyToManyField.symmetrical = False (user group)
* REST PERMISSIONS

View File

@ -63,6 +63,7 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
change_form_template = 'admin/accounts/account/change_form.html'
actions = [disable]
change_view_actions = actions
list_select_related = ('billcontact',)
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
@ -99,12 +100,6 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
fieldsets.insert(1, (_("Related services"), {'fields': fields}))
return fieldsets
def get_queryset(self, request):
""" Select related for performance """
qs = super(AccountAdmin, self).get_queryset(request)
related = ('invoicecontact',)
return qs.select_related(*related)
def save_model(self, request, obj, form, change):
super(AccountAdmin, self).save_model(request, obj, form, change)
if not change:
@ -152,6 +147,7 @@ class AccountAdminMixin(object):
change_list_template = 'admin/accounts/account/change_list.html'
change_form_template = 'admin/accounts/account/change_form.html'
account = None
list_select_related = ('account',)
def account_link(self, instance):
account = instance.account if instance.pk else self.account
@ -167,11 +163,6 @@ class AccountAdminMixin(object):
self.account = obj.account
return super(AccountAdminMixin, self).get_readonly_fields(request, obj=obj)
def get_queryset(self, request):
""" Select related for performance """
qs = super(AccountAdminMixin, self).get_queryset(request)
return qs.select_related('account')
def formfield_for_dbfield(self, db_field, **kwargs):
""" Filter by account """
formfield = super(AccountAdminMixin, self).formfield_for_dbfield(db_field, **kwargs)

View File

@ -1,4 +1,5 @@
from rest_framework import viewsets
from django.utils.translation import ugettext_lazy as _
from rest_framework import viewsets, exceptions
from orchestra.api import router, SetPasswordApiMixin
@ -20,6 +21,12 @@ class AccountViewSet(SetPasswordApiMixin, viewsets.ModelViewSet):
def get_queryset(self):
qs = super(AccountViewSet, self).get_queryset()
return qs.filter(id=self.request.user.pk)
def destroy(self, request, pk=None):
# TODO reimplement in permissions
if not request.user.is_superuser:
raise exceptions.PermissionDenied(_("Accounts can not be deleted."))
super(AccountViewSet, self).destroy(request, pk=pk)
router.register(r'accounts', AccountViewSet)

View File

@ -9,7 +9,12 @@ from .models import Account
def create_account_creation_form():
fields = {}
fields = {
'create_systemuser': forms.BooleanField(initial=True, required=False,
label=_("Create systemuser"), widget=forms.CheckboxInput(attrs={'disabled': True}),
help_text=_("Designates whether to creates a related system user with the same "
"username and password or not."))
}
for model, key, kwargs, help_text in settings.ACCOUNTS_CREATE_RELATED:
model = get_model(model)
field_name = 'create_%s' % model._meta.model_name
@ -28,6 +33,9 @@ def create_account_creation_form():
except KeyError:
# Previous validation error
return
systemuser_model = Account.main_systemuser.field.rel.to
if systemuser_model.objects.filter(username=account.username).exists():
raise forms.ValidationError(_("A system user with this name already exists"))
for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
model = get_model(model)
kwargs = {
@ -44,9 +52,10 @@ def create_account_creation_form():
model = get_model(model)
field_name = 'create_%s' % model._meta.model_name
if self.cleaned_data[field_name]:
for key, value in related_kwargs.iteritems():
related_kwargs[key] = eval(value, {'account': account})
model.objects.create(account=account, **related_kwargs)
kwargs = {
key: eval(value, {'account': account}) for key, value in related_kwargs.iteritems()
}
model.objects.create(account=account, **kwargs)
fields.update({
'create_related_fields': fields.keys(),

View File

@ -17,6 +17,8 @@ class Account(auth.AbstractBaseUser):
help_text=_("Required. 30 characters or fewer. Letters, digits and ./-/_ only."),
validators=[validators.RegexValidator(r'^[\w.-]+$',
_("Enter a valid username."), 'invalid')])
main_systemuser = models.ForeignKey(settings.ACCOUNTS_SYSTEMUSER_MODEL, null=True,
related_name='accounts_main')
first_name = models.CharField(_("first name"), max_length=30, blank=True)
last_name = models.CharField(_("last name"), max_length=30, blank=True)
email = models.EmailField(_('email address'), help_text=_("Used for password recovery"))
@ -50,14 +52,22 @@ class Account(auth.AbstractBaseUser):
def is_staff(self):
return self.is_superuser
@property
def main_systemuser(self):
return self.systemusers.get(is_main=True)
# @property
# def main_systemuser(self):
# return self.systemusers.get(is_main=True)
@classmethod
def get_main(cls):
return cls.objects.get(pk=settings.ACCOUNTS_MAIN_PK)
def save(self, *args, **kwargs):
created = not self.pk
super(Account, self).save(*args, **kwargs)
if created:
self.main_systemuser = self.systemusers.create(account=self, username=self.username,
password=self.password)
self.save(update_fields=['main_systemuser'])
def clean(self):
self.first_name = self.first_name.strip()
self.last_name = self.last_name.strip()
@ -124,15 +134,17 @@ class Account(auth.AbstractBaseUser):
if self.is_active and self.is_superuser:
return True
return auth._user_has_module_perms(self, app_label)
def get_related_passwords(self):
related = []
for model, key, kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
if 'password' not in kwargs:
related = [
self.main_systemuser,
]
for model, key, related_kwargs, __ in settings.ACCOUNTS_CREATE_RELATED:
if 'password' not in related_kwargs:
continue
model = get_model(model)
kwargs = {
key: eval(kwargs[key], {'account': self})
key: eval(related_kwargs[key], {'account': self})
}
try:
rel = model.objects.get(account=self, **kwargs)

View File

@ -18,6 +18,10 @@ ACCOUNTS_LANGUAGES = getattr(settings, 'ACCOUNTS_LANGUAGES', (
))
ACCOUNTS_SYSTEMUSER_MODEL = getattr(settings, 'ACCOUNTS_SYSTEMUSER_MODEL',
'systemusers.SystemUser')
ACCOUNTS_DEFAULT_LANGUAGE = getattr(settings, 'ACCOUNTS_DEFAULT_LANGUAGE', 'en')
@ -26,15 +30,6 @@ ACCOUNTS_MAIN_PK = getattr(settings, 'ACCOUNTS_MAIN_PK', 1)
ACCOUNTS_CREATE_RELATED = getattr(settings, 'ACCOUNTS_CREATE_RELATED', (
# <model>, <key field>, <kwargs>, <help_text>
('systemusers.SystemUser',
'username',
{
'username': 'account.username',
'password': 'account.password',
'is_main': 'True',
},
_("Designates whether to creates a related system user with the same username and password or not."),
),
('mailboxes.Mailbox',
'name',
{

View File

@ -36,7 +36,7 @@ class BillLineInline(admin.TabularInline):
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, str(total), img)
return '<span title="%s">%s <img src="%s"></img></span>' % (content, str(total), img)
return total
display_total.short_description = _("Total")
display_total.allow_tags = True

View File

@ -3,7 +3,7 @@ from django.contrib import admin
from django.utils.translation import ugettext, ugettext_lazy as _
from orchestra.admin import AtLeastOneRequiredInlineFormSet
from orchestra.admin.utils import insertattr
from orchestra.admin.utils import insertattr, admin_link, change_url
from orchestra.apps.accounts.admin import AccountAdmin, AccountAdminMixin
from orchestra.forms.widgets import paddingCheckboxSelectMultiple
@ -74,15 +74,20 @@ class ContactInline(admin.StackedInline):
formset = AtLeastOneRequiredInlineFormSet
extra = 0
fields = (
'short_name', 'full_name', 'email', 'email_usage', ('phone', 'phone2'),
'address', ('city', 'zipcode'), 'country',
('short_name', 'full_name'), 'email', 'email_usage', ('phone', 'phone2'),
)
def get_extra(self, request, obj=None, **kwargs):
return 0 if obj and obj.contacts.exists() else 1
def get_view_on_site_url(self, obj=None):
if obj:
return change_url(obj)
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'short_name':
kwargs['widget'] = forms.TextInput(attrs={'size':'15'})
if db_field.name == 'address':
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 2})
if db_field.name == 'email_usage':

View File

@ -296,10 +296,13 @@ class AdminDatabaseMixin(DatabaseTestMixin):
self.selenium.get(url)
user = DatabaseUser.objects.get(username=username, type=self.db_type)
users_input = self.selenium.find_element_by_id('id_users')
users_select = Select(users_input)
users_from = self.selenium.find_element_by_id('id_users_from')
users_select = Select(users_from)
users_select.select_by_value(str(user.pk))
add_user = self.selenium.find_element_by_id('id_users_add_link')
add_user.click()
save = self.selenium.find_element_by_name('_save')
save.submit()
self.assertNotEqual(url, self.selenium.current_url)
@ -310,13 +313,23 @@ class AdminDatabaseMixin(DatabaseTestMixin):
url = self.live_server_url + change_url(database)
self.selenium.get(url)
# remove user "username"
user = DatabaseUser.objects.get(username=username, type=self.db_type)
users_input = self.selenium.find_element_by_id('id_users')
users_select = Select(users_input)
users_select.deselect_by_value(str(user.pk))
user = DatabaseUser.objects.get(username=username2, type=self.db_type)
users_to = self.selenium.find_element_by_id('id_users_to')
users_select = Select(users_to)
users_select.select_by_value(str(user.pk))
remove_user = self.selenium.find_element_by_id('id_users_remove_link')
remove_user.click()
time.sleep(0.2)
# add user "username2"
user = DatabaseUser.objects.get(username=username2, type=self.db_type)
users_from = self.selenium.find_element_by_id('id_users_from')
users_select = Select(users_from)
users_select.select_by_value(str(user.pk))
add_user = self.selenium.find_element_by_id('id_users_add_link')
add_user.click()
time.sleep(0.2)
save = self.selenium.find_element_by_name('_save')
save.submit()

View File

@ -20,19 +20,22 @@ class RecordInline(admin.TabularInline):
formset = RecordInlineFormSet
verbose_name_plural = _("Extra records")
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
# class Media:
# css = {
# 'all': ('orchestra/css/hide-inline-id.css',)
# }
#
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """
if db_field.name == 'value':
kwargs['widget'] = forms.TextInput(attrs={'size':'100'})
if db_field.name == 'ttl':
kwargs['widget'] = forms.TextInput(attrs={'size':'10'})
return super(RecordInline, self).formfield_for_dbfield(db_field, **kwargs)
class DomainInline(admin.TabularInline):
# TODO account, and record sumary fields
model = Domain
fields = ('domain_link',)
readonly_fields = ('domain_link',)
@ -47,6 +50,7 @@ class DomainInline(admin.TabularInline):
class DomainAdmin(ChangeListDefaultFilter, AccountAdminMixin, ExtendedModelAdmin):
# TODO name link
fields = ('name', 'account')
list_display = (
'structured_name', 'display_is_top', 'websites', 'account_link'

View File

@ -91,7 +91,7 @@ class Bind9MasterDomainBackend(ServiceController):
'zone_path': settings.DOMAINS_ZONE_PATH % {'name': domain.name},
'subdomains': domain.subdomains.all(),
'banner': self.get_banner(),
'slaves': '; '.join(self.get_slaves(domain)) or '"none"',
'slaves': '; '.join(self.get_slaves(domain)) or '',
}
context.update({
'conf_path': settings.DOMAINS_MASTERS_PATH,
@ -133,7 +133,7 @@ class Bind9SlaveDomainBackend(Bind9MasterDomainBackend):
'name': domain.name,
'banner': self.get_banner(),
'subdomains': domain.subdomains.all(),
'masters': '; '.join(self.get_masters(domain)) or '"none"',
'masters': '; '.join(self.get_masters(domain)) or '',
}
context.update({
'conf_path': settings.DOMAINS_SLAVES_PATH,

View File

@ -42,6 +42,9 @@ class Domain(models.Model):
# don't cache, don't replace by top_id
return not bool(self.top)
def get_absolute_url(self):
return 'http://%s' % self.name
def get_records(self):
""" proxy method, needed for input validation, see helpers.domain_for_validation """
return self.records.all()

View File

@ -180,6 +180,7 @@ class TicketAdmin(ChangeListDefaultFilter, ExtendedModelAdmin):
'description')
}),
)
list_select_related = ('queue', 'owner', 'creator')
class Media:
css = {
@ -285,11 +286,6 @@ class TicketAdmin(ChangeListDefaultFilter, ExtendedModelAdmin):
data = request.POST.get("data")
data_formated = markdown(strip_tags(data))
return HttpResponse(data_formated)
def get_queryset(self, request):
""" Order by structured name and imporve performance """
qs = super(TicketAdmin, self).get_queryset(request)
return qs.select_related('queue', 'owner', 'creator')
class QueueAdmin(admin.ModelAdmin):

View File

@ -43,6 +43,12 @@ class List(models.Model):
def set_password(self, password):
self.password = password
def get_absolute_url(self):
context = {
'name': self.name
}
return settings.LISTS_LIST_URL % context
services.register(List)

View File

@ -7,10 +7,11 @@ LISTS_DOMAIN_MODEL = getattr(settings, 'LISTS_DOMAIN_MODEL', 'domains.Domain')
LISTS_DEFAULT_DOMAIN = getattr(settings, 'LIST_DEFAULT_DOMAIN', 'lists.orchestra.lan')
LISTS_LIST_URL = getattr(settings, 'LISTS_LIST_URL', 'https://lists.orchestra.lan/mailman/listinfo/%(name)s')
LISTS_MAILMAN_POST_LOG_PATH = getattr(settings, 'LISTS_MAILMAN_POST_LOG_PATH',
'/var/log/mailman/post')
LISTS_MAILMAN_ROOT_PATH = getattr(settings, 'LISTS_MAILMAN_ROOT_PATH',
'/var/lib/mailman/')

View File

@ -70,11 +70,15 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
display_addresses.allow_tags = True
def get_fieldsets(self, request, obj=None):
""" not collapsed filtering when exists """
fieldsets = super(MailboxAdmin, self).get_fieldsets(request, obj=obj)
if obj and obj.filtering == obj.CUSTOM:
# not collapsed filtering when exists
fieldsets = copy.deepcopy(fieldsets)
fieldsets[1][1]['classes'] = fieldsets[0][1]['fields'] + ('open',)
elif '_to_field' in parse_qs(request.META['QUERY_STRING']):
# remove address from popup
fieldsets = list(copy.deepcopy(fieldsets))
fieldsets.pop(-1)
return fieldsets
def get_form(self, *args, **kwargs):

View File

@ -14,7 +14,6 @@ from django.core.management.base import CommandError
from django.core.urlresolvers import reverse
from selenium.webdriver.support.select import Select
from orchestra.apps.accounts.models import Account
from orchestra.apps.orchestration.models import Server, Route
from orchestra.apps.resources.models import Resource
from orchestra.utils.system import run, sshrun
@ -303,9 +302,9 @@ class AdminMailboxMixin(MailboxMixin):
url = self.live_server_url + reverse('admin:mailboxes_mailbox_add')
self.selenium.get(url)
account_input = self.selenium.find_element_by_id('id_account')
account_select = Select(account_input)
account_select.select_by_value(str(self.account.pk))
# account_input = self.selenium.find_element_by_id('id_account')
# account_select = Select(account_input)
# account_select.select_by_value(str(self.account.pk))
name_field = self.selenium.find_element_by_id('id_name')
name_field.send_keys(username)

View File

@ -10,7 +10,7 @@ from .models import MiscService, Miscellaneous
class MiscServiceAdmin(admin.ModelAdmin):
list_display = ('name', 'num_instances')
list_display = ('name', 'verbose_name', 'num_instances')
def num_instances(self, misc):
""" return num slivers as a link to slivers changelist view """

View File

@ -3,11 +3,16 @@ from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services
from orchestra.core.validators import validate_name
class MiscService(models.Model):
name = models.CharField(_("name"), max_length=256)
description = models.TextField(_("description"), blank=True)
name = models.CharField(_("name"), max_length=32, unique=True, validators=[validate_name],
help_text=_("Raw name used for internal referenciation, i.e. service match definition"))
verbose_name = models.CharField(_("verbose name"), max_length=256, blank=True,
help_text=_("Human readable name"))
description = models.TextField(_("description"), blank=True,
help_text=_("Optional description"))
has_amount = models.BooleanField(_("has amount"), default=False,
help_text=_("Designates whether this service has <tt>amount</tt> "
"property or not."))
@ -17,6 +22,12 @@ class MiscService(models.Model):
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 Miscellaneous(models.Model):

View File

@ -181,7 +181,7 @@ class Order(models.Model):
if metric is not None:
MetricStorage.store(self, metric)
metric = ', metric:{}'.format(metric)
description = "{}: {}".format(handler.description, str(instance))
description = handler.get_order_description(instance)
logger.info("UPDATED order id:{id}, description:{description}{metric}".format(
id=self.id, description=description, metric=metric))
if self.description != description:

View File

@ -31,8 +31,18 @@ def process_transactions(modeladmin, request, queryset):
if not processes:
return
opts = modeladmin.model._meta
num = len(queryset)
context = {
'title': _("Huston, be advised"),
'title': ungettext(
_("Selected transaction has been processed."),
_("%s Selected transactions have been processed.") % num,
num),
'content_message': ungettext(
_("The following transaction process has been generated, "
"you may want to save it on your computer now."),
_("The following %s transaction processes have been generated, "
"you may want to save it on your computer now.") % len(processes),
len(processes)),
'action_name': _("Process"),
'processes': processes,
'opts': opts,

View File

@ -92,6 +92,7 @@ class TransactionAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
filter_by_account_fields = ('bill', 'source')
change_readonly_fields = ('amount', 'currency')
readonly_fields = ('bill_link', 'display_state', 'process_link', 'account_link', 'source_link')
list_select_related = ('account', 'source', 'bill__account')
bill_link = admin_link('bill')
source_link = admin_link('source')
@ -99,10 +100,6 @@ class TransactionAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
account_link = admin_link('bill__account')
display_state = admin_colored('state', colors=STATE_COLORS)
def get_queryset(self, request):
qs = super(TransactionAdmin, self).get_queryset(request)
return qs.select_related('source', 'bill__account')
def get_change_view_actions(self, obj=None):
actions = super(TransactionAdmin, self).get_change_view_actions()
exclude = []

View File

@ -1,8 +1,9 @@
{% extends "admin/orchestra/generic_confirmation.html" %}
{% load i18n admin_urls utils %}
{% block content %}
<p>The following transaction processes have been generated, you may want to save them on your computer now.</p>
<p>{{ content_message }}</p>
<ul>
{% for proc in processes %}
<li> <a href="{{ proc.id }}">Process #{{ proc.id }}</a>

View File

@ -1,6 +1,7 @@
from django.contrib import admin, messages
from django.contrib.contenttypes import generic
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
@ -14,6 +15,8 @@ from .models import Resource, ResourceData, MonitorData
class ResourceAdmin(ExtendedModelAdmin):
# TODO error after saving: u"Key 'name' not found in 'ResourceForm'"
# prepopulated_fields = {'name': ('verbose_name',)}
list_display = (
'id', 'verbose_name', 'content_type', 'period', 'on_demand',
'default_allocation', 'unit', 'disable_trigger', 'crontab',
@ -21,11 +24,11 @@ class ResourceAdmin(ExtendedModelAdmin):
list_filter = (UsedContentTypeFilter, 'period', 'on_demand', 'disable_trigger')
fieldsets = (
(None, {
'fields': ('name', 'content_type', 'period'),
'fields': ('verbose_name', 'name', 'content_type', 'period'),
}),
(_("Configuration"), {
'fields': ('verbose_name', 'unit', 'scale', 'on_demand',
'default_allocation', 'disable_trigger', 'is_active'),
'fields': ('unit', 'scale', 'on_demand', 'default_allocation', 'disable_trigger',
'is_active'),
}),
(_("Monitoring"), {
'fields': ('monitors', 'crontab'),
@ -36,10 +39,10 @@ class ResourceAdmin(ExtendedModelAdmin):
def add_view(self, request, **kwargs):
""" Warning user if the node is not fully configured """
if request.method == 'POST':
messages.warning(request, _(
"Restarting orchestra and celerybeat is required to fully apply changes. "
messages.warning(request, mark_safe(_(
"Restarting orchestra and celerybeat is required to fully apply changes.<br> "
"Remember that new allocated values will be applied when objects are saved."
))
)))
return super(ResourceAdmin, self).add_view(request, **kwargs)
def save_model(self, request, obj, form, change):

View File

@ -8,9 +8,12 @@ from djcelery.models import PeriodicTask, CrontabSchedule
from orchestra.core import validators
from orchestra.models import queryset, fields
from orchestra.utils.paths import get_project_root
from orchestra.utils.system import run
from . import helpers
from .backends import ServiceMonitor
from .validators import validate_scale
class ResourceQuerySet(models.QuerySet):
@ -34,16 +37,15 @@ class Resource(models.Model):
_related = set() # keeps track of related models for resource cleanup
name = models.CharField(_("name"), max_length=32,
help_text=_('Required. 32 characters or fewer. Lowercase letters, '
'digits and hyphen only.'),
help_text=_("Required. 32 characters or fewer. Lowercase letters, "
"digits and hyphen only."),
validators=[validators.validate_name])
verbose_name = models.CharField(_("verbose name"), max_length=256)
content_type = models.ForeignKey(ContentType,
help_text=_("Model where this resource will be hooked."))
period = models.CharField(_("period"), max_length=16, choices=PERIODS,
default=LAST,
help_text=_("Operation used for aggregating this resource monitored"
"data."))
help_text=_("Operation used for aggregating this resource monitored data."))
on_demand = models.BooleanField(_("on demand"), default=False,
help_text=_("If enabled the resource will not be pre-allocated, "
"but allocated under the application demand"))
@ -53,8 +55,8 @@ class Resource(models.Model):
"on demand resource"))
unit = models.CharField(_("unit"), max_length=16,
help_text=_("The unit in which this resource is measured. "
"For example GB, KB or subscribers"))
scale = models.PositiveIntegerField(_("scale"),
"For example GB, KB or subscribers"))
scale = models.CharField(_("scale"), max_length=32, validators=[validate_scale],
help_text=_("Scale in which this resource monitoring resoults should "
"be prorcessed to match with unit. e.g. <tt>10**9</tt>"))
disable_trigger = models.BooleanField(_("disable trigger"), default=False,
@ -79,6 +81,9 @@ class Resource(models.Model):
def __unicode__(self):
return "{}-{}".format(str(self.content_type), self.name)
def clean(self):
self.verbose_name = self.verbose_name.strip()
def save(self, *args, **kwargs):
created = not self.pk
super(Resource, self).save(*args, **kwargs)
@ -102,7 +107,7 @@ class Resource(models.Model):
task.save(update_fields=['crontab'])
# This only work on tests (multiprocessing used on real deployments)
apps.get_app_config('resources').reload_relations()
# TODO touch wsgi.py for code reloading?
run('touch %s/wsgi.py' % get_project_root())
def delete(self, *args, **kwargs):
super(Resource, self).delete(*args, **kwargs)

View File

@ -0,0 +1,8 @@
from django.core.validators import ValidationError
def validate_scale(value):
try:
int(eval(value))
except ValueError:
raise ValidationError(_("%s value is not a valid scale expression"))

View File

@ -1,7 +1,7 @@
from django.contrib.admin import helpers
from django.core.urlresolvers import reverse
from django.db import transaction
from django.shortcuts import render
from django.shortcuts import render, redirect
from django.template.response import TemplateResponse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
@ -62,3 +62,21 @@ def view_help(modeladmin, request, queryset):
return TemplateResponse(request, 'admin/services/service/help.html', context)
view_help.url_name = 'help'
view_help.verbose_name = _("Help")
def clone(modeladmin, request, queryset):
service = queryset.get()
fields = (
'content_type_id', 'match', 'handler_type', 'is_active', 'ignore_superusers', 'billing_period',
'billing_point', 'is_fee', 'metric', 'nominal_price', 'tax', 'pricing_period',
'rate_algorithm', 'on_cancel', 'payment_style',
)
query = []
for field in fields:
value = getattr(service, field)
field = field.replace('_id', '')
query.append('%s=%s' % (field, value))
opts = service._meta
url = reverse('admin:%s_%s_add' % (opts.app_label, opts.model_name))
url += '?%s' % '&'.join(query)
return redirect(url)

View File

@ -9,7 +9,7 @@ from orchestra.admin.filters import UsedContentTypeFilter
from orchestra.apps.accounts.admin import AccountAdminMixin
from orchestra.core import services
from .actions import update_orders, view_help
from .actions import update_orders, view_help, clone
from .models import Plan, ContractedPlan, Rate, Service
@ -42,7 +42,7 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
}),
(_("Billing options"), {
'classes': ('wide',),
'fields': ('billing_period', 'billing_point', 'is_fee')
'fields': ('billing_period', 'billing_point', 'is_fee', 'order_description')
}),
(_("Pricing options"), {
'classes': ('wide',),
@ -51,7 +51,7 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
}),
)
inlines = [RateInline]
actions = [update_orders]
actions = [update_orders, clone]
change_view_actions = actions + [view_help]
def formfield_for_dbfield(self, db_field, **kwargs):
@ -60,7 +60,7 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
models = [model._meta.model_name for model in services.get()]
queryset = db_field.rel.to.objects
kwargs['queryset'] = queryset.filter(model__in=models)
if db_field.name in ['match', 'metric']:
if db_field.name in ['match', 'metric', 'order_description']:
kwargs['widget'] = forms.TextInput(attrs={'size':'160'})
return super(ServiceAdmin, self).formfield_for_dbfield(db_field, **kwargs)

View File

@ -62,6 +62,16 @@ class ServiceHandler(plugins.Plugin):
}
return eval(self.metric, safe_locals)
def get_order_description(self, instance):
safe_locals = {
'instance': instance,
'obj': instance,
instance._meta.model_name: instance,
}
if not self.order_description:
return '%s: %s' % (self.description, instance)
return eval(self.order_description, safe_locals)
def get_billing_point(self, order, bp=None, **options):
not_cachable = self.billing_point == self.FIXED_DATE and options.get('fixed_point')
if not_cachable or bp is None:

View File

@ -10,6 +10,7 @@ from django.utils.module_loading import autodiscover_modules
from django.utils.translation import ugettext_lazy as _
from orchestra.core import caches, services, accounts
from orchestra.core.validators import validate_name
from orchestra.models import queryset
from . import settings, rating
@ -17,7 +18,8 @@ from .handlers import ServiceHandler
class Plan(models.Model):
name = models.CharField(_("plan"), max_length=128)
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,
@ -29,7 +31,10 @@ class Plan(models.Model):
return self.name
def clean(self):
self.name = self.name.strip()
self.verbose_name = self.verbose_name.strip()
def get_verbose_name(self):
return self.verbose_name or self.name
class ContractedPlan(models.Model):
@ -147,6 +152,12 @@ class Service(models.Model):
is_fee = models.BooleanField(_("fee"), default=False,
help_text=_("Designates whether this service should be billed as "
" membership fee or not"))
order_description = models.CharField(_("Order description"), max_length=128, blank=True,
help_text=_(
"Python <a href='https://docs.python.org/2/library/functions.html#eval'>expression</a> "
"used for generating the description for the bill lines of this services.<br>"
"Defaults to <tt>'%s: %s' % (handler.description, instance)</tt>"
))
# Pricing
metric = models.CharField(_("metric"), max_length=256, blank=True,
help_text=_(

View File

@ -34,8 +34,7 @@ class JobBillingTest(BaseBillingTest):
if not account:
account = self.create_account()
description = 'Random Job %s' % random_ascii(10)
service, __ = MiscService.objects.get_or_create(name='job', description=description,
has_amount=True)
service, __ = MiscService.objects.get_or_create(name='job', has_amount=True)
return account.miscellaneous.create(service=service, description=description, amount=amount)
def test_job(self):

View File

@ -43,7 +43,7 @@ class BaseTrafficBillingTest(BaseBillingTest):
period=Resource.MONTHLY_SUM,
verbose_name='Account Traffic',
unit='GB',
scale=10**9,
scale='10**9',
on_demand=True,
monitors='FTPTraffic',
)

View File

@ -4,18 +4,20 @@ from django.contrib import admin
from django.contrib.admin.util import unquote
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import ugettext, ugettext_lazy as _
from django.utils.safestring import mark_safe
from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
from orchestra.admin.utils import wrap_admin_view
from orchestra.apps.accounts.admin import SelectAccountAdminMixin
from orchestra.forms import UserCreationForm, UserChangeForm
from .filters import IsMainListFilter
from .models import SystemUser
class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin):
list_display = ('username', 'account_link', 'shell', 'home', 'is_active', 'is_main')
list_filter = ('is_active', 'is_main', 'shell')
list_display = ('username', 'account_link', 'shell', 'home', 'display_active', 'display_main')
list_filter = ('is_active', 'shell', IsMainListFilter)
fieldsets = (
(None, {
'fields': ('username', 'password', 'account_link', 'is_active')
@ -26,7 +28,7 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende
)
add_fieldsets = (
(None, {
'fields': ('username', 'password1', 'password2', 'account')
'fields': ('account_link', 'username', 'password1', 'password2')
}),
(_("System"), {
'fields': ('home', 'shell', 'groups'),
@ -41,6 +43,17 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende
form = UserChangeForm
ordering = ('-id',)
def display_active(self, user):
return user.active
display_active.short_description = _("Active")
display_active.admin_order_field = 'is_active'
display_active.boolean = True
def display_main(self, user):
return user.is_main
display_main.short_description = _("Main")
display_main.boolean = True
def get_form(self, request, obj=None, **kwargs):
""" exclude self reference on groups """
form = super(SystemUserAdmin, self).get_form(request, obj=obj, **kwargs)
@ -51,6 +64,10 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende
formfield = form.base_fields['groups']
formfield.queryset = formfield.queryset.exclude(id=obj.id)
return form
def has_delete_permission(self, request, obj=None):
if obj and obj.is_main:
return False
return super(SystemUserAdmin, self).has_delete_permission(request, obj=obj)
admin.site.register(SystemUser, SystemUserAdmin)

View File

@ -1,4 +1,5 @@
from rest_framework import viewsets
from django.utils.translation import ugettext_lazy as _
from rest_framework import viewsets, exceptions
from orchestra.api import router, SetPasswordApiMixin
from orchestra.apps.accounts.api import AccountApiMixin
@ -11,6 +12,12 @@ class SystemUserViewSet(AccountApiMixin, SetPasswordApiMixin, viewsets.ModelView
model = SystemUser
serializer_class = SystemUserSerializer
filter_fields = ('username',)
def destroy(self, request, pk=None):
user = self.get_object()
if user.is_main:
raise exceptions.PermissionDenied(_("Main system user can not be deleted."))
super(SystemUserViewSet, self).destroy(request, pk=pk)
router.register(r'systemusers', SystemUserViewSet)

View File

@ -0,0 +1,23 @@
from django.contrib.admin import SimpleListFilter
from django.db.models import F
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _
class IsMainListFilter(SimpleListFilter):
""" Filter Nodes by group according to request.user """
title = _("main")
parameter_name = 'is_main'
def lookups(self, request, model_admin):
return (
('True', _("Yes")),
('False', _("No")),
)
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.filter(account__main_systemuser_id=F('id'))
if self.value() == 'False':
return queryset.exclude(account__main_systemuser_id=F('id'))

View File

@ -36,7 +36,7 @@ class SystemUser(models.Model):
groups = models.ManyToManyField('self', blank=True,
help_text=_("A new group will be created for the user. "
"Which additional groups would you like them to be a member of?"))
is_main = models.BooleanField(_("is main"), default=False)
# is_main = models.BooleanField(_("is main"), default=False)
is_active = models.BooleanField(_("active"), default=True,
help_text=_("Designates whether this account should be treated as active. "
"Unselect this instead of deleting accounts."))
@ -53,6 +53,13 @@ class SystemUser(models.Model):
except type(self).account.field.rel.to.DoesNotExist:
return self.is_active
@property
def is_main(self):
# On account creation main_systemuser_id is still None
if self.account.main_systemuser_id:
return self.account.main_systemuser_id == self.pk
return self.account.username == self.username
def set_password(self, raw_password):
self.password = make_password(raw_password)
@ -63,7 +70,7 @@ class SystemUser(models.Model):
}
basehome = settings.SYSTEMUSERS_HOME % context
else:
basehome = self.account.systemusers.get(is_main=True).get_home()
basehome = self.account.main_systemuser.get_home()
basehome = basehome.replace('/./', '/')
home = os.path.join(basehome, self.home)
# Chrooting

View File

@ -143,6 +143,7 @@ class SystemUserMixin(object):
self.validate_user(username)
self.delete(username)
self.validate_delete(username)
self.assertRaises(Exception, self.delete, self.account.username)
def test_add_group(self):
username = '%s_systemuser' % random_ascii(10)
@ -190,7 +191,7 @@ class RESTSystemUserMixin(SystemUserMixin):
self.rest_login()
# create main user
self.save(self.account.username)
self.addCleanup(self.delete, self.account.username)
self.addCleanup(self.delete_account, self.account.username)
@save_response_on_error
def add(self, username, password, shell='/dev/null'):
@ -230,7 +231,7 @@ class AdminSystemUserMixin(SystemUserMixin):
self.admin_login()
# create main user
self.save(self.account.username)
self.addCleanup(self.delete, self.account.username)
self.addCleanup(self.delete_account, self.account.username)
@snapshot_on_error
def add(self, username, password, shell='/dev/null'):
@ -245,10 +246,6 @@ class AdminSystemUserMixin(SystemUserMixin):
password_field = self.selenium.find_element_by_id('id_password2')
password_field.send_keys(password)
account_input = self.selenium.find_element_by_id('id_account')
account_select = Select(account_input)
account_select.select_by_value(str(self.account.pk))
shell_input = self.selenium.find_element_by_id('id_shell')
shell_select = Select(shell_input)
shell_select.select_by_value(shell)
@ -261,6 +258,10 @@ class AdminSystemUserMixin(SystemUserMixin):
user = SystemUser.objects.get(username=username)
self.admin_delete(user)
@snapshot_on_error
def delete_account(self, username):
self.admin_delete(self.account)
@snapshot_on_error
def disable(self, username):
user = SystemUser.objects.get(username=username)
@ -332,7 +333,7 @@ class AdminSystemUserTest(AdminSystemUserMixin, BaseLiveServerTestCase):
@snapshot_on_error
def test_delete_account(self):
home = self.account.systemusers.get(is_main=True).get_home()
home = self.account.main_systemuser.get_home()
delete = reverse('admin:accounts_account_delete', args=(self.account.pk,))
url = self.live_server_url + delete

View File

@ -46,8 +46,8 @@ class WebAppServiceMixin(object):
def get_context(self, webapp):
return {
'user': webapp.account.username,
'group': webapp.account.username,
'user': webapp.get_username(),
'group': webapp.get_groupname(),
'app_name': webapp.name,
'type': webapp.type,
'app_path': webapp.get_path().rstrip('/'),

View File

@ -52,6 +52,12 @@ class WebApp(models.Model):
'app_name': self.name,
}
return settings.WEBAPPS_BASE_ROOT % context
def get_username(self):
return self.account.username
def get_groupname(self):
return self.get_username()
class WebAppOption(models.Model):

View File

@ -13,10 +13,10 @@ class WebsiteOptionInline(admin.TabularInline):
model = WebsiteOption
extra = 1
class Media:
css = {
'all': ('orchestra/css/hide-inline-id.css',)
}
# class Media:
# css = {
# 'all': ('orchestra/css/hide-inline-id.css',)
# }
def formfield_for_dbfield(self, db_field, **kwargs):
""" Make value input widget bigger """

View File

@ -164,8 +164,8 @@ class Apache2Backend(ServiceController):
'site_name': site.name,
'ip': settings.WEBSITES_DEFAULT_IP,
'site_unique_name': site.unique_name,
'user': site.account.username,
'group': site.account.username,
'user': site.get_username(),
'group': site.get_groupname(),
'sites_enabled': sites_enabled,
'sites_available': "%s.conf" % os.path.join(sites_available, site.unique_name),
'logs': os.path.join(settings.WEBSITES_BASE_APACHE_LOGS, site.unique_name),

View File

@ -50,6 +50,17 @@ class Website(models.Model):
if self.port == 443:
return 'https'
raise TypeError('No protocol for port "%s"' % self.port)
def get_absolute_url(self):
domain = self.domains.first()
if domain:
return '%s://%s' % (self.protocol, domain)
def get_username(self):
return self.account.username
def get_groupname(self):
return self.get_username()
class WebsiteOption(models.Model):
@ -93,6 +104,11 @@ class Content(models.Model):
def clean(self):
if not self.path.startswith('/'):
self.path = '/' + self.path
def get_absolute_url(self):
domain = self.website.domains.first()
if domain:
return '%s://%s%s' % (self.website.protocol, domain, self.path)
services.register(Website)

View File

@ -46,7 +46,7 @@ def read_async(fd):
return ''
def run(command, display=True, error_codes=[0], silent=False, stdin=''):
def run(command, display=False, error_codes=[0], silent=False, stdin=''):
""" Subprocess wrapper for running commands """
if display:
sys.stderr.write("\n\033[1m $ %s\033[0m\n" % command)

View File

@ -69,6 +69,8 @@ class BaseTestCase(TestCase, AppDependencyMixin):
class BaseLiveServerTestCase(AppDependencyMixin, LiveServerTestCase):
@classmethod
def setUpClass(cls):
# Avoid problems with the overlaping menu when clicking
settings.ADMIN_TOOLS_MENU = 'admin_tools.menu.Menu'
cls.vdisplay = Xvfb()
cls.vdisplay.start()
cls.selenium = WebDriver()
@ -180,4 +182,3 @@ def save_response_on_error(test):
dumpfile.write(self.rest.last_response.content)
raise
return inner