Source code for trionyx.forms

"""
trionyx.forms
~~~~~~~~~~~~~

Core forms for Trionyx

:copyright: 2018 by Maikel Martens
:license: GPLv3
"""
import logging
import inspect
from collections import defaultdict
from typing import List, Optional, Dict

from django.forms import *  # noqa F403
from django.forms import ModelForm as DjangoModelForm
from django.db.models.fields import NOT_PROVIDED
from django.db import transaction
from django.conf import settings
from django.urls import reverse

from trionyx.config import models_config
from trionyx import utils

logger = logging.getLogger(__name__)

TX_MODEL_OVERWRITES_REVERSE = {value.lower(): key.lower() for key, value in settings.TX_MODEL_OVERWRITES.items()}


class ModelAjaxChoiceField(ModelChoiceField):
    """
    Ajax model choice field, for Trionyx models.

    Note: This will not work if this is used with a form that is displayed multiple times on a page.
    """

    registered_fields: Dict[str, ModelChoiceField] = {}

    def __init__(self, queryset, *args, **kwargs):
        """Init"""
        self.field_id = utils.random_string(16)
        self.registered_fields[self.field_id] = self
        super().__init__(queryset, *args, **kwargs)

    @property
    def choices(self):
        """Get active choices"""
        from django.utils.functional import lazy
        # Lazy function is used, because first call to choices if before `prepare_value`
        return lazy(self.lazy_choices, list)()

    def lazy_choices(self):
        """Lazy choices, is used to get active choices"""
        value = utils.get_local_data(f'choices-{self.field_id}')
        return self.queryset.filter(id=value).values_list('id', 'verbose_name') if value else []

    def prepare_value(self, value):
        """Prepare value and store value in local data"""
        utils.set_local_data(f'choices-{self.field_id}', value)
        return super().prepare_value(value)

    def widget_attrs(self, widget):
        """Set select2 widget data"""
        return {
            'data-ajax-url': reverse('trionyx:form-choices', kwargs={'id': self.field_id}),
        }


class ModelAjaxMultipleChoiceField(ModelMultipleChoiceField):
    """
    Ajax model multiple choice field, for Trionyx models.

    Note: This will not work if this is used with a form that is displayed multiple times on a page.
    """

    def __init__(self, queryset, *args, **kwargs):
        """Init"""
        self.field_id = utils.random_string(16)
        ModelAjaxChoiceField.registered_fields[self.field_id] = self
        super().__init__(queryset, *args, **kwargs)

    @property
    def choices(self):
        """Get active choices"""
        from django.utils.functional import lazy
        return lazy(self.lazy_choices, list)()

    def lazy_choices(self):
        """Get active choices"""
        values = utils.get_local_data(f'choices-{self.field_id}')
        return [(v.id, v.verbose_name) for v in values] if values else []

    def prepare_value(self, value):
        """Prepare value"""
        utils.set_local_data(f'choices-{self.field_id}', value)
        return super().prepare_value(value)

    def widget_attrs(self, widget):
        """Set select2 widget data"""
        return {
            'data-ajax-url': reverse('trionyx:form-choices', kwargs={'id': self.field_id}),
        }


class ModelForm(DjangoModelForm):  # type: ignore
    """Trionyx ModelForm"""

    css_files: List[str] = []
    """CSS files that need to be loaded"""

    js_files: List[str] = []
    """JS files that need to be loaded"""

    def get_inline_forms(self):
        """Get inline forms"""
        if not hasattr(self, '__inline_forms'):
            self.__inline_forms = {}
            for key, options in getattr(self, 'inline_forms', {}).items():
                kwargs = {
                    'prefix': key,
                }

                fk_name = options.get('fk_name', 'instance')
                if fk_name == 'instance':
                    kwargs['instance'] = self.instance
                else:
                    kwargs['instance'] = getattr(self.instance, key, None)
                    kwargs['initial'] = {
                        fk_name: self.instance
                    }

                if 'queryset' in options:
                    self.__inline_forms[key] = options['form'](
                        self.data if self.data else None,
                        queryset=options['queryset'],
                        **kwargs
                    )
                else:
                    self.__inline_forms[key] = options['form'](self.data if self.data else None, **kwargs)

        return self.__inline_forms

    def is_valid(self):
        """Check if form and inline forms are valid"""
        valid = super().is_valid()

        for key, form in self.get_inline_forms().items():
            valid = valid and form.is_valid()

        return valid

    def save(self, commit=True):
        """Save form and inline forms"""
        object_updated = False
        config = models_config.get_config(self._meta.model)
        fields = [field.name for field in config.get_fields()]
        with transaction.atomic():
            obj = super().save(commit)

            for key, form in self.get_inline_forms().items():
                form.is_valid()  # Make sure cleaned_data is filled
                fk_name = self.inline_forms[key].get('fk_name', 'instance')
                if fk_name == 'instance':
                    logger.debug('Save inline form {} as FormSet, commit: {}'.format(key, commit))
                    form.instance = obj
                    form.save(commit)
                else:
                    logger.debug('Save inline form {} as Form, commit: {}'.format(key, commit))
                    inline_obj = form.save(False)
                    if inline_obj:
                        setattr(inline_obj, fk_name, obj)
                        if commit:
                            inline_obj.save()

                        if key in fields:
                            logger.debug(' * Set inline form object on parent form as: {}'.format(key))
                            setattr(obj, key, inline_obj)
                            object_updated = True

        if object_updated and commit:
            obj.save()
        return obj


