Many to many field validation in django

MyModel has one ManyToManyField that links to AnotherModel.
For validation purposes I need to check the model after m2m relationships have been already set in the database, by applying custom logic implemented with database queries (practically no way to implement them in a different way)

At the moment, I’m raising ValidationError inside a signal receiver:

@receiver(m2m_changed, sender=MyModel.relationship.through)
def validator_from_signal(sender, instance, action, reverse, model, **kwargs):
    if action == 'post_add':
        if reverse and not check_reverse(instance):
            msg = f'AnotherModel {instance.id} violates validation checks.'
            logging.error(msg)
            raise ValidationError(msg)
        elif not reverse and not check(instance):
            msg = f'MyModel {instance.id} violates validation checks.'
            logging.error(msg)
            raise ValidationError(msg)

It works, thanks to the transaction behaviour which in case of exception rollbacks everything, but if the user submits a wrong MyModel or AnotherModel (because of ManyToMany illegal relationships), in particular with the Admin Form, they get a 500 internal server error. Which isn’t very user friendly…

I opened a ticket to ask for support of ValidationError / IntegrityError coming from signals, but it was rejected under the “probably there is another solution” reason (see #33832 (Support M2M validation using signals) – Django), so now I’m asking for advice to the community. How would you implement such logic?

I can’t implement this check inside the clean() method:

  1. because m2m fields are not populated on the database at that stage
  2. because it would only work for the admin form

What do you think?

Let’s see if I understand what you’re saying here.

You have two models:

class MyModel(...):
    name = CharField(...)

class OtherModel(...):
    name = CharField(...)
    my_groups = ManyToManyField(MyModel, ...)

And you have a view where you’re adding instances of either (or both) of MyModel and OtherModel to the database, along with adding a relationship between them:

my_model = MyModel(...)
other_model = OtherModel(...)
other_model.my_groups.add(my_model)

But you can’t validate my_model until you do the add, is that correct?

If so, it seems to me like there are at least three different possibilities here:

  • It’s not valid to associate my_model with other_model
  • my_model is not valid
  • other_model is not valid

Each one of these conditions appears to me to be different situations.

Is my understanding correct of the basic problem?

If so, which one of those three conditions need to be handled?

  • If more than one, depending upon conditions, do you know what those conditions are?

What are the resolutions to these conditions?

  • Reject the relationship?
  • Alter either my_model or other_model
  • Something else?

Are you only looking for an Admin-based solution, or does this need to be addressed in views?

the model would be

currently I’m leveraging the ModelAdmin as a view;

my_model would be not valid, because the relation is mandatory but it is being created created/updated with illegal relationship to instance(s) of AnotherModel. The expected behavior is a complete rollback on the addition/change of my_model, and an error should be displayed on the admin panel as for usual ValidationError(s)

and yes, validation should be run after the model is “added” with all the relationship inside the database, even though inside the transaction

Currently this use-case appears only in the admin panel, so I would be happy today with an admin-based solution, but I’d like a more general approach if one day I’ll expose a similar view to normal users

One more verification - the situation is that you cannot have an instance of MyModel without there being at least one related entry in the many-to-many join table? And so you’re adding a MyModel entry and in the same view, adding the relationships to OtherModel?

Personally, I wouldn’t even think of trying to do this in the Admin. It would seem to me to be a whole lot easier to do this in a user-written view. Wrap both processes in a transaction and roll it back if the validation fails.

In such cases, I tend to fall back on the principle stated in the Django Admin docs:

If you need to provide a more process-centric interface that abstracts away the implementation details of database tables and fields, then it’s probably time to write your own views.

I need to leverage ModelAdmin for time constraints on the development, which is one of the reasons I chose Django as a framework. The only thing I need now is a place where to put my code that can raise ValidationError(s) and see those catched and displayed properly, which signals are not… for some reason

is there some method override on the model admin I could use? I feel like there should be

one hint is that currently, when my signal raises ValidationError, it goes through the admin view in the stack trace… so technically speaking it would be viable to put a try/catch somewhere maybe

Except:

The admin’s recommended use is limited to an organization’s internal management tool. It’s not intended for building your entire front end around.

I have seen too many cases where people think they can ignore this advice - and it ends up costing them time overall.

There are clearly situations where the admin is the wrong tool - you’ll spend more time trying to make it do exactly what you want, when you can crank out a generic view in a lot less time.

Now, whether or not this situation is one of those cases, I can’t say for sure. But it sure feels to me like you’re expending a lot more effort on this than what’s necessary for the stated requirement.

Admins aren’t allowed to add relationships that would cause a “wrong” model, based on arbitrary validation rules, coded with queries that I’d like to run after relationships have been added. I don’t see how that would go beyond the scope of an admin panel

Examples of places where I could probably monkeypatch the problem with a try/catch and some logic:

In admin:

In form model:

Fixed by monkeypatching ModelAdmin from options.py
PREVIOUS VERSION:

MY VERSION:

            if all_valid(formsets) and form_validated:
                try:
                    self.save_model(request, new_object, form, not add)
                    self.save_related(request, form, formsets, not add)
                    change_message = self.construct_change_message(
                    request, form, formsets, add
                    )
                    if add:
                        self.log_addition(request, new_object, change_message)
                        return self.response_add(request, new_object)
                    else:
                        self.log_change(request, new_object, change_message)
                        return self.response_change(request, new_object)
                except ValidationError as e:
                    form_validated = False
                    form._update_errors([e])

Solution:

class MyForm(ModelForm):
    def clean(self):
        ok = False
        with transaction.atomic(savepoint=True, durable=False):
            ok = check(self.save(commit=True))
            transaction.set_rollback(True)
        if not ok:
            raise ValidationError(f'MyModel {self.instance.id} violates validation checks.')
        return super().clean()


@admin.register(MyModel)
class MyModelAdmin(admin.ModelAdmin):
    form = MyForm