Ordered ManyToManyField that can be used in fieldsets
Asked Answered
M

1

9

I've been working through an ordered ManyToManyField widget, and have the front-end aspect of it working nicely:

alt text

Unfortunately, I'm having a great deal of trouble getting the backend working. The obvious way to hook up the backend is to use a through table keyed off a model with ForeignKeys to both sides of the relationship and overwrite the save method. This would work great, except that due to idiosyncrasies of the content, it is an absolute requirement that this widget be placed in a fieldset (using the ModelAdmin fieldsets property), which is apparently not possible.

I'm out of ideas. Any suggestions?

Thanks!

Marvelofperu answered 6/7, 2010 at 22:37 Comment(4)
+1 for using the word 'idiosyncrasies' in your question. Cool idea. I'm giving it some thought.Arnold
Can you tell us why your using a 'through' model instead of a stock many to many relation? what extra metadata are you storing in this relation? The problem I see with having this feature implemented in default admin is that there is currently no way to add the extra metadata in the through relation simply by choosing elements form a list as your widget implies.Timbuktu
Thomas: the ordering data would be the additional field, giving the "through" model three fields: a ForeignKey to each side of the M2M and a PositiveIntegerField representing order, with all three fields being unique_together.Sendal
I know it's sort of late but would you accept to share that front end code ?Nolasco
E
9

In regard to how to set up the models, you're right in that a through table with an "order" column is the ideal way to represent it. You're also right in that Django will not let you refer to that relationship in a fieldset. The trick to cracking this problem is to remember that the field names you specify in the "fieldsets" or "fields" of a ModelAdmin do not actually refer to the fields of the Model, but to the fields of the ModelForm, which we are free to override to our heart's delight. With many2many fields, this gets tricky, but bear with me:

Let's say you're trying to represent contests and competitors that compete in them, with an ordered many2many between contests and competitors where the order represents the competitors' ranking in that contest. Your models.py would then look like this:

from django.db import models

class Contest(models.Model):
    name = models.CharField(max_length=50)
    # More fields here, if you like.
    contestants = models.ManyToManyField('Contestant', through='ContestResults')

class Contestant(models.Model):
    name = models.CharField(max_length=50)

class ContestResults(models.Model):
    contest = models.ForeignKey(Contest)
    contestant = models.ForeignKey(Contestant)
    rank = models.IntegerField()

Hopefully, this is similar to what you're dealing with. Now, for the admin. I've written an example admin.py with plenty of comments to explain what's happening, but here's a summary to help you along:

Since I don't have the code to the ordered m2m widget you've written, I've used a placeholder dummy widget that simply inherits from TextInput. The input holds a comma-separated list (without spaces) of contestant IDs, and the order of their appearance in the string determines the value of their "rank" column in the ContestResults model.

