From 0047a781af9834db7f69d508ae0f83e8a43f5900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Howe=20Gersager?= Date: Fri, 7 Apr 2023 15:16:39 +0200 Subject: [PATCH] Fix constance management command without admin installed (#506) * refactor out ConstanceForm and get_values into forms.py and utils.py respectively * fix tests and documentation * correct mock import * fix merge --- constance/admin.py | 176 +-------------------- constance/forms.py | 157 ++++++++++++++++++ constance/management/commands/constance.py | 6 +- constance/utils.py | 17 ++ docs/index.rst | 3 +- tests/test_admin.py | 9 +- tests/test_checks.py | 2 +- tests/test_form.py | 2 +- tests/test_utils.py | 2 +- 9 files changed, 195 insertions(+), 179 deletions(-) create mode 100644 constance/forms.py diff --git a/constance/admin.py b/constance/admin.py index 638837b..f42dbcf 100644 --- a/constance/admin.py +++ b/constance/admin.py @@ -1,187 +1,25 @@ from collections import OrderedDict -from datetime import datetime, date, time, timedelta -from decimal import Decimal +from datetime import date, datetime from operator import itemgetter -import hashlib -from django import forms, VERSION, conf +from django import VERSION, forms from django.apps import apps from django.contrib import admin, messages -from django.contrib.admin import widgets from django.contrib.admin.options import csrf_protect_m -from django.core.exceptions import PermissionDenied, ImproperlyConfigured -from django.core.files.storage import default_storage -from django.forms import fields +from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.template.response import TemplateResponse -from django.utils import timezone -from django.utils.encoding import smart_bytes -from django.utils.formats import localize -from django.utils.module_loading import import_string -from django.utils.text import normalize_newlines -from django.utils.translation import gettext_lazy as _ from django.urls import path +from django.utils.formats import localize +from django.utils.translation import gettext_lazy as _ from . import LazyConfig, settings -from .checks import get_inconsistent_fieldnames - +from .forms import ConstanceForm +from .utils import get_values config = LazyConfig() -NUMERIC_WIDGET = forms.TextInput(attrs={'size': 10}) - -INTEGER_LIKE = (fields.IntegerField, {'widget': NUMERIC_WIDGET}) -STRING_LIKE = (fields.CharField, { - 'widget': forms.Textarea(attrs={'rows': 3}), - 'required': False, -}) - -FIELDS = { - bool: (fields.BooleanField, {'required': False}), - int: INTEGER_LIKE, - Decimal: (fields.DecimalField, {'widget': NUMERIC_WIDGET}), - str: STRING_LIKE, - datetime: ( - fields.SplitDateTimeField, {'widget': widgets.AdminSplitDateTime} - ), - timedelta: ( - fields.DurationField, {'widget': widgets.AdminTextInputWidget} - ), - date: (fields.DateField, {'widget': widgets.AdminDateWidget}), - time: (fields.TimeField, {'widget': widgets.AdminTimeWidget}), - float: (fields.FloatField, {'widget': NUMERIC_WIDGET}), -} - - -def parse_additional_fields(fields): - for key in fields: - field = list(fields[key]) - - if len(field) == 1: - field.append({}) - - field[0] = import_string(field[0]) - - if 'widget' in field[1]: - klass = import_string(field[1]['widget']) - field[1]['widget'] = klass( - **(field[1].get('widget_kwargs', {}) or {}) - ) - - if 'widget_kwargs' in field[1]: - del field[1]['widget_kwargs'] - - fields[key] = field - - return fields - - -FIELDS.update(parse_additional_fields(settings.ADDITIONAL_FIELDS)) - - -def get_values(): - """ - Get dictionary of values from the backend - :return: - """ - - # First load a mapping between config name and default value - default_initial = ((name, options[0]) - for name, options in settings.CONFIG.items()) - # Then update the mapping with actually values from the backend - initial = dict(default_initial, **dict(config._backend.mget(settings.CONFIG))) - - return initial - - -class ConstanceForm(forms.Form): - version = forms.CharField(widget=forms.HiddenInput) - - def __init__(self, initial, request=None, *args, **kwargs): - super().__init__(*args, initial=initial, **kwargs) - version_hash = hashlib.sha256() - - only_view = request and not request.user.has_perm('constance.change_config') - if only_view: - messages.warning( - request, - _("You don't have permission to change these values"), - ) - - for name, options in settings.CONFIG.items(): - default = options[0] - if len(options) == 3: - config_type = options[2] - if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type): - raise ImproperlyConfigured(_("Default value type must be " - "equal to declared config " - "parameter type. Please fix " - "the default value of " - "'%(name)s'.") - % {'name': name}) - else: - config_type = type(default) - - if config_type not in FIELDS: - raise ImproperlyConfigured(_("Constance doesn't support " - "config values of the type " - "%(config_type)s. Please fix " - "the value of '%(name)s'.") - % {'config_type': config_type, - 'name': name}) - field_class, kwargs = FIELDS[config_type] - if only_view: - kwargs['disabled'] = True - self.fields[name] = field_class(label=name, **kwargs) - - version_hash.update(smart_bytes(initial.get(name, ''))) - self.initial['version'] = version_hash.hexdigest() - - def save(self): - for file_field in self.files: - file = self.cleaned_data[file_field] - self.cleaned_data[file_field] = default_storage.save(file.name, file) - - for name in settings.CONFIG: - current = getattr(config, name) - new = self.cleaned_data[name] - - if isinstance(new, str): - new = normalize_newlines(new) - - if conf.settings.USE_TZ and isinstance(current, datetime) and not timezone.is_aware(current): - current = timezone.make_aware(current) - - if current != new: - setattr(config, name, new) - - def clean_version(self): - value = self.cleaned_data['version'] - - if settings.IGNORE_ADMIN_VERSION_CHECK: - return value - - if value != self.initial['version']: - raise forms.ValidationError(_('The settings have been modified ' - 'by someone else. Please reload the ' - 'form and resubmit your changes.')) - return value - - def clean(self): - cleaned_data = super().clean() - - if not settings.CONFIG_FIELDSETS: - return cleaned_data - - missing_keys, extra_keys = get_inconsistent_fieldnames() - if missing_keys or extra_keys: - raise forms.ValidationError(_('CONSTANCE_CONFIG_FIELDSETS is missing ' - 'field(s) that exists in CONSTANCE_CONFIG.')) - - return cleaned_data - - class ConstanceAdmin(admin.ModelAdmin): change_list_template = 'admin/constance/change_list.html' change_list_form = ConstanceForm diff --git a/constance/forms.py b/constance/forms.py new file mode 100644 index 0000000..5ed30c2 --- /dev/null +++ b/constance/forms.py @@ -0,0 +1,157 @@ +import hashlib +from datetime import date, datetime, time, timedelta +from decimal import Decimal + +from django import conf, forms +from django.contrib import messages +from django.contrib.admin import widgets +from django.core.exceptions import ImproperlyConfigured +from django.core.files.storage import default_storage +from django.forms import fields +from django.utils import timezone +from django.utils.encoding import smart_bytes +from django.utils.module_loading import import_string +from django.utils.text import normalize_newlines +from django.utils.translation import gettext_lazy as _ + +from . import LazyConfig, settings +from .checks import get_inconsistent_fieldnames + +config = LazyConfig() + +NUMERIC_WIDGET = forms.TextInput(attrs={'size': 10}) + +INTEGER_LIKE = (fields.IntegerField, {'widget': NUMERIC_WIDGET}) +STRING_LIKE = (fields.CharField, { + 'widget': forms.Textarea(attrs={'rows': 3}), + 'required': False, +}) + +FIELDS = { + bool: (fields.BooleanField, {'required': False}), + int: INTEGER_LIKE, + Decimal: (fields.DecimalField, {'widget': NUMERIC_WIDGET}), + str: STRING_LIKE, + datetime: ( + fields.SplitDateTimeField, {'widget': widgets.AdminSplitDateTime} + ), + timedelta: ( + fields.DurationField, {'widget': widgets.AdminTextInputWidget} + ), + date: (fields.DateField, {'widget': widgets.AdminDateWidget}), + time: (fields.TimeField, {'widget': widgets.AdminTimeWidget}), + float: (fields.FloatField, {'widget': NUMERIC_WIDGET}), +} + +def parse_additional_fields(fields): + for key in fields: + field = list(fields[key]) + + if len(field) == 1: + field.append({}) + + field[0] = import_string(field[0]) + + if 'widget' in field[1]: + klass = import_string(field[1]['widget']) + field[1]['widget'] = klass( + **(field[1].get('widget_kwargs', {}) or {}) + ) + + if 'widget_kwargs' in field[1]: + del field[1]['widget_kwargs'] + + fields[key] = field + + return fields + + +FIELDS.update(parse_additional_fields(settings.ADDITIONAL_FIELDS)) + + + +class ConstanceForm(forms.Form): + version = forms.CharField(widget=forms.HiddenInput) + + def __init__(self, initial, request=None, *args, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + version_hash = hashlib.sha256() + + only_view = request and not request.user.has_perm('constance.change_config') + if only_view: + messages.warning( + request, + _("You don't have permission to change these values"), + ) + + for name, options in settings.CONFIG.items(): + default = options[0] + if len(options) == 3: + config_type = options[2] + if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type): + raise ImproperlyConfigured(_("Default value type must be " + "equal to declared config " + "parameter type. Please fix " + "the default value of " + "'%(name)s'.") + % {'name': name}) + else: + config_type = type(default) + + if config_type not in FIELDS: + raise ImproperlyConfigured(_("Constance doesn't support " + "config values of the type " + "%(config_type)s. Please fix " + "the value of '%(name)s'.") + % {'config_type': config_type, + 'name': name}) + field_class, kwargs = FIELDS[config_type] + if only_view: + kwargs['disabled'] = True + self.fields[name] = field_class(label=name, **kwargs) + + version_hash.update(smart_bytes(initial.get(name, ''))) + self.initial['version'] = version_hash.hexdigest() + + def save(self): + for file_field in self.files: + file = self.cleaned_data[file_field] + self.cleaned_data[file_field] = default_storage.save(file.name, file) + + for name in settings.CONFIG: + current = getattr(config, name) + new = self.cleaned_data[name] + + if isinstance(new, str): + new = normalize_newlines(new) + + if conf.settings.USE_TZ and isinstance(current, datetime) and not timezone.is_aware(current): + current = timezone.make_aware(current) + + if current != new: + setattr(config, name, new) + + def clean_version(self): + value = self.cleaned_data['version'] + + if settings.IGNORE_ADMIN_VERSION_CHECK: + return value + + if value != self.initial['version']: + raise forms.ValidationError(_('The settings have been modified ' + 'by someone else. Please reload the ' + 'form and resubmit your changes.')) + return value + + def clean(self): + cleaned_data = super().clean() + + if not settings.CONFIG_FIELDSETS: + return cleaned_data + + missing_keys, extra_keys = get_inconsistent_fieldnames() + if missing_keys or extra_keys: + raise forms.ValidationError(_('CONSTANCE_CONFIG_FIELDSETS is missing ' + 'field(s) that exists in CONSTANCE_CONFIG.')) + + return cleaned_data diff --git a/constance/management/commands/constance.py b/constance/management/commands/constance.py index 7d44000..c307887 100644 --- a/constance/management/commands/constance.py +++ b/constance/management/commands/constance.py @@ -1,12 +1,12 @@ +from django import VERSION from django.conf import settings from django.core.exceptions import ValidationError from django.core.management import BaseCommand, CommandError from django.utils.translation import gettext as _ -from django import VERSION - from ... import config -from ...admin import ConstanceForm, get_values +from ...forms import ConstanceForm +from ...utils import get_values from ...models import Constance diff --git a/constance/utils.py b/constance/utils.py index 8d8d341..4151239 100644 --- a/constance/utils.py +++ b/constance/utils.py @@ -1,6 +1,23 @@ from importlib import import_module +from . import LazyConfig, settings + +config = LazyConfig() def import_module_attr(path): package, module = path.rsplit('.', 1) return getattr(import_module(package), module) + +def get_values(): + """ + Get dictionary of values from the backend + :return: + """ + + # First load a mapping between config name and default value + default_initial = ((name, options[0]) + for name, options in settings.CONFIG.items()) + # Then update the mapping with actually values from the backend + initial = dict(default_initial, **dict(config._backend.mget(settings.CONFIG))) + + return initial \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 64efc4b..c047310 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -391,7 +391,8 @@ settings the way you like. .. code-block:: python - from constance.admin import ConstanceAdmin, ConstanceForm, Config + from constance.admin import ConstanceAdmin, Config + from constance.forms import ConstanceForm class CustomConfigForm(ConstanceForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/tests/test_admin.py b/tests/test_admin.py index 14b5047..921ce10 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -10,9 +10,12 @@ from django.test import TestCase, RequestFactory from django.utils.translation import gettext_lazy as _ from constance import settings -from constance.admin import Config, get_values, ConstanceForm +from constance.admin import Config +from constance.utils import get_values +from constance.forms import ConstanceForm from unittest import mock + class TestAdmin(TestCase): model = Config @@ -108,8 +111,8 @@ class TestAdmin(TestCase): 'INT_VALUE': (1, 'some int'), }) @mock.patch('constance.settings.IGNORE_ADMIN_VERSION_CHECK', True) - @mock.patch("constance.admin.ConstanceForm.save", lambda _: None) - @mock.patch("constance.admin.ConstanceForm.is_valid", lambda _: True) + @mock.patch("constance.forms.ConstanceForm.save", lambda _: None) + @mock.patch("constance.forms.ConstanceForm.is_valid", lambda _: True) def test_submit(self): """ Test that submitting the admin page results in an http redirect when diff --git a/tests/test_checks.py b/tests/test_checks.py index 0e42556..4bf8f63 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -1,8 +1,8 @@ +from constance import settings from unittest import mock from constance.checks import check_fieldsets, get_inconsistent_fieldnames from django.test import TestCase -from constance import settings class ChecksTestCase(TestCase): diff --git a/tests/test_form.py b/tests/test_form.py index 4016c86..7e8ac77 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -1,4 +1,4 @@ -from constance.admin import ConstanceForm +from constance.forms import ConstanceForm from django.forms import fields from django.test import TestCase diff --git a/tests/test_utils.py b/tests/test_utils.py index a47b3e4..ad66286 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ import datetime from decimal import Decimal -from constance.admin import get_values +from constance.utils import get_values from constance.management.commands.constance import _set_constance_value from django.core.exceptions import ValidationError from django.test import TestCase