How to best manage Forms with many-to-many Model Fields

I am implementing my first model requiring a many-to-many field. It’s proving to be the adventure I expected. Writing the post to get feedback on my approach and clarify details on how Django manages these model objects.

Here is the key field declaration:

speciesInstances  = models.ManyToManyField (SpeciesInstance, related_name='species_maintenance_groups')

I have learned that speciesInstances cannot be accessed until the object is saved in the db. This led me to puzzle over how to best manage the Model Form for this field (editable, hidden, or read-only) and its associated validation. Excluding fields leads to skipping those fields with validation, while setting the field as disabled by overriding init in the model declaration leads to ‘immutable object’ errors accessing the parent object.

def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._meta.fields.fields['speciesInstances'].disabled = True

I could possibly set the field to read-only (editable=False) but that also applies to the admin page which I don’t want.

speciesInstances  = models.ManyToManyField(SpeciesInstance, editable=False, related_name='species_maintenance_groups')

So currently I am simply excluding this field from the associated Model Form and accepting that any validation checks and assignments to this field require custom code prior to saving to the db. Next up is implementing a custom query to narrow the set of speciesInstance objects suitable for editing (it’s very small compared to the very large .all() set) and figuring out the custom form to support user addition or removal from speciesInstances.

As I’m still a bit of a newbie I’m asking for feedback on my approach and any corrections to my assumptions on how things work.

Thanks!

Where and how specifically are you encountering an error with this?

If you’re just in the process of creating a new instance of this model, then using the default save method of the form should work.

This implies to me that you might be doing something different or unusual in your view / form handling, so it would be beneficial to see the complete view and form.

If I enable the override of init to set speciesInstances to readonly I get the following AttributeError if I enable overriding the model init to set the field disabled:

'ImmutableList' object has no attribute 'fields'

Here is the view code:

def createSpeciesMaintenanceLog (request, pk):
    speciesInstance = SpeciesInstance.objects.get(id=pk)
    species = speciesInstance.species
    name = speciesInstance.name + " - species maintenance collaboration"
    form = SpeciesMaintenanceLogForm(initial={'species':species, 'name':name} )
    if (request.method == 'POST'):
        form = SpeciesMaintenanceLogForm(request.POST)
        if form.is_valid():
            speciesMaintenanceLog = form.save()
            speciesMaintenanceLog.speciesInstances.add (speciesInstance)
            speciesMaintenanceLog.save()
            return HttpResponseRedirect(reverse("speciesMaintenanceLog", args=[speciesMaintenanceLog.id]))
    context = {'form': form, 'speciesInstance': speciesInstance}
    return render (request, 'species/createSpeciesMaintenanceLog.html', context)

and here is the form:

class SpeciesMaintenanceLogEntryForm (ModelForm):
    class Meta:
        model = SpeciesMaintenanceLogEntry
        fields = '__all__'
        exclude = ['speciesInstances']
        widgets = { 'name':                forms.Textarea(attrs={'rows':1,'cols':40}),
                    'log_entry_image':     forms.Textarea(attrs={'rows':1,'cols':40}),                   
                     'log_entry_notes':    forms.Textarea(attrs={'rows':1,'cols':40}),}

The error occurs at the successful return, unable to set the pk using the parent object id:

return HttpResponseRedirect(reverse("speciesMaintenanceLog", args=[speciesMaintenanceLog.id]))

That’s because you’re referencing the wrong object.

should be a reference to self.fields['speciesInstances'], which is the field instance within the form instance.

However, this:

invalidates the above, because if there’s no field speciesInstances in the form object, there’s nothing to set an attribute on.

Thanks Ken. There were of course a lot of code revisions and I recently went back to using
exclude = [‘speciesInstances’] in the form as that was my best working solution so far … so my error leaving that in for the post. Revised the code as you suggest and I still see the same attribute error

'ImmutableList' object has no attribute 'fields'

Here are some updated code snippets including the model declaration.

Model:

class SpeciesMaintenanceLog (models.Model):
    name                      = models.CharField (max_length=240)
    species                   = models.ForeignKey(Species, on_delete=models.CASCADE, null=True, related_name='species_maintenance_logs')  
    speciesInstances          = models.ManyToManyField(SpeciesInstance, related_name='species_instance_maintenance_logs') 
    description               = models.TextField (null=True, blank=True)
    created                   = models.DateTimeField(auto_now_add=True)  # updated only at 1st save
    lastUpdated               = models.DateTimeField(auto_now=True)      # updated every save

    def __str__(self):
        return self.name
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['speciesInstances'].disabled = True

Model Form:

