Search code examples
pythondjangodjango-adminmany-to-many

Customize related objects for many-to-many field in Django


There are games entity, each of them could have 1 or more platforms. Also each game could have 1 or more links to related games (with their own platforms). Here it looks like in models.py:

class Game(TimeStampedModel):
    gid = models.CharField(max_length=38, blank=True, null=True)
    name = models.CharField(max_length=512)
    platforms = models.ManyToManyField(
        Platform, blank=True, null=True)
    ...
    #here is the self-referencing m2m field
    related_games = models.ManyToManyField(
        "self", related_name="related", blank=True)

And this model are served with this code in admin.py:

@admin.register(Game)
class GameAdmin(AdminImageMixin, reversion.VersionAdmin):
    list_display = ("created", "name", "get_platforms"... )
    list_filter = ("platforms", "year",)
    #I'm interested in changing the field below
    filter_horizontal = ("related_games",)

    formfield_overrides = {
        models.ManyToManyField: {"widget": CheckboxSelectMultiple},
    }

    def get_platforms(self, obj):
        return ", ".join([p.name for p in obj.platforms.all()])

I need to extend filter_horizontal = ("related_games",) part of admin.py - to add a platform information of each game in related games widget. It should look like (game name and platforms list): "Virtual Fighter (PS4, PSP, PS3)".

The application uses Django 1.7 and Python 2.7

Thank you for your attention.


Solution

  • By default, what is shown for each item in a filter_horizontal is based on the object's __str__ or __unicode__ method, so you could try something like the following:

    class Game(TimeStampedModel):
        # field definitions
        # ...
        def __unicode__(self):
            return '{0} ({1})'.format(
                self.name,
                (', '.join(self.platforms.all()) if self.platforms.exists()
                    else 'none')
            )
    

    This will make each game show in the list (and everywhere else) as "Name (Platforms)", for example "Crash Bandicoot (PS1, PS2)" or "Battlefield (none)" if it doesn't have any platforms

    Alternatively, if you don't want to change the __unicode__ method of your model, you'll need to set your ModelAdmin to use a custom ModelForm, specifying that the related_games field should use a custom ModelMultipleChoiceField with a custom FilteredSelectMultiple widget, in which you will need to override the render_options method. The following classes should be in their respective separate files, but it would look something like:

    # admin.py
    
    class GameAdmin(AdminImageMixin, reversion.VersionAdmin):
        # ...
        form = GameForm
        # ...
    
    
    # forms.py
    
    from django import forms
    
    class GameForm(forms.ModelForm):
        related_games = RelatedGamesField()
    
        class Meta:
            fields = (
                'gid',
                'name',
                'platforms',
                'related_games',
            )
    
    
    # fields.py
    
    from django.forms.models import ModelMultipleChoiceField
    
    class RelatedGamesField(ModelMultipleChoiceField):
        widget = RelatedGamesWidget()
    
    
    # widgets.py
    
    from django.contrib.admin.widgets import FilteredSelectMultiple
    
    class RelatedGamesWidget(FilteredSelectMultiple):
        def render_options(self, choices, selected_choices):
            # slightly modified from Django source code
            selected_choices = set(force_text(v) for v in selected_choices)
            output = []
            for option_value, option_label in chain(self.choices, choices):
                if isinstance(option_label, (list, tuple)):
                    output.append(format_html(
                        '<optgroup label="{0}">',
                        # however you want to have the related games show up, eg.,
                        '{0} ({1})'.format(
                            option_value.name,
                            (', '.join(option_value.platforms.all())
                                if option_value.platforms.exists() else 'none')
                        )
                    ))
                    for option in option_label:
                        output.append(self.render_option(selected_choices, *option))
                    output.append('</optgroup>')
                else:
                    output.append(self.render_option(selected_choices, option_value, option_label))
            return '\n'.join(output)