diff --git a/HISTORY.rst b/HISTORY.rst index 13eb9d46c5..1d7ad3e24e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -52,6 +52,9 @@ * Update middleware to new style classes. * Add server side invalid document template. * Move tag specific JavaScript to the tags app. +* Reduce form boilerplate code with new FormOptions class. +* Use FormOptions for the DetailForm class. +* DetailForm now support help text on extra fields. 3.1.11 (2019-04-XX) =================== diff --git a/docs/releases/3.2.rst b/docs/releases/3.2.rst index c8490ea427..8df3657f49 100644 --- a/docs/releases/3.2.rst +++ b/docs/releases/3.2.rst @@ -76,6 +76,9 @@ Other changes * Update middleware to new style classes. * Add server side invalid document template. * Move tag specific JavaScript to the tags app. +* Reduce form boilerplate code with new FormOptions class. +* Use FormOptions for the DetailForm class. +* DetailForm now support help text on extra fields. Removals -------- diff --git a/mayan/apps/common/forms.py b/mayan/apps/common/forms.py index 03f5d7f171..5fcaaacc5a 100644 --- a/mayan/apps/common/forms.py +++ b/mayan/apps/common/forms.py @@ -4,14 +4,16 @@ import os from django import forms from django.conf import settings +from django.contrib.admin.utils import label_for_field from django.contrib.auth import get_user_model +from django.core.exceptions import FieldDoesNotExist from django.db import models from django.utils.module_loading import import_string from django.utils.translation import ugettext_lazy as _ from .classes import Package from .models import UserLocaleProfile -from .utils import return_attrib +from .utils import introspect_attribute, resolve_attribute from .widgets import DisableableSelectWidget, PlainWidget, TextAreaDiv @@ -37,26 +39,98 @@ class ChoiceForm(forms.Form): selection = forms.MultipleChoiceField(widget=DisableableSelectWidget()) +class FormOptions(object): + def __init__(self, form, kwargs, options=None): + """ + Option definitions will be iterated. The option value will be + determined in the following order: as passed via keyword + arguments during form intialization, as form get_... method or + finally as static Meta options. This is to allow a form with + Meta options or method to be overrided at initialization + and increase the usability of a single class. + """ + for name, default_value in self.option_definitions.items(): + try: + # Check for a runtime value via kwargs + value = kwargs.pop(name) + except KeyError: + try: + # Check if there is a get_... method + value = getattr(self, 'get_{}'.format(name))() + except AttributeError: + try: + # Check the meta class options + value = getattr(options, name) + except AttributeError: + value = default_value + + setattr(self, name, value) + + +class DetailFormOption(FormOptions): + # Dictionary list of option names and default values + option_definitions = {'extra_fields': []} + + class DetailForm(forms.ModelForm): def __init__(self, *args, **kwargs): - self.extra_fields = kwargs.pop('extra_fields', ()) + self.opts = DetailFormOption( + form=self, kwargs=kwargs, options=getattr(self, 'Meta', None) + ) super(DetailForm, self).__init__(*args, **kwargs) - for extra_field in self.extra_fields: - result = return_attrib(self.instance, extra_field['field']) - label = 'label' in extra_field and extra_field['label'] or None + for extra_field in self.opts.extra_fields: + obj = extra_field.get('object', self.instance) + field = extra_field['field'] + + result = resolve_attribute( + attribute=field, obj=obj + ) + + label = extra_field.get('label', None) + + # If label is not specified try to get it from the object itself + if not label: + attribute_name, obj = introspect_attribute( + attribute_name=field, obj=obj + ) + + if not obj: + label = _('None') + else: + try: + label = getattr( + getattr(obj, attribute_name), 'short_description' + ) + except AttributeError: + label = label_for_field( + name=attribute_name, model=obj + ) + + help_text = extra_field.get('help_text', None) + # If help_text is not specified try to get it from the object itself + if not help_text: + if obj: + try: + field_object = obj._meta.get_field(field_name=field) + except FieldDoesNotExist: + field_object = field + + help_text = getattr( + field_object, 'help_text', None + ) + # TODO: Add others result types <=> Field types if isinstance(result, models.query.QuerySet): - self.fields[extra_field['field']] = \ - forms.ModelMultipleChoiceField( - queryset=result, label=label) + self.fields[field] = forms.ModelMultipleChoiceField( + queryset=result, label=label + ) else: - self.fields[extra_field['field']] = forms.CharField( - label=extra_field['label'], - initial=return_attrib( - self.instance, - extra_field['field'], None - ), + self.fields[field] = forms.CharField( + initial=resolve_attribute( + obj=obj, + attribute=field + ), label=label, help_text=help_text, widget=extra_field.get('widget', PlainWidget) ) diff --git a/mayan/apps/common/utils.py b/mayan/apps/common/utils.py index df53507ed9..d94081cf0c 100644 --- a/mayan/apps/common/utils.py +++ b/mayan/apps/common/utils.py @@ -4,6 +4,8 @@ import logging import types from django.conf import settings +from django.core.exceptions import FieldDoesNotExist +from django.db.models.constants import LOOKUP_SEP from django.urls import resolve as django_resolve from django.urls.base import get_script_prefix from django.utils.datastructures import MultiValueDict @@ -51,11 +53,78 @@ def get_user_label_text(context): return context['request'].user.get_full_name() or context['request'].user +def introspect_attribute(attribute_name, obj): + """ + Resolve the attribute of model. Supports nested reference using dotted + paths or double underscore. + """ + try: + # Try as a related field + obj._meta.get_field(field_name=attribute_name) + except (AttributeError, FieldDoesNotExist): + attribute_name = attribute_name.replace('__', '.') + + try: + # If there are separators in the attribute name, traverse them + # to the final attribute + attribute_part, attribute_remaining = attribute_name.split( + '.', 1 + ) + except ValueError: + return attribute_name, obj + else: + related_field = obj._meta.get_field(field_name=attribute_part) + return introspect_attribute( + attribute_name=attribute_part, + obj=related_field.related_model, + ) + else: + return attribute_name, obj + + def resolve(path, urlconf=None): path = '/{}'.format(path.replace(get_script_prefix(), '', 1)) return django_resolve(path=path, urlconf=urlconf) +def resolve_attribute(attribute, obj, kwargs=None): + """ + Resolve the attribute of an object. Behaves like the Python REPL but with + an unified dotted path schema regardless of the attribute type. + Supports callables, dictionaries, properties, related model fields. + """ + if not kwargs: + kwargs = {} + + # Try as a callable + try: + return attribute(obj, **kwargs) + except TypeError: + # Try as a dictionary + try: + return obj[attribute] + except TypeError: + try: + # If there are dots in the attribute name, traverse them + # to the final attribute + result = reduce_function(getattr, attribute.split('.'), obj) + try: + # Try it as a method + return result(**kwargs) + except TypeError: + # Try it as a property + return result + except AttributeError: + # Try as a related model field + if LOOKUP_SEP in attribute: + attribute_replaced = attribute.replace(LOOKUP_SEP, '.') + return resolve_attribute( + obj=obj, attribute=attribute_replaced, kwargs=kwargs + ) + else: + raise + + def return_attrib(obj, attrib, arguments=None): if isinstance(attrib, types.FunctionType): return attrib(obj)