diff --git a/HISTORY.rst b/HISTORY.rst index da9cb9b890..723bffe6ac 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -50,8 +50,11 @@ - Add workflow action to update the label and description of a document. - Add COMMON_PROJECT_TITLE as a setting option to customize the title string. - - +- Add support for YAML configuration files. +- Add support for editing setting options and saving them using the + new YAML configuration file support. +- Add new revertsettings management command. +- Add new permission to edit setting via the UI. 3.0.1 (2018-07-08) ================= diff --git a/mayan/apps/converter/settings.py b/mayan/apps/converter/settings.py index bfe04fb82d..678b5de8b0 100644 --- a/mayan/apps/converter/settings.py +++ b/mayan/apps/converter/settings.py @@ -32,5 +32,5 @@ setting_graphics_backend_config = namespace.add_setting( DEFAULT_PILLOW_FORMAT ), help_text=_( 'Configuration options for the graphics conversion backend.' - ), global_name='CONVERTER_GRAPHICS_BACKEND_CONFIG', + ), global_name='CONVERTER_GRAPHICS_BACKEND_CONFIG', quoted=True ) diff --git a/mayan/apps/document_signatures/settings.py b/mayan/apps/document_signatures/settings.py index 18525cd39d..d3f04f8b67 100644 --- a/mayan/apps/document_signatures/settings.py +++ b/mayan/apps/document_signatures/settings.py @@ -16,5 +16,5 @@ setting_storage_backend_arguments = namespace.add_setting( global_name='SIGNATURES_STORAGE_BACKEND_ARGUMENTS', default='{{location: {}}}'.format( os.path.join(settings.MEDIA_ROOT, 'document_signatures') - ) + ), quoted=True ) diff --git a/mayan/apps/documents/settings.py b/mayan/apps/documents/settings.py index 3bee9448ea..3af7f5bd33 100644 --- a/mayan/apps/documents/settings.py +++ b/mayan/apps/documents/settings.py @@ -10,6 +10,17 @@ from smart_settings import Namespace from .literals import DEFAULT_LANGUAGE, DEFAULT_LANGUAGE_CODES namespace = Namespace(name='documents', label=_('Documents')) + +setting_documentimagecache_storage = namespace.add_setting( + global_name='DOCUMENTS_CACHE_STORAGE_BACKEND', + default='django.core.files.storage.FileSystemStorage', quoted=True +) +setting_documentimagecache_storage_arguments = namespace.add_setting( + global_name='DOCUMENTS_CACHE_STORAGE_BACKEND_ARGUMENTS', + default='{{location: {}}}'.format( + os.path.join(settings.MEDIA_ROOT, 'document_cache') + ), quoted=True +) setting_disable_base_image_cache = namespace.add_setting( global_name='DOCUMENTS_DISABLE_BASE_IMAGE_CACHE', default=False, help_text=_( @@ -31,19 +42,6 @@ setting_display_height = namespace.add_setting( setting_display_width = namespace.add_setting( global_name='DOCUMENTS_DISPLAY_WIDTH', default='3600' ) -settings_document_page_image_cache_time = namespace.add_setting( - global_name='DOCUMENTS_PAGE_IMAGE_CACHE_TIME', default='31556926' -) -setting_documentimagecache_storage = namespace.add_setting( - global_name='DOCUMENTS_CACHE_STORAGE_BACKEND', - default='django.core.files.storage.FileSystemStorage' -) -setting_documentimagecache_storage_arguments = namespace.add_setting( - global_name='DOCUMENTS_CACHE_STORAGE_BACKEND_ARGUMENTS', - default='{{location: {}}}'.format( - os.path.join(settings.MEDIA_ROOT, 'document_cache') - ) -) setting_fix_orientation = namespace.add_setting( global_name='DOCUMENTS_FIX_ORIENTATION', default=False, help_text=_( @@ -61,6 +59,9 @@ setting_language_codes = namespace.add_setting( global_name='DOCUMENTS_LANGUAGE_CODES', default=DEFAULT_LANGUAGE_CODES, help_text=_('List of supported document languages. In ISO639-3 format.') ) +settings_document_page_image_cache_time = namespace.add_setting( + global_name='DOCUMENTS_PAGE_IMAGE_CACHE_TIME', default='31556926' +) setting_preview_height = namespace.add_setting( global_name='DOCUMENTS_PREVIEW_HEIGHT', default='' ) diff --git a/mayan/apps/mailer/settings.py b/mayan/apps/mailer/settings.py index da1ea8c527..6ba4fb5f97 100644 --- a/mayan/apps/mailer/settings.py +++ b/mayan/apps/mailer/settings.py @@ -13,20 +13,20 @@ namespace = Namespace(name='mailer', label=_('Mailing')) setting_link_subject_template = namespace.add_setting( default=_('Link for document: {{ document }}'), help_text=_('Template for the document link email form subject line.'), - global_name='MAILER_LINK_SUBJECT_TEMPLATE', + global_name='MAILER_LINK_SUBJECT_TEMPLATE', quoted=True ) setting_link_body_template = namespace.add_setting( default=DEFAULT_LINK_BODY_TEMPLATE, help_text=_('Template for the document link email form body text. Can include HTML.'), - global_name='MAILER_LINK_BODY_TEMPLATE', + global_name='MAILER_LINK_BODY_TEMPLATE', quoted=True ) setting_document_subject_template = namespace.add_setting( default=_('Document: {{ document }}'), help_text=_('Template for the document email form subject line.'), - global_name='MAILER_DOCUMENT_SUBJECT_TEMPLATE', + global_name='MAILER_DOCUMENT_SUBJECT_TEMPLATE', quoted=True ) setting_document_body_template = namespace.add_setting( default=DEFAULT_DOCUMENT_BODY_TEMPLATE, help_text=_('Template for the document email form body text. Can include HTML.'), - global_name='MAILER_DOCUMENT_BODY_TEMPLATE', + global_name='MAILER_DOCUMENT_BODY_TEMPLATE', quoted=True ) diff --git a/mayan/apps/smart_settings/apps.py b/mayan/apps/smart_settings/apps.py index 6825455503..ee6eefee74 100644 --- a/mayan/apps/smart_settings/apps.py +++ b/mayan/apps/smart_settings/apps.py @@ -2,11 +2,14 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ -from common import MayanAppConfig, menu_setup, menu_object +from common import MayanAppConfig, menu_secondary, menu_setup, menu_object from navigation import SourceColumn from .classes import Namespace, Setting -from .links import link_namespace_detail, link_namespace_list +from .links import ( + link_namespace_detail, link_namespace_list, link_namespace_root_list, + link_setting_edit +) from .widgets import setting_widget @@ -37,4 +40,10 @@ class SmartSettingsApp(MayanAppConfig): menu_object.bind_links( links=(link_namespace_detail,), sources=(Namespace,) ) + menu_object.bind_links( + links=(link_setting_edit,), sources=(Setting,) + ) + menu_secondary.bind_links(links=(link_namespace_root_list,)) menu_setup.bind_links(links=(link_namespace_list,)) + + Setting.save_last_known_good() diff --git a/mayan/apps/smart_settings/classes.py b/mayan/apps/smart_settings/classes.py index 90ceebdfe1..6175ac5008 100644 --- a/mayan/apps/smart_settings/classes.py +++ b/mayan/apps/smart_settings/classes.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from importlib import import_module import logging import os +import sys import yaml @@ -43,9 +44,6 @@ class Namespace(object): for namespace in cls.get_all(): namespace.invalidate_cache() - def __str__(self): - return force_text(self.label) - def __init__(self, name, label): if name in self.__class__._registry: raise Exception( @@ -56,6 +54,9 @@ class Namespace(object): self.__class__._registry[name] = self self._settings = [] + def __str__(self): + return force_text(self.label) + def add_setting(self, **kwargs): return Setting(namespace=self, **kwargs) @@ -90,14 +91,52 @@ class Setting(object): return result @classmethod - def get(cls, global_name): - return cls._registry[global_name].value + def dump_data(cls): + result = [] - def __init__(self, namespace, global_name, default, help_text=None, is_path=False): + for setting in cls.get_all(): + # Ensure there is at least one newline + line = '{}: {}\n'.format( + setting.global_name, setting.serialized_value + ) + + # If there are two newlines, remove one + if line.endswith('\n\n'): + line = line[:-1] + + result.append(line) + + return ''.join(result) + + @classmethod + def get(cls, global_name): + return cls._registry[global_name] + + @classmethod + def get_all(cls): + return sorted(cls._registry.values(), key=lambda x: x.global_name) + + @classmethod + def save_configuration(cls, path=settings.CONFIGURATION_FILEPATH): + with open(path, 'w') as file_object: + file_object.write(cls.dump_data()) + + @classmethod + def save_last_known_good(cls): + # Don't write over the last good configuration if we are trying + # to restore the last good configuration + if not 'revertsettings' in sys.argv: + cls.save_configuration( + path=settings.CONFIGURATION_LAST_GOOD_FILEPATH + ) + + def __init__(self, namespace, global_name, default, help_text=None, is_path=False, quoted=False): self.global_name = global_name self.default = default self.help_text = help_text self.loaded = False + self.namespace = namespace + self.quoted = quoted namespace._settings.append(self) self.__class__._registry[global_name] = self @@ -137,5 +176,9 @@ class Setting(object): @value.setter def value(self, value): # value is in YAML format - self.yaml = value + if self.quoted: + self.yaml = '\'{}\''.format(value) + value = '\'{}\''.format(value) + else: + self.yaml = value self.raw_value = Setting.deserialize_value(value) diff --git a/mayan/apps/smart_settings/forms.py b/mayan/apps/smart_settings/forms.py new file mode 100644 index 0000000000..f3f1f1758e --- /dev/null +++ b/mayan/apps/smart_settings/forms.py @@ -0,0 +1,36 @@ +from __future__ import unicode_literals + +import yaml + +from django import forms +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.module_loading import import_string +from django.utils.translation import ugettext_lazy as _ + + +class SettingForm(forms.Form): + value = forms.CharField( + help_text=_('Enter the new setting value.'), required=False, + widget=forms.widgets.Textarea() + ) + + def __init__(self, *args, **kwargs): + super(SettingForm, self).__init__(*args, **kwargs) + self.fields['value'].help_text = self.initial['setting'].help_text + self.fields['value'].initial = self.initial['setting'].value + + def clean(self): + try: + yaml.safe_dump(self.cleaned_data['value']) + except yaml.YAMLError as exception: + try: + yaml.safe_load('{}'.format(self.cleaned_data['value'])) + except yaml.YAMLError as exception: + raise ValidationError( + _( + '"%s" not a valid entry.' + ) % self.cleaned_data['value'] + ) diff --git a/mayan/apps/smart_settings/links.py b/mayan/apps/smart_settings/links.py index 8ac413fa92..5ac6c7da95 100644 --- a/mayan/apps/smart_settings/links.py +++ b/mayan/apps/smart_settings/links.py @@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from navigation import Link from .icons import icon_namespace_list -from .permissions import permission_settings_view +from .permissions import permission_settings_edit, permission_settings_view link_namespace_list = Link( icon_class=icon_namespace_list, permissions=(permission_settings_view,), @@ -15,3 +15,12 @@ link_namespace_detail = Link( args='resolved_object.name', permissions=(permission_settings_view,), text=_('Settings'), view='settings:namespace_detail', ) +# Duplicate the link to use a different name +link_namespace_root_list = Link( + icon_class=icon_namespace_list, permissions=(permission_settings_view,), + text=_('Namespaces'), view='settings:namespace_list' +) +link_setting_edit = Link( + args='resolved_object.global_name', permissions=(permission_settings_edit,), + text=_('Edit'), view='settings:setting_edit_view', +) diff --git a/mayan/apps/smart_settings/literals.py b/mayan/apps/smart_settings/literals.py new file mode 100644 index 0000000000..d5fec7dd94 --- /dev/null +++ b/mayan/apps/smart_settings/literals.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals + +#from importlib import import_module +#import logging +import os + +#import yaml + +from django.apps import apps +from django.conf import settings +#from django.utils.functional import Promise +#from django.utils.encoding import force_text, python_2_unicode_compatible + +#SETTING_FILE_LAST_KNOWN_GOOD = +CONFIGURATION_FILENAME = '_settings.yml' +CONFIGURATION_FILEPATH = os.path.join( + settings.MEDIA_ROOT, CONFIGURATION_FILENAME +) diff --git a/mayan/apps/smart_settings/management/__init__.py b/mayan/apps/smart_settings/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mayan/apps/smart_settings/management/commands/__init__.py b/mayan/apps/smart_settings/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mayan/apps/smart_settings/management/commands/revertsettings.py b/mayan/apps/smart_settings/management/commands/revertsettings.py new file mode 100644 index 0000000000..939ce3a097 --- /dev/null +++ b/mayan/apps/smart_settings/management/commands/revertsettings.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + +import errno +import os +from shutil import copyfile + +from django.conf import settings +from django.core import management +from django.core.management.utils import get_random_secret_key + + +class Command(management.BaseCommand): + help = 'Rollback the configuration file to the last valid version.' + + def handle(self, *args, **options): + try: + copyfile( + settings.CONFIGURATION_LAST_GOOD_FILEPATH, + settings.CONFIGURATION_FILEPATH + ) + except IOError as exception: + if exception.errno == errno.ENOENT: + self.stdout.write( + self.style.NOTICE( + 'There is no last valid version to restore.' + ) + ) + else: + raise diff --git a/mayan/apps/smart_settings/permissions.py b/mayan/apps/smart_settings/permissions.py index 3a7fb3b14a..38f82240a7 100644 --- a/mayan/apps/smart_settings/permissions.py +++ b/mayan/apps/smart_settings/permissions.py @@ -6,6 +6,9 @@ from permissions import PermissionNamespace namespace = PermissionNamespace('smart_settings', _('Smart settings')) +permission_settings_edit = namespace.add_permission( + name='permission_settings_edit', label=_('Edit settings') +) permission_settings_view = namespace.add_permission( name='permission_settings_view', label=_('View settings') ) diff --git a/mayan/apps/smart_settings/urls.py b/mayan/apps/smart_settings/urls.py index 2e376b8564..7e108ac756 100644 --- a/mayan/apps/smart_settings/urls.py +++ b/mayan/apps/smart_settings/urls.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.conf.urls import url -from .views import NamespaceDetailView, NamespaceListView +from .views import NamespaceDetailView, NamespaceListView, SettingEditView urlpatterns = [ url( @@ -13,4 +13,8 @@ urlpatterns = [ r'^namespace/(?P\w+)/$', NamespaceDetailView.as_view(), name='namespace_detail' ), + url( + r'^edit/(?P\w+)/$', + SettingEditView.as_view(), name='setting_edit_view' + ), ] diff --git a/mayan/apps/smart_settings/views.py b/mayan/apps/smart_settings/views.py index 902a9d41b9..73f34515be 100644 --- a/mayan/apps/smart_settings/views.py +++ b/mayan/apps/smart_settings/views.py @@ -1,12 +1,15 @@ from __future__ import unicode_literals +from django.contrib import messages from django.http import Http404 +from django.urls import reverse from django.utils.translation import ugettext_lazy as _ -from common.views import SingleObjectListView +from common.views import FormView, SingleObjectListView -from .classes import Namespace -from .permissions import permission_settings_view +from .classes import Namespace, Setting +from .forms import SettingForm +from .permissions import permission_settings_edit, permission_settings_view class NamespaceListView(SingleObjectListView): @@ -39,3 +42,35 @@ class NamespaceDetailView(SingleObjectListView): def get_object_list(self): return self.get_namespace().settings + + +class SettingEditView(FormView): + form_class = SettingForm + view_permission = permission_settings_edit + + def form_valid(self, form): + self.get_object().value = form.cleaned_data['value'] + Setting.save_configuration() + messages.success( + self.request, _('Setting updated successfully.') + ) + return super(SettingEditView, self).form_valid(form=form) + + def get_extra_context(self): + return { + 'hide_link': True, + 'title': _('Edit setting: %s') % self.get_object(), + } + + def get_initial(self): + return {'setting': self.get_object()} + + def get_object(self): + return Setting.get(self.kwargs['setting_global_name']) + + def get_post_action_redirect(self): + return reverse( + 'settings:namespace_detail', args=( + self.get_object().namespace.name, + ) + ) diff --git a/mayan/settings/base.py b/mayan/settings/base.py index 3fb7817890..2945c29629 100644 --- a/mayan/settings/base.py +++ b/mayan/settings/base.py @@ -11,9 +11,12 @@ https://docs.djangoproject.com/en/1.10/ref/settings/ """ from __future__ import unicode_literals +import errno import os import sys +import yaml + from django.utils.translation import ugettext_lazy as _ import environ @@ -347,3 +350,44 @@ else: # ----- Debug ----- DEBUG = env.bool('MAYAN_DEBUG', default=False) + + + +CONFIGURATION_FILENAME = '_settings.yml' +CONFIGURATION_FILEPATH = os.path.join(MEDIA_ROOT, CONFIGURATION_FILENAME) + +CONFIGURATION_USER_FILENAME = 'config.yml' +CONFIGURATION_USER_FILEPATH = os.path.join( + MEDIA_ROOT, CONFIGURATION_USER_FILENAME +) + +CONFIGURATION_LAST_GOOD_FILENAME = '_settings_backup.yml' +CONFIGURATION_LAST_GOOD_FILEPATH = os.path.join( + MEDIA_ROOT, CONFIGURATION_LAST_GOOD_FILENAME +) + + +def read_configuration_file(path): + try: + with open(CONFIGURATION_FILEPATH) as file_object: + file_object.seek(0, os.SEEK_END) + if file_object.tell(): + file_object.seek(0) + try: + globals().update(yaml.safe_load(file_object)) + except yaml.YAMLError as exception: + exit( + 'Error loading configuration file: {}; {}'.format( + CONFIGURATION_FILEPATH, exception + ) + ) + except IOError as exception: + if exception.errno == errno.ENOENT: + pass + else: + raise + + +if not 'revertsettings' in sys.argv: + read_configuration_file(CONFIGURATION_FILEPATH) + read_configuration_file(CONFIGURATION_USER_FILEPATH)