class Wysiwyg(CharField):
    """Wysiwyg summernote form field"""

    def __init__(self, *args, **kwargs):
        """Init field"""
        if not kwargs.get('widget'):
            kwargs['widget'] = Textarea(attrs={'class': 'summernote'})
        super().__init__(*args, **kwargs)


class FormRegister:
    """Class where forms can be registered"""

    def __init__(self):
        """Init"""
        self.forms = defaultdict(dict)

    def register(
        self, code: Optional[str] = None, model_alias: Optional[str] = None,
        default_create: Optional[bool] = False, default_edit: Optional[bool] = False, minimal: Optional[bool] = False,
        create_permission: Optional[str] = None, edit_permission: Optional[str] = None
    ):
        """Register form for given model_alias,
        if no model_alias is given the Meta.model is used to generate the model alias.

        :param str code: Code to identify form
        :param str model_alias: Alias for a model (if not provided the Meta.model is used)
        :param bool default_create: Use this form for create
        :param bool default_edit: Use this form for editing
        :param bool minimal: Use this form for minimal create

        .. code-block:: python

            # <app>/forms.py
            from trionyx import forms

            @forms.register(default_create=True, default_edit=True)
            class UserForm(forms.ModelForm):

                class Meta:
                    model = User

        """
        def wrapper(form):
            form_code = code if code else form.__name__.lower()
            model_name = self.get_model_alias(model_alias if model_alias else form.Meta.model, False)

            if form_code in self.forms[model_name]:
                raise ValueError("Form {} already registered for model {}".format(code, model_name))

            self.forms[model_name][form_code] = {
                'form': form,
                'default_create': default_create,
                'default_edit': default_edit,
                'minimal': minimal,
                'create_permission': create_permission,
                'edit_permission': edit_permission,
            }
            return form

        return wrapper

    def get_model_alias(self, model_alias, rewrite=True):
        """Get model alias if class then convert to alias string"""
        from trionyx.models import Model
        if inspect.isclass(model_alias) and issubclass(model_alias, Model):
            if rewrite:
                models_config.get_model_name(models_config.get_config(model_alias).model)
            return models_config.get_model_name(model_alias)
        return model_alias

    def get_create_permission(self, model, code=None):
        """Get create permission for form"""
        return self.forms.get(self.get_model_alias(model), {}).get(code, {}).get('create_permission')

    def get_edit_permission(self, model, code=None):
        """Get edit permission for form"""
        return self.forms.get(self.get_model_alias(model), {}).get(code, {}).get('edit_permission')

    def get_form(self, model, code):
        """Get form based on code"""
        return self.forms.get(self.get_model_alias(model), dict()).get(code)['form']

    def get_all_forms(self, model):
        """Get all forms for model"""
        return [config['form'] for code, config in self.forms.get(self.get_model_alias(model), dict()).items()]

    def get_create_form(self, model):
        """Get default create form"""
        form = self._get_model_form_by_config(model, 'default_create')
        return form if form else self._create_form(model)

    def get_create_minimal_form(self, model):
        """Get default minimal create form"""
        form = self._get_model_form_by_config(model, 'minimal')
        return form if form else self._create_form(model, True)

    def get_edit_form(self, model):
        """Get default edit form"""
        form = self._get_model_form_by_config(model, 'default_edit')
        return form if form else self._create_form(model)

    def _get_model_form_by_config(self, model, config):
        """Get default form"""
        model_alias = self.get_model_alias(model)
        for _, form in self.forms.get(model_alias, dict()).items():
            if form[config]:
                return form['form']

        # Check for original form
        if model_alias in TX_MODEL_OVERWRITES_REVERSE:
            model_alias = TX_MODEL_OVERWRITES_REVERSE.get(model_alias)
            for _, form in self.forms.get(model_alias, dict()).items():
                if form[config]:
                    return form['form']
        return None

    def _create_form(self, model, only_required=False):
        """Create form from model"""
        def use_field(field):
            if not only_required:
                return True
            return field.default == NOT_PROVIDED

        config = models_config.get_config(model)
        return modelform_factory(model, fields=[f.name for f in config.get_fields() if use_field(f)])


form_register = FormRegister()
register = form_register.register