Source code for superdjango.ui.views.bulk

from django import forms
from django.http import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
from superdjango.assets import StyleSheet, JavaScript
from superdjango.html.library import Table as HTMLTable
from ..constants import VERB
from .crud import UIModelView
from .forms import UIFormMixin, UIFormSetMixin

__all__ = (
    "UIBatchChangeView",
    "UIBulkCompareView",
    "UIBulkDeleteView",
    "UIBulkEditView",
)


# TODO: Implement UIBulkTrashView to support TrashModel. Can re-use bulk delete with some modifications.

# TODO: Implement UIBulkEditView using compare view as starting point.

# https://docs.djangoproject.com/en/2.1/topics/forms/formsets/

# http://kevindias.com/writing/django-class-based-views-multiple-inline-formsets/
# https://whoisnicoleharris.com/2015/01/06/implementing-django-formsets.html
# http://blog.mattwoodward.com/2015/02/dynamically-adding-forms-to-and.html
# https://medium.com/@adandan01/django-inline-formsets-example-mybook-420cc4b6225d

# TODO: Consider UIBulkCreateView that allows a batch of records to be created all at once.

# UIImportView (becomes additional option for list view actions)
# UIExportView (becomes additional option for list view actions)

class UIBatchChangeForm(forms.Form):
    """Supports the UIBatchChangeView."""
    # TODO: Since the original intent didn't work out, is UIBatchChangeForm really needed?
    pass


# Views


class UIBatchChangeView(UIFormMixin, UIModelView):
    """Change the value of one or more fields cross selected objects."""

    # TODO: UIBatchChangeView needs to somehow support a "no change" option when multiple fields have been configured.
    # For example, a dropdown with an empty value of "no change". Maybe still use ui.get_form_field() but wrapped in a
    # local get_form_field() which intelligently adds an empty value to dropdowns, converts booleans to null booleans,
    # and so on. We might also disallow char fields.

    after_items_message = None
    before_items_message = None
    changed_count = 0

    confirmed_keyword = "confirmed"
    """The POST key used to indicate whether the change is confirmed. This must be present in order to continue with 
    the update operation.
    """

    form = None
    selected_objects = list()

    # noinspection PyUnusedLocal
    def get(self, request, *args, **kwargs):
        """Handle GET requests. Overridden to prevent form handling."""
        context = self.get_context_data()
        return self.render_to_response(context)

    def get_after_items_message(self):
        """Get the message to be displayed after the list of items to be changed.

        :rtype: str

        """
        context = self._get_message_context()
        return self.after_items_message % context

    def get_before_items_message(self):
        """Get the message to be displayed before the list of items to be changed.

        :rtype: str

        """
        context = self._get_message_context()
        return self.before_items_message % context

    # def get_breadcrumbs(self):
    #     """Get breadcrumbs for batch change."""
    #     crumbs = super().get_breadcrumbs()
    #     crumbs.add(self.get_subtitle(), "")
    #
    #     return crumbs

    def get_context_data(self, **kwargs):
        """Add ``selected_objects`` to the context."""
        context = super().get_context_data(**kwargs)

        context['after_items_message'] = self.get_after_items_message()
        context['before_items_message'] = self.get_before_items_message()
        context['confirmed_keyword'] = self.confirmed_keyword
        context['selected_objects'] = self.selected_objects

        return context

    def get_css(self):
        """Batch change supplies only empty CSS configuration."""
        return StyleSheet()

    def get_form(self, request):
        fields = dict()
        for name in self.fields:
            model_field = self.ui.meta.get_field(name)
            field = self.ui.get_form_field(model_field, request, required=False)
            fields[name] = field

        form_class = type("BatchChangeForm", (UIBatchChangeForm,), fields)
        return form_class(data=request.POST)

    def get_js(self):
        """Batch change supplies only empty JavScript configuration."""
        return JavaScript()

    def get_selected_objects(self):
        """Get the objects that have been selected for comparison.

        :rtype: list

        """
        lookup_field = self.ui.get_lookup_field()
        selected_objects = list()
        for i in self.request.POST.getlist("selected_objects"):
            if lookup_field == "pk":
                i = int(i)

            criteria = {lookup_field: i}

            try:
                record = self.ui.model.objects.get(**criteria)
                selected_objects.append(record)
            except self.ui.model.DoesNotExist:
                pass

        return selected_objects

    def get_verb(self):
        return VERB.BATCH_CHANGE

    # noinspection PyUnusedLocal
    def post(self, request, *args, **kwargs):
        """Handle POST by calling the ``submit()`` method.

        When ``confirmed_keyword`` is defined, this is checked prior to calling ``submit()``. This allows data to be
        posted to the view *without* the confirmation in order to re-use the view for confirming additional action.

        """
        form = self.get_form(request)

        self.selected_objects = self.get_selected_objects()

        confirmed = request.POST.get(self.confirmed_keyword, None)
        if confirmed and form.is_valid():
            self.submit(form, request)
            return HttpResponseRedirect(self.get_success_url())

        context = self.get_context_data(form=form)
        return self.render_to_response(context)

    # noinspection PyUnusedLocal
    def submit(self, form, request):
        """Update the model instances."""
        objects = self.get_selected_objects()
        for o in objects:

            self.ui.save_history(o, request, self.get_verb())

            for field in self.fields:
                new_value = form.cleaned_data[field]
                current_value = getattr(o, field)
                if new_value and new_value != current_value:
                    setattr(o, field, new_value)

            if self.ui.is_audit_model():
                o.audit(request.user)
            else:
                o.save()

            self.changed_count += 1

        self.messages.success(self.get_success_message())

    def _get_message_context(self):
        context = super()._get_message_context()
        context['count'] = self.changed_count
        return context