class SpeciesMaintenanceLogEntryForm (ModelForm):
    class Meta:
        model = SpeciesMaintenanceLogEntry
        fields = '__all__'
        widgets = { 'name':                forms.Textarea(attrs={'rows':1,'cols':40}),
                    'log_entry_image':     forms.Textarea(attrs={'rows':1,'cols':40}),                   
                     'log_entry_notes':    forms.Textarea(attrs={'rows':1,'cols':40}),}

View:

def createSpeciesMaintenanceLog (request, pk):
    speciesInstance = SpeciesInstance.objects.get(id=pk)
    species = speciesInstance.species
    name = speciesInstance.name + " - species maintenance collaboration"
    form = SpeciesMaintenanceLogForm(initial={'species':species, 'name':name} )
    if (request.method == 'POST'):
        form = SpeciesMaintenanceLogForm(request.POST)
        if form.is_valid():
            speciesMaintenanceLog = form.save()
            speciesMaintenanceLog.speciesInstances.add (speciesInstance)
            speciesMaintenanceLog.save()
            return HttpResponseRedirect(reverse("speciesMaintenanceLog", args=[speciesMaintenanceLog.id]))
    context = {'form': form, 'speciesInstance': speciesInstance}
    return render (request, 'species/createSpeciesMaintenanceLog.html', context)

Once the edit is complete the error appears at the function return:

return HttpResponseRedirect(reverse("speciesMaintenanceLog", args=[speciesMaintenanceLog.id]))

I have removed the init override and am moving forward with the form excluding the speciesInstance field. Everything is working as I expected. I was looking for a way to preserve the field validation during edits but it’s not a must-have. I’m fine writing the custom code where needed.

Please post the full error with the complete traceback.

Also, in the general case, I usually recommend against posting snippets of individuals classes or functions, as it’s possible that there are errors in the code that has been removed. So I always recommend posting complete versions of the classes or functions that are being discussed.

Here’s the error with traceback using the ‘copy-paste view’

Environment:


Request Method: GET
Request URL: http://localhost/editSpeciesMaintenanceLog/22/

Django Version: 5.1.6
Python Version: 3.11.9
Installed Applications:
['django.contrib.admin',
 'django.contrib.auth',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.staticfiles',
 'crispy_forms',
 'crispy_bootstrap5',
 'species.apps.SpeciesConfig',
 'allauth',
 'allauth.account',
 'allauth.socialaccount',
 'allauth.socialaccount.providers.google',
 'django_recaptcha',
 'django.contrib.sites']
Installed Middleware:
['django.middleware.security.SecurityMiddleware',
 'django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.clickjacking.XFrameOptionsMiddleware',
 'allauth.account.middleware.AccountMiddleware']



Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
               ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/decorators.py", line 60, in _view_wrapper
    return view_func(request, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/species/views.py", line 417, in editSpeciesMaintenanceLog
    speciesMaintenanceLog = SpeciesMaintenanceLog.objects.get(id=pk)
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/manager.py", line 87, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 645, in get
    num = len(clone)
          ^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 382, in __len__
    self._fetch_all()
    ^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1928, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 124, in __iter__
    obj = model_cls.from_db(
          
  File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 582, in from_db
    new = cls(*values)
          ^^^^^^^^^^^^
  File "/app/species/models.py", line 253, in __init__
    self.fields['speciesInstances'].disabled = True                  # disables form editing (hides field) while preserving validation
    ^^^^^^^^^^^

Exception Type: AttributeError at /editSpeciesMaintenanceLog/22/
Exception Value: 'SpeciesMaintenanceLog' object has no attribute 'fields'
 

Ahh, I had mis-understood where you had that line.

That line still belongs in the __init__ method of the form, not the model, if that’s where it’s going to apply.

But I’m not sure I’m following what the intent, or underlying issue is here that needs to be addressed.

If you’re creating a form to create a new object, there really isn’t any value to disabling a field - just don’t include it in the form being requested. (It’s not like there’s a current value to display for that.)

Also:

This third line - the extra save() call - is totally unnecessary. Adding an entry to a ManyToMany relationship makes no changes to either of the referenced models. Its sole effect is to create an entry in the join table linking the two.

Thanks Ken. Moving the override to the form worked as you describe. I thought it was odd that it was being applied to the model. Seemed heavy-handed and makes much better sense on the form. Lots of great suggestions on stackoverflow but they’re hit and miss.

Once I got this working as intended I was surprised to see a validation error on the disabled speciesInstances field in an edit scenario where a valid list of 1 speciesInstance exists. I can see the list displayed correctly in the admin panel, so that defeated the purpose of preserving validation of the field. So I’m going to use the simpler ‘exclude field’ in the form, which is what your suggesting and what I had working early-on, and add my own speciesInstance check prior to committing changes.

Kind of a long winding tour of exploring details but hey it was a good learning experience.

Thank you for your help. Much appreciated.