What happens is that we override the default ModelForm for Contest with our own, and then define a "results" field inside it (we can't call the field "contestants", since there would be a name conflict with the m2m field in the model). We then override __init__(), which is called when the form is displayed in the admin, so we can fetch any ContestResults that may have already been defined for the Contest, and use them to populate the widget. We also override save(), so that we can in turn get the data from the widget and create the needed ContestResults.

Note that for the sake of simplicity this example omits things like validation of the data from the widget, so things will break if you try to type in anything unexpected in the text input. Also, the code for creating the ContestResults is quite simplistic, and could be greatly improved upon.

I should also add that I've actually ran this code and verified that it works.

from django import forms
from django.contrib import admin
from models import Contest, Contestant, ContestResults

# Generates a function that sequentially calls the two functions that were
# passed to it
def func_concat(old_func, new_func):
    def function():
        old_func()
        new_func()
    return function

# A dummy widget to be replaced with your own.
class OrderedManyToManyWidget(forms.widgets.TextInput):
    pass

# A simple CharField that shows a comma-separated list of contestant IDs.
class ResultsField(forms.CharField):
    widget = OrderedManyToManyWidget()

class ContestAdminForm(forms.models.ModelForm):
    # Any fields declared here can be referred to in the "fieldsets" or
    # "fields" of the ModelAdmin. It is crucial that our custom field does not
    # use the same name as the m2m field field in the model ("contestants" in
    # our example).
    results = ResultsField()

    # Be sure to specify your model here.
    class Meta:
        model = Contest

    # Override init so we can populate the form field with the existing data.
    def __init__(self, *args, **kwargs):
        instance = kwargs.get('instance', None)
        # See if we are editing an existing Contest. If not, there is nothing
        # to be done.
        if instance and instance.pk:
            # Get a list of all the IDs of the contestants already specified
            # for this contest.
            contestants = ContestResults.objects.filter(contest=instance).order_by('rank').values_list('contestant_id', flat=True)
            # Make them into a comma-separated string, and put them in our
            # custom field.
            self.base_fields['results'].initial = ','.join(map(str, contestants))
            # Depending on how you've written your widget, you can pass things
            # like a list of available contestants to it here, if necessary.
        super(ContestAdminForm, self).__init__(*args, **kwargs)

    def save(self, *args, **kwargs):
        # This "commit" business complicates things somewhat. When true, it 
        # means that the model instance will actually be saved and all is
        # good. When false, save() returns an unsaved instance of the model.
        # When save() calls are made by the Django admin, commit is pretty
        # much invariably false, though I'm not sure why. This is a problem
        # because when creating a new Contest instance, it needs to have been
        # saved in the DB and have a PK, before we can create ContestResults.
        # Fortunately, all models have a built-in method called save_m2m()
        # which will always be executed after save(), and we can append our
        # ContestResults-creating code to the existing same_m2m() method.
        commit = kwargs.get('commit', True)
        # Save the Contest and get an instance of the saved model
        instance = super(ContestAdminForm, self).save(*args, **kwargs)
        # This is known as a lexical closure, which means that if we store
        # this function and execute it later on, it will execute in the same
        # context (i.e. it will have access to the current instance and self).
        def save_m2m():
            # This is really naive code and should be improved upon,
            # especially in terms of validation, but the basic gist is to make
            # the needed ContestResults. For now, we'll just delete any
            # existing ContestResults for this Contest and create them anew.
            ContestResults.objects.filter(contest=instance).delete()
            # Make a list of (rank, contestant ID) tuples from the comma-
            # -separated list of contestant IDs we get from the results field.
            formdata = enumerate(map(int, self.cleaned_data['results'].split(',')), 1)
            for rank, contestant in formdata:
                ContestResults.objects.create(contest=instance, contestant_id=contestant, rank=rank)
        if commit:
            # If we're committing (fat chance), simply run the closure.
            save_m2m()
        else:
            # Using a function concatenator, ensure our save_m2m closure is
            # called after the existing save_m2m function (which will be
            # called later on if commit is False).
            self.save_m2m = func_concat(self.save_m2m, save_m2m)
        # Return the instance like a good save() method.
        return instance

class ContestAdmin(admin.ModelAdmin):
    # The precious fieldsets.
    fieldsets = (
        ('Basic Info', {
            'fields': ('name', 'results',)
        }),)
    # Here's where we override our form
    form = ContestAdminForm

admin.site.register(Contest, ContestAdmin)

In case you're wondering, I had ran into this problem myself on a project I've been working on, so most of this code comes from that project. I hope you find it useful.

Edythedythe answered 13/7, 2010 at 11:21 Comment(5)
I appreciate the thoroughness and thoughtfulness of the response, thank you! I'll dig in to this today and let you know how it turns out.Sendal
I'm very close to getting this to work, thank you! The big key was the pointer that fieldsets actually referred to fields on a form, not on a model. The one problem I'm running into is that setting ModelForm.base_fields[field].initial doesn't seem to take; the data in the field is still populated by the value in the database (unlike your case, I'll actually be overwriting the initial value of a real m2m field; that's seemingly an important distinction. Any suggestions on how I might accomplish that? Related django-users post, bit.ly/a7R3aoSendal
Ah, I think I've run into this before. In some forms, instead of ModelForm.base_fields[field].initial, you need to set ModelForm.initial[field]. Can you try that and let me know how that goes?Edythedythe
Ran into the same issue while trying to add a BooleanField that didn't match any Field in the model, but corresponded to a custom saving option. Thanks. <offtopic> One thing I don't understand is why django needs to be so complex to accomplish those kind of things. I really wonder if sometimes python makes things really messy </offtopic>Guggle
After bashing my head against something like this for a while, this made my day when it worked. Thanks.Hiddenite

© 2022 - 2024 — McMap. All rights reserved.