class UIBulkCompareView(UIModelView):
    """Compare two or more objects with one another.

    The ``fields`` property is used to determine which fields are compared.

    """
    row_actions = None
    row_actions_label = None

    # def get_breadcrumbs(self):
    #     """Get breadcrumbs for multiple object comparison."""
    #     crumbs = super().get_breadcrumbs()
    #     crumbs.add(self.get_subtitle(), "")
    #
    #     return crumbs

    def get_context_data(self, **kwargs):
        """Add ``selected_objects`` to the context."""
        context = super().get_context_data(**kwargs)

        # Get the selected records from POST.
        selected_objects = self.get_selected_objects()
        context['selected_objects'] = selected_objects
        context['total_selected_objects'] = len(selected_objects)

        # Build the table columns.
        table = HTMLTable()
        table.column("compare_field", label=_("Field"))
        column_ids = list()
        for row in selected_objects:
            column_ids.append(row.pk)
            table.column("compare_%s" % row.pk, label=self.get_display_name(row))

        # Add the fields to each data row.
        controls = self.get_controls()
        for control in controls:
            data = list()
            data.append(control)

            for row in selected_objects:
                value = control.get_datum(row, self.request)
                data.append(value)

            table.row(data)

        # Row actions, if present are added directly to the table.
        if self.row_actions is not None:
            table.actions = list()
            table.row_actions_label = self.row_actions_label

            for row in selected_objects:
                actions = list()
                for action in self.row_actions:
                    _action = self.ui.get_action(self.request, action, record=row)
                    if _action is not None:
                        actions.append(_action)

                table.actions.append(actions)

        # Add the finished table to the context.
        context['table'] = table

        # Support for history.
        for row in selected_objects:
            self.ui.save_history(None, row, self.request, self.get_verb())

        return context

    def get_selected_objects(self):
        """Get the objects that have been selected for comparison.

        :rtype: list

        """
        lookup_field = self.ui.get_lookup_field()
        selected_objects = list()
        for i in self.request.POST.getlist("selected_objects"):
            if lookup_field == "pk":
                i = int(i)

            criteria = {lookup_field: i}

            try:
                record = self.ui.model.objects.get(**criteria)
                selected_objects.append(record)
            except self.ui.model.DoesNotExist:
                pass

        return selected_objects

    def get_title(self):
        """Get only the model's verbose name."""
        return self.ui.get_verbose_name()

    def get_verb(self):
        return VERB.BULK_COMPARE

    # noinspection PyUnusedLocal
    def post(self, request, *args, **kwargs):
        """Handle POSTed objects for comparison."""
        context = self.get_context_data()
        return self.render_to_response(context)


