ic-labs / django-icekit

GLAMkit is a next-generation Python CMS by the Interaction Consortium, designed especially for the cultural sector.
http://glamkit.com
MIT License
47 stars 11 forks source link

Support ManyToMany relationship between publishable models via raw ID field #193

Open bcarberry opened 7 years ago

bcarberry commented 7 years ago

I've created an app called 'publications'. This has a 'publication' model that is publishable (imagine a list of books the museum has published, each item contains full metadata and excerpts from a book).

A publication instance can be related to other publishable content, ie exhibitions, events, or other publications.

When relating a publication instance to an instance of an exhibition or a publication via a raw ID field, I am able to select only a draft foreign key which is good, however after saving the publication instance, I then see that both the draft and the published foreign keys are being related in the raw ID field for related exhibition, or related publication.

From James Murty: unfortunately ICEkit does not yet include a generic, or more easily reused, way of fixing choice fields in the admin for bidirectional ManyToMany relationships between publishable models, to avoid incorrectly showing FKs to published copies. This work is done by SFMOMAEventsAdminForm._filter_m2m_field_qs_to_draft_only in SFMOMA's implementation, and I believe this is the only place the issue has been dealt with so far.

Suggest adding the functionality that currently is in Eventkit as noted above, into Icekit, to easily reuse in publishable models and content items.

jmurty commented 7 years ago

The root problem here is that bidirectional ManyToMany relationships, with publishable models on both ends, need special treatment in the Django admin to show only the IDs of draft copies, never IDs of published copies.

In most cases, hiding the published copies in the admin is relatively simple: you override the get_queryset methods to filter out the published copies.

However, with ManyToMany relationships and the form fields that support them, there is no direct way to control which of the less directly related items should be shown in the admin and which should not.

We solved this for SFMOMA previously (see code snippet below) by:

  1. monkey-patching the affected fields (choice/select fields) to force them to show only IDs for draft related items
  2. carefully handling bidirectional M2M relationships in signal handlers to ensure that even though the relationships to published items get lost in the render-then-submit process for admin forms, they are put back in place when items on either side of the M2M relationship are saved.

We need a more generic and flexible solution for ICEkit Publishing in general, for cases like this. Ideally we would find a better way to filter the items shown in Django admin forms for bidirectional M2M publishable relationships. Or if no better mechanism is available, at least handle more field types than only choice/select fields (e.g. include raw ID fields)

Here is example code showing the monkey-patching workaround for choice fields:

class ExampleAdminForm(form.ModelForm):

    def __init__(self, *args, **kwargs):
        super(ExampleAdminForm, self).__init__(*args, **kwargs)
        # Monkey-patch bidirectional publishable M2M relationship fields
        self._filter_m2m_field_qs_to_draft_only()

    def _filter_m2m_field_qs_to_draft_only(self):
        """
        Alter the queryset and `prepare_value` method for `ModelChoiceField`
        and `ModelMultipleChoiceField` to exclude published objects, if the
        related model is publishable, to avoid listing published copies of
        related items in the admin as choices or as existing relationships.
        """
        def prepare_value__pre_filter_with_queryset(self, value):
            """
            Method to override field's `prepare_value` method to filter out
            PKs not in field's queryset, i.e. PKs to published items.

            This does not cause relationships to published relationships to be
            cleared in the admin because the `handle_publishable_m2m_changed`
            signal handler does extra work to keep them.
            """
            # Only process a list, presumably of PKs
            if value and isinstance(value, (tuple, list)):
                # Valid PKs according to our queryset
                valid_pks = set(self.queryset.values_list('pk', flat=True))
                # Update `value` to be intersection of given and valid values
                value = list(set(value) & valid_pks)
            # Continue processing value as normal, using original method
            return self._original_prepare_value(value)

        # Exclude published objects related via M2M fields.
        for field in self.fields.values():
            # Field is FK or M2M.
            if isinstance(field, (ModelChoiceField, ModelMultipleChoiceField)):
                # Related model is publishable.
                if issubclass(field.queryset.model, PublishingModel):
                    # Exclude published objects from queryset
                    field.queryset = \
                        field.queryset.exclude(publishing_is_draft=False)
                    # Preempt the original `prepare_value` method with our own
                    # pre-processor method to apply the QS filter to values.
                    field._original_prepare_value = field.prepare_value
                    field.prepare_value = \
                        prepare_value__pre_filter_with_queryset.__get__(
                            field, field.__class__)