1from collections import OrderedDict
2from datetime import datetime, date, time, timedelta
3from decimal import Decimal
4from operator import itemgetter
5import hashlib
6
7from django import forms, VERSION, conf
8from django.apps import apps
9from django.conf.urls import url
10from django.contrib import admin, messages
11from django.contrib.admin import widgets
12from django.contrib.admin.options import csrf_protect_m
13from django.core.exceptions import PermissionDenied, ImproperlyConfigured
14from django.core.files.storage import default_storage
15from django.forms import fields
16from django.http import HttpResponseRedirect
17from django.template.response import TemplateResponse
18from django.utils import timezone
19from django.utils.encoding import smart_bytes
20from django.utils.formats import localize
21from django.utils.module_loading import import_string
22from django.utils.translation import ugettext_lazy as _
23import six
24
25from . import LazyConfig, settings
26from .checks import get_inconsistent_fieldnames
27
28
29config = LazyConfig()
30
31
32NUMERIC_WIDGET = forms.TextInput(attrs={'size': 10})
33
34INTEGER_LIKE = (fields.IntegerField, {'widget': NUMERIC_WIDGET})
35STRING_LIKE = (fields.CharField, {
36    'widget': forms.Textarea(attrs={'rows': 3}),
37    'required': False,
38})
39
40FIELDS = {
41    bool: (fields.BooleanField, {'required': False}),
42    int: INTEGER_LIKE,
43    Decimal: (fields.DecimalField, {'widget': NUMERIC_WIDGET}),
44    str: STRING_LIKE,
45    datetime: (
46        fields.SplitDateTimeField, {'widget': widgets.AdminSplitDateTime}
47    ),
48    timedelta: (
49        fields.DurationField, {'widget': widgets.AdminTextInputWidget}
50    ),
51    date: (fields.DateField, {'widget': widgets.AdminDateWidget}),
52    time: (fields.TimeField, {'widget': widgets.AdminTimeWidget}),
53    float: (fields.FloatField, {'widget': NUMERIC_WIDGET}),
54}
55
56
57def parse_additional_fields(fields):
58    for key in fields:
59        field = list(fields[key])
60
61        if len(field) == 1:
62            field.append({})
63
64        field[0] = import_string(field[0])
65
66        if 'widget' in field[1]:
67            klass = import_string(field[1]['widget'])
68            field[1]['widget'] = klass(
69                **(field[1].get('widget_kwargs', {}) or {})
70            )
71
72            if 'widget_kwargs' in field[1]:
73                del field[1]['widget_kwargs']
74
75        fields[key] = field
76
77    return fields
78
79
80FIELDS.update(parse_additional_fields(settings.ADDITIONAL_FIELDS))
81
82if not six.PY3:
83    FIELDS.update({
84        long: INTEGER_LIKE,
85        unicode: STRING_LIKE,
86    })
87
88
89def get_values():
90    """
91    Get dictionary of values from the backend
92    :return:
93    """
94
95    # First load a mapping between config name and default value
96    default_initial = ((name, options[0])
97                       for name, options in settings.CONFIG.items())
98    # Then update the mapping with actually values from the backend
99    initial = dict(default_initial, **dict(config._backend.mget(settings.CONFIG)))
100
101    return initial
102
103
104class ConstanceForm(forms.Form):
105    version = forms.CharField(widget=forms.HiddenInput)
106
107    def __init__(self, initial, *args, **kwargs):
108        super(ConstanceForm, self).__init__(*args, initial=initial, **kwargs)
109        version_hash = hashlib.md5()
110
111        for name, options in settings.CONFIG.items():
112            default = options[0]
113            if len(options) == 3:
114                config_type = options[2]
115                if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type):
116                    raise ImproperlyConfigured(_("Default value type must be "
117                                                 "equal to declared config "
118                                                 "parameter type. Please fix "
119                                                 "the default value of "
120                                                 "'%(name)s'.")
121                                               % {'name': name})
122            else:
123                config_type = type(default)
124
125            if config_type not in FIELDS:
126                raise ImproperlyConfigured(_("Constance doesn't support "
127                                             "config values of the type "
128                                             "%(config_type)s. Please fix "
129                                             "the value of '%(name)s'.")
130                                           % {'config_type': config_type,
131                                              'name': name})
132            field_class, kwargs = FIELDS[config_type]
133            self.fields[name] = field_class(label=name, **kwargs)
134
135            version_hash.update(smart_bytes(initial.get(name, '')))
136        self.initial['version'] = version_hash.hexdigest()
137
138    def save(self):
139        for file_field in self.files:
140            file = self.cleaned_data[file_field]
141            self.cleaned_data[file_field] = default_storage.save(file.name, file)
142
143        for name in settings.CONFIG:
144            current = getattr(config, name)
145            new = self.cleaned_data[name]
146
147            if conf.settings.USE_TZ and isinstance(current, datetime) and not timezone.is_aware(current):
148                current = timezone.make_aware(current)
149
150            if current != new:
151                setattr(config, name, new)
152
153    def clean_version(self):
154        value = self.cleaned_data['version']
155
156        if settings.IGNORE_ADMIN_VERSION_CHECK:
157            return value
158
159        if value != self.initial['version']:
160            raise forms.ValidationError(_('The settings have been modified '
161                                          'by someone else. Please reload the '
162                                          'form and resubmit your changes.'))
163        return value
164
165    def clean(self):
166        cleaned_data = super(ConstanceForm, self).clean()
167
168        if not settings.CONFIG_FIELDSETS:
169            return cleaned_data
170
171        if get_inconsistent_fieldnames():
172            raise forms.ValidationError(_('CONSTANCE_CONFIG_FIELDSETS is missing '
173                                          'field(s) that exists in CONSTANCE_CONFIG.'))
174
175        return cleaned_data
176
177
178class ConstanceAdmin(admin.ModelAdmin):
179    change_list_template = 'admin/constance/change_list.html'
180    change_list_form = ConstanceForm
181
182    def get_urls(self):
183        info = self.model._meta.app_label, self.model._meta.module_name
184        return [
185            url(r'^$',
186                self.admin_site.admin_view(self.changelist_view),
187                name='%s_%s_changelist' % info),
188            url(r'^$',
189                self.admin_site.admin_view(self.changelist_view),
190                name='%s_%s_add' % info),
191        ]
192
193    def get_config_value(self, name, options, form, initial):
194        default, help_text = options[0], options[1]
195        # First try to load the value from the actual backend
196        value = initial.get(name)
197        # Then if the returned value is None, get the default
198        if value is None:
199            value = getattr(config, name)
200        config_value = {
201            'name': name,
202            'default': localize(default),
203            'raw_default': default,
204            'help_text': _(help_text),
205            'value': localize(value),
206            'modified': localize(value) != localize(default),
207            'form_field': form[name],
208            'is_date': isinstance(default, date),
209            'is_datetime': isinstance(default, datetime),
210            'is_checkbox': isinstance(form[name].field.widget, forms.CheckboxInput),
211            'is_file': isinstance(form[name].field.widget, forms.FileInput),
212        }
213
214        return config_value
215
216    def get_changelist_form(self, request):
217        """
218        Returns a Form class for use in the changelist_view.
219        """
220        # Defaults to self.change_list_form in order to preserve backward
221        # compatibility
222        return self.change_list_form
223
224    @csrf_protect_m
225    def changelist_view(self, request, extra_context=None):
226        if not self.has_change_permission(request, None):
227            raise PermissionDenied
228        initial = get_values()
229        form_cls = self.get_changelist_form(request)
230        form = form_cls(initial=initial)
231        if request.method == 'POST':
232            form = form_cls(
233                data=request.POST, files=request.FILES, initial=initial
234            )
235            if form.is_valid():
236                form.save()
237                messages.add_message(
238                    request,
239                    messages.SUCCESS,
240                    _('Live settings updated successfully.'),
241                )
242                return HttpResponseRedirect('.')
243        context = dict(
244            self.admin_site.each_context(request),
245            config_values=[],
246            title=self.model._meta.app_config.verbose_name,
247            app_label='constance',
248            opts=self.model._meta,
249            form=form,
250            media=self.media + form.media,
251            icon_type='gif' if VERSION < (1, 9) else 'svg',
252        )
253        for name, options in settings.CONFIG.items():
254            context['config_values'].append(
255                self.get_config_value(name, options, form, initial)
256            )
257
258        if settings.CONFIG_FIELDSETS:
259            context['fieldsets'] = []
260            for fieldset_title, fields_list in settings.CONFIG_FIELDSETS.items():
261                absent_fields = [field for field in fields_list
262                                 if field not in settings.CONFIG]
263                assert not any(absent_fields), (
264                    "CONSTANCE_CONFIG_FIELDSETS contains field(s) that does "
265                    "not exist: %s" % ', '.join(absent_fields))
266
267                config_values = []
268
269                for name in fields_list:
270                    options = settings.CONFIG.get(name)
271                    if options:
272                        config_values.append(
273                            self.get_config_value(name, options, form, initial)
274                        )
275
276                context['fieldsets'].append({
277                    'title': fieldset_title,
278                    'config_values': config_values
279                })
280            if not isinstance(settings.CONFIG_FIELDSETS, OrderedDict):
281                context['fieldsets'].sort(key=itemgetter('title'))
282
283        if not isinstance(settings.CONFIG, OrderedDict):
284            context['config_values'].sort(key=itemgetter('name'))
285        request.current_app = self.admin_site.name
286        return TemplateResponse(request, self.change_list_template, context)
287
288    def has_add_permission(self, *args, **kwargs):
289        return False
290
291    def has_delete_permission(self, *args, **kwargs):
292        return False
293
294    def has_change_permission(self, request, obj=None):
295        if settings.SUPERUSER_ONLY:
296            return request.user.is_superuser
297        return super(ConstanceAdmin, self).has_change_permission(request, obj)
298
299
300class Config(object):
301    class Meta(object):
302        app_label = 'constance'
303        object_name = 'Config'
304        model_name = module_name = 'config'
305        verbose_name_plural = _('config')
306        abstract = False
307        swapped = False
308
309        def get_ordered_objects(self):
310            return False
311
312        def get_change_permission(self):
313            return 'change_%s' % self.model_name
314
315        @property
316        def app_config(self):
317            return apps.get_app_config(self.app_label)
318
319        @property
320        def label(self):
321            return '%s.%s' % (self.app_label, self.object_name)
322
323        @property
324        def label_lower(self):
325            return '%s.%s' % (self.app_label, self.model_name)
326
327    _meta = Meta()
328
329
330admin.site.register([Config], ConstanceAdmin)
331