diff --git a/TODO.md b/TODO.md index 92b2d7ce..b783c23c 100644 --- a/TODO.md +++ b/TODO.md @@ -462,6 +462,13 @@ mkhomedir_helper or create ssh homes with bash.rc and such # POSTFIX web traffic monitor '": uid=" from=<%(user)s>' +# Mv .deleted make sure it works with nested destinations +# Re-run backends (save regenerate, delete run same script) warning on confirmation page: DELETED objects will be deleted on the server if you have recreated them. +# Automatically re-run backends until success? only timedout executions? + + + + ### Quick start 0. Install orchestra following any of these methods: 1. [PIP-only, Fast deployment setup (demo)](README.md#fast-deployment-setup) diff --git a/orchestra/admin/decorators.py b/orchestra/admin/decorators.py index 4c812841..4c5abcae 100644 --- a/orchestra/admin/decorators.py +++ b/orchestra/admin/decorators.py @@ -1,6 +1,8 @@ from functools import wraps, partial +from django.contrib import messages from django.contrib.admin import helpers +from django.core.exceptions import ValidationError from django.template.response import TemplateResponse from django.utils.decorators import available_attrs from django.utils.encoding import force_text @@ -13,7 +15,7 @@ def admin_field(method): """ Wraps a function to be used as a ModelAdmin method field """ def admin_field_wrapper(*args, **kwargs): """ utility function for creating admin links """ - kwargs['field'] = args[0] if args else '' + kwargs['field'] = args[0] if args else '__str__' kwargs['order'] = kwargs.get('order', kwargs['field']) kwargs['popup'] = kwargs.get('popup', False) # TODO get field verbose name @@ -38,7 +40,7 @@ def format_display_objects(modeladmin, request, queryset): return objects -def action_with_confirmation(action_name=None, extra_context={}, +def action_with_confirmation(action_name=None, extra_context=None, validator=None, template='admin/orchestra/generic_confirmation.html'): """ Generic pattern for actions that needs confirmation step @@ -46,9 +48,15 @@ def action_with_confirmation(action_name=None, extra_context={}, """ - def decorator(func, extra_context=extra_context, template=template, action_name=action_name): + def decorator(func, extra_context=extra_context, template=template, action_name=action_name, validatior=validator): @wraps(func, assigned=available_attrs(func)) - def inner(modeladmin, request, queryset, action_name=action_name, extra_context=extra_context): + def inner(modeladmin, request, queryset, action_name=action_name, extra_context=extra_context, validator=validator): + if validator is not None: + try: + validator(queryset) + except ValidationError as e: + messages.error(request, '
'.join(e)) + return # The user has already confirmed the action. if request.POST.get('post') == 'generic_confirmation': stay = func(modeladmin, request, queryset) @@ -82,7 +90,7 @@ def action_with_confirmation(action_name=None, extra_context={}, if callable(extra_context): extra_context = extra_context(modeladmin, request, queryset) - context.update(extra_context) + context.update(extra_context or {}) if 'display_objects' not in context: # Compute it only when necessary context['display_objects'] = format_display_objects(modeladmin, request, queryset) diff --git a/orchestra/contrib/bills/actions.py b/orchestra/contrib/bills/actions.py index 6d461e60..083160f6 100644 --- a/orchestra/contrib/bills/actions.py +++ b/orchestra/contrib/bills/actions.py @@ -4,6 +4,7 @@ from datetime import date from django.contrib import messages from django.contrib.admin import helpers +from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db import transaction from django.forms.models import modelformset_factory @@ -246,11 +247,16 @@ def copy_lines(modeladmin, request, queryset): return move_lines(modeladmin, request, queryset) -@action_with_confirmation() +def validate_amend_bills(bills): + for bill in bills: + if bill.is_open: + raise ValidationError(_("Selected bills should be in closed state")) + if bill.type not in bill.AMEND_MAP: + raise ValidationError(_("%s can not be amended.") % bill.get_type_display()) + + +@action_with_confirmation(validator=validate_amend_bills) def amend_bills(modeladmin, request, queryset): - if queryset.filter(is_open=True).exists(): - messages.warning(request, _("Selected bills should be in closed state")) - return amend_ids = [] for bill in queryset: with translation.override(bill.account.language): diff --git a/orchestra/contrib/bills/admin.py b/orchestra/contrib/bills/admin.py index 3e1ed01b..65d0c9c8 100644 --- a/orchestra/contrib/bills/admin.py +++ b/orchestra/contrib/bills/admin.py @@ -321,6 +321,8 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): if obj: if not obj.is_open: exclude += ['close_bills', 'close_send_download_bills'] + if obj.type not in obj.AMEND_MAP: + exclude += ['amend_bills'] return [action for action in actions if action.__name__ not in exclude] def get_inline_instances(self, request, obj=None): diff --git a/orchestra/contrib/mailboxes/admin.py b/orchestra/contrib/mailboxes/admin.py index 7f77d6e5..ad405e80 100644 --- a/orchestra/contrib/mailboxes/admin.py +++ b/orchestra/contrib/mailboxes/admin.py @@ -219,7 +219,14 @@ class AddressAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): display_mailboxes.allow_tags = True def display_forward(self, address): - values = [ dest for dest in address.forward.split() ] + forward_mailboxes = {m.name: m for m in address.get_forward_mailboxes()} + values = [] + for forward in address.forward.split(): + mbox = forward_mailboxes.get(forward) + if mbox: + values.append(admin_link()(mbox)) + else: + values.append(forward) return '
'.join(values) display_forward.short_description = _("Forward") display_forward.allow_tags = True diff --git a/orchestra/contrib/mailer/engine.py b/orchestra/contrib/mailer/engine.py index 62e45772..fad98a46 100644 --- a/orchestra/contrib/mailer/engine.py +++ b/orchestra/contrib/mailer/engine.py @@ -72,5 +72,5 @@ def send_pending(bulk=settings.MAILER_BULK_MESSAGES): except OperationLocked: pass finally: - if connection.connection is not None: + if 'connection' in vars() and connection.connection is not None: connection.close() diff --git a/orchestra/contrib/orchestration/admin.py b/orchestra/contrib/orchestration/admin.py index 4a0d7bad..000dadf9 100644 --- a/orchestra/contrib/orchestration/admin.py +++ b/orchestra/contrib/orchestration/admin.py @@ -134,7 +134,7 @@ class BackendLogAdmin(admin.ModelAdmin): 'display_created', 'execution_time', ) list_display_links = ('id', 'backend') - list_filter = ('state', 'backend', 'server') + list_filter = ('state', 'server', 'backend') search_fields = ('script',) date_hierarchy = 'created_at' inlines = (BackendOperationInline,) diff --git a/orchestra/contrib/orchestration/manager.py b/orchestra/contrib/orchestration/manager.py index 44f19ed3..b5f6cfca 100644 --- a/orchestra/contrib/orchestration/manager.py +++ b/orchestra/contrib/orchestration/manager.py @@ -184,7 +184,7 @@ def collect(instance, action, **kwargs): if update_fields is not None: # TODO remove this, django does not execute post_save if update_fields=[]... # Maybe open a ticket at Djangoproject ? - # INITIAL INTENTION: "update_fileds=[]" is a convention for explicitly executing backend + # INITIAL INTENTION: "update_fields=[]" is a convention for explicitly executing backend # i.e. account.disable() if update_fields != []: execute = False diff --git a/orchestra/contrib/orchestration/methods.py b/orchestra/contrib/orchestration/methods.py index 54a58916..8596b03c 100644 --- a/orchestra/contrib/orchestration/methods.py +++ b/orchestra/contrib/orchestration/methods.py @@ -123,7 +123,10 @@ def OpenSSH(backend, log, server, cmds, async=False): exit_code = ssh.exit_code if not log.exit_code: log.exit_code = exit_code - log.state = log.SUCCESS if exit_code == 0 else log.FAILURE + if exit_code == 255 and log.stderr.startswith('ssh: connect to host'): + log.state = log.TIMEOUT + else: + log.state = log.SUCCESS if exit_code == 0 else log.FAILURE logger.debug('%s execution state on %s is %s' % (backend, server, log.state)) log.save() except: diff --git a/orchestra/contrib/saas/services/bscw.py b/orchestra/contrib/saas/services/bscw.py index 6a45b759..1afd119e 100644 --- a/orchestra/contrib/saas/services/bscw.py +++ b/orchestra/contrib/saas/services/bscw.py @@ -22,4 +22,4 @@ class BSCWService(SoftwareService): serializer = BSCWDataSerializer icon = 'orchestra/icons/apps/BSCW.png' site_domain = settings.SAAS_BSCW_DOMAIN - change_readonly_fileds = ('email',) + change_readonly_fields = ('email',) diff --git a/orchestra/contrib/saas/services/gitlab.py b/orchestra/contrib/saas/services/gitlab.py index f62042ce..1586a77f 100644 --- a/orchestra/contrib/saas/services/gitlab.py +++ b/orchestra/contrib/saas/services/gitlab.py @@ -30,6 +30,6 @@ class GitLabService(SoftwareService): change_form = GitLaChangeForm serializer = GitLabSerializer site_domain = settings.SAAS_GITLAB_DOMAIN - change_readonly_fileds = ('email', 'user_id',) + change_readonly_fields = ('email', 'user_id',) verbose_name = "GitLab" icon = 'orchestra/icons/apps/gitlab.png' diff --git a/orchestra/contrib/saas/services/options.py b/orchestra/contrib/saas/services/options.py index 0819f75b..af2f75c5 100644 --- a/orchestra/contrib/saas/services/options.py +++ b/orchestra/contrib/saas/services/options.py @@ -49,8 +49,8 @@ class SoftwareService(plugins.Plugin, metaclass=plugins.PluginMount): plugins.append(import_class(cls)) return plugins - def get_change_readonly_fileds(cls): - fields = super(SoftwareService, cls).get_change_readonly_fileds() + def get_change_readonly_fields(cls): + fields = super(SoftwareService, cls).get_change_readonly_fields() return fields + ('name',) def get_site_domain(self): diff --git a/orchestra/contrib/saas/services/seafile.py b/orchestra/contrib/saas/services/seafile.py index da736142..3e7ea594 100644 --- a/orchestra/contrib/saas/services/seafile.py +++ b/orchestra/contrib/saas/services/seafile.py @@ -28,4 +28,4 @@ class SeaFileService(SoftwareService): serializer = SeaFileDataSerializer icon = 'orchestra/icons/apps/seafile.png' site_domain = settings.SAAS_SEAFILE_DOMAIN - change_readonly_fileds = ('email',) + change_readonly_fields = ('email',) diff --git a/orchestra/contrib/saas/services/wordpress.py b/orchestra/contrib/saas/services/wordpress.py index ff8ad933..1e4d3f39 100644 --- a/orchestra/contrib/saas/services/wordpress.py +++ b/orchestra/contrib/saas/services/wordpress.py @@ -40,6 +40,6 @@ class WordPressService(SoftwareService): change_form = WordPressChangeForm serializer = WordPressDataSerializer icon = 'orchestra/icons/apps/WordPress.png' - change_readonly_fileds = ('email', 'blog_id') + change_readonly_fields = ('email', 'blog_id') site_domain = settings.SAAS_WORDPRESS_DOMAIN allow_custom_url = settings.SAAS_WORDPRESS_ALLOW_CUSTOM_URL diff --git a/orchestra/contrib/webapps/backends/moodle.py b/orchestra/contrib/webapps/backends/moodle.py index f140c6c5..9a14f7ef 100644 --- a/orchestra/contrib/webapps/backends/moodle.py +++ b/orchestra/contrib/webapps/backends/moodle.py @@ -67,7 +67,10 @@ class MoodleBackend(WebAppServiceMixin, ServiceController): fi rm %(app_path)s/.lock chown -R %(user)s:%(group)s %(app_path)s - su %(user)s --shell /bin/bash << 'EOF' + # Run install moodle cli command on the background, because it takes so long... + stdout=$(mktemp) + stderr=$(mktemp) + nohup su %(user)s --shell /bin/bash << 'EOF' > $stdout 2> $stderr & php %(app_path)s/admin/cli/install_database.php \\ --fullname="%(site_name)s" \\ --shortname="%(site_name)s" \\ @@ -77,6 +80,14 @@ class MoodleBackend(WebAppServiceMixin, ServiceController): --agree-license \\ --allow-unstable EOF + pid=$! + sleep 2 + if ! ps -p $pid > /dev/null; then + cat $stdout + cat $stderr >&2 + exit_code=$(wait $pid) + fi + rm $stdout $stderr """) % context ) diff --git a/orchestra/contrib/webapps/types/cms.py b/orchestra/contrib/webapps/types/cms.py index d3000354..80822cc2 100644 --- a/orchestra/contrib/webapps/types/cms.py +++ b/orchestra/contrib/webapps/types/cms.py @@ -51,12 +51,13 @@ class CMSApp(PHPApp): """ Abstract AppType with common CMS functionality """ serializer = CMSAppSerializer change_form = CMSAppForm - change_readonly_fileds = ('db_name', 'db_user', 'password',) + change_readonly_fields = ('db_name', 'db_user', 'password',) db_type = Database.MYSQL abstract = True + db_prefix = 'cms_' def get_db_name(self): - db_name = 'wp_%s_%s' % (self.instance.name, self.instance.account) + db_name = '%s%s_%s' % (self.db_prefix, self.instance.name, self.instance.account) # Limit for mysql database names return db_name[:65] diff --git a/orchestra/contrib/webapps/types/misc.py b/orchestra/contrib/webapps/types/misc.py index f94b879a..81224e71 100644 --- a/orchestra/contrib/webapps/types/misc.py +++ b/orchestra/contrib/webapps/types/misc.py @@ -52,4 +52,4 @@ class SymbolicLinkApp(PHPApp): form = SymbolicLinkForm serializer = SymbolicLinkSerializer icon = 'orchestra/icons/apps/SymbolicLink.png' - change_readonly_fileds = ('path',) + change_readonly_fields = ('path',) diff --git a/orchestra/contrib/webapps/types/moodle.py b/orchestra/contrib/webapps/types/moodle.py index 9d61450a..54921dd6 100644 --- a/orchestra/contrib/webapps/types/moodle.py +++ b/orchestra/contrib/webapps/types/moodle.py @@ -13,6 +13,7 @@ class MoodleApp(CMSApp): "The password will be visible in the 'password' field after the installer has finished." ) icon = 'orchestra/icons/apps/Moodle.png' + db_prefix = 'modl_' def get_detail(self): return self.instance.data.get('php_version', '') diff --git a/orchestra/contrib/webapps/types/wordpress.py b/orchestra/contrib/webapps/types/wordpress.py index b8606246..0d02ff8f 100644 --- a/orchestra/contrib/webapps/types/wordpress.py +++ b/orchestra/contrib/webapps/types/wordpress.py @@ -13,6 +13,7 @@ class WordPressApp(CMSApp): "The password will be visible in the 'password' field after the installer has finished." ) icon = 'orchestra/icons/apps/WordPress.png' + db_prefix = 'wp_' def get_detail(self): return self.instance.data.get('php_version', '') diff --git a/orchestra/contrib/websites/serializers.py b/orchestra/contrib/websites/serializers.py index e08c12c4..89fd6ce8 100644 --- a/orchestra/contrib/websites/serializers.py +++ b/orchestra/contrib/websites/serializers.py @@ -54,7 +54,7 @@ class WebsiteSerializer(AccountSerializerMixin, HyperlinkedModelSerializer): class Meta: model = Website fields = ('url', 'id', 'name', 'protocol', 'domains', 'is_active', 'contents', 'directives') - postonly_fileds = ('name',) + postonly_fields = ('name',) def validate(self, data): """ Prevent multiples domains on the same protocol """ diff --git a/orchestra/forms/options.py b/orchestra/forms/options.py index 36719af8..8d4875bf 100644 --- a/orchestra/forms/options.py +++ b/orchestra/forms/options.py @@ -82,7 +82,7 @@ class ReadOnlyFormMixin(object): """ Mixin class for ModelForm or Form that provides support for SpanField on readonly fields Meta: - readonly_fileds = (ro_field1, ro_field2) + readonly_fields = (ro_field1, ro_field2) """ def __init__(self, *args, **kwargs): super(ReadOnlyFormMixin, self).__init__(*args, **kwargs) diff --git a/orchestra/plugins/forms.py b/orchestra/plugins/forms.py index a657d748..fcb50421 100644 --- a/orchestra/plugins/forms.py +++ b/orchestra/plugins/forms.py @@ -27,7 +27,7 @@ class PluginDataForm(forms.ModelForm): self._meta.help_texts = { self.plugin_field: plugin_help_text or model_help_text } - for field in self.plugin.get_change_readonly_fileds(): + for field in self.plugin.get_change_readonly_fields(): value = getattr(self.instance, field, None) or self.instance.data.get(field) display = value foo_display = getattr(self.instance, 'get_%s_display' % field, None) diff --git a/orchestra/plugins/options.py b/orchestra/plugins/options.py index 7d0c5ca2..0ff197aa 100644 --- a/orchestra/plugins/options.py +++ b/orchestra/plugins/options.py @@ -9,7 +9,7 @@ class Plugin(object): change_form = None form = None serializer = None - change_readonly_fileds = () + change_readonly_fields = () plugin_field = None def __init__(self, instance=None): @@ -52,8 +52,8 @@ class Plugin(object): return sorted(choices, key=lambda e: e[1]) @classmethod - def get_change_readonly_fileds(cls): - return cls.change_readonly_fileds + def get_change_readonly_fields(cls): + return cls.change_readonly_fields @classmethod def get_class_path(cls):