class UIBulkDeleteView(UIFormMixin, UIModelView):

    after_items_message = None
    before_items_message = None

    confirmed_keyword = "confirmed"
    """The POST key used to indicate whether the deletion is confirmed. This must be present in order to continue with 
    the delete operation.
    """

    deleted_count = 0
    selected_objects = list()

    # noinspection PyUnusedLocal
    def get(self, request, *args, **kwargs):
        """Handle GET requests. Overridden to prevent form handling."""
        context = self.get_context_data()
        return self.render_to_response(context)

    def get_after_items_message(self):
        """Get the message to be displayed after the list of items to be deleted.

        :rtype: str

        """
        context = self._get_message_context()
        return self.after_items_message % context

    def get_before_items_message(self):
        """Get the message to be displayed before the list of items to be deleted.

        :rtype: str

        """
        context = self._get_message_context()
        return self.before_items_message % context

    # def get_breadcrumbs(self):
    #     """Get breadcrumbs for multiple object comparison."""
    #     crumbs = super().get_breadcrumbs()
    #     crumbs.add(self.get_subtitle(), "")
    #
    #     return crumbs

    def get_context_data(self, **kwargs):
        """Add ``selected_objects`` to the context."""
        context = super().get_context_data(**kwargs)

        context['after_items_message'] = self.get_after_items_message()
        context['before_items_message'] = self.get_before_items_message()
        context['confirmed_keyword'] = self.confirmed_keyword
        context['selected_objects'] = self.selected_objects

        return context

    def get_css(self):
        """Bulk delete provides no CSS configuration."""
        return StyleSheet()

    def get_js(self):
        """Bulk delete provides no JavaScript configuration."""
        return JavaScript()

    def get_selected_objects(self):
        """Get the objects that have been selected for delete.

        :rtype: list

        """
        selected_objects = list()

        lookup_field = self.ui.get_lookup_field()

        for i in self.request.POST.getlist("selected_objects"):
            if lookup_field == "pk":
                i = int(i)

            criteria = {lookup_field: i}

            try:
                record = self.ui.model.objects.get(**criteria)
                selected_objects.append(record)
            except self.ui.model.DoesNotExist:
                pass

        return selected_objects

    def get_verb(self):
        return VERB.BULK_DELETE

    # noinspection PyUnusedLocal
    def post(self, request, *args, **kwargs):
        """Handle POST by calling the ``submit()`` method.

        ``confirmed_keyword`` is checked prior to calling ``submit()``. This allows data to be posted to the view
        *without* the confirmation in order to re-use the view for confirming additional action.

        """
        confirmed = request.POST.get(self.confirmed_keyword, None)
        if confirmed:
            self.submit(request)
            return HttpResponseRedirect(self.get_success_url())

        self.selected_objects = self.get_selected_objects()

        context = self.get_context_data()
        return self.render_to_response(context)

    # noinspection PyUnusedLocal
    def submit(self, request):
        """Delete the model instances."""
        objects = self.get_selected_objects()
        for o in objects:

            self.ui.save_history(o, self.request, self.get_verb())

            o.delete()
            self.deleted_count += 1

        self.messages.success(self.get_success_message())

    def _get_message_context(self):
        context = super()._get_message_context()
        context['count'] = self.deleted_count
        return context


class UIBulkEditView(UIFormSetMixin, UIModelView):
    pass