Handling Unique Constraints in M2M Relationships with Inline Forms

There are models Business, Person, and Person2Business (M2M relationship). Neither the Business model nor the Person model mentions a connection with Person2Business, however, Person2Business has the following relationships:

person = models.ForeignKey(
    Person,
    verbose_name='Individual',
    related_name='person2business_relations',
    on_delete=models.PROTECT,
)
business = models.ForeignKey(
    Business,
    verbose_name='Business',
    related_name='person2business_relations',
    on_delete=models.PROTECT,
)
relation_type = models.CharField(
    'Relationship Type',
    max_length=50,
    choices=Person2BusinessRelationType.choices,
)

Thus, we don’t need to specify these relationships in Person or Business. In the Django admin for Person and Business, an inline from Person2Business is used. This way, in the admin edit page for both Person and Business, we can specify Person2Business relationships. Person2Business also has the relation_type field, which specifies the type of relationship. In particular, there is a relationship type “Agent”.

A task has emerged to prohibit one business from having two agents. That is, it’s not allowed to attach two individuals with the relationship type “agent” to one business. The solution is quite simple - set a constraint in Person2Business:

        UniqueConstraint(
            fields=['business'],
            condition=Q(relation_type='AGENT'),
            name='unique_agent_per_business_user',
            violation_error_message='This business already has another agent',
        ),

and everything works fine, except for one issue. When we add a second agent to a business through Person, selecting the business and relationship type, we logically get the error “This business already has another agent”. However, when we do the same thing but from the business page, selecting Person and relationship type, the constraint is triggered, but the exception is not caught by Django and we see a traceback instead of a friendly error.

If we somehow modify the constraint (going from the Person model), we can do the opposite, so that the traceback appears on the Person editing page. But this, of course, doesn’t fix the problem.

Overriding clean in the form, modeladmin, model for Person2Business doesn’t help, as they don’t have access to other records that can be added in the inline simultaneously.

Attempting to check at the FormSet level solves the traceback problem but creates another - a double error on the Person side: one generated by Django through the constraint and one we created in the FormSet. Also, the solution ends up with a lot of code. It seems that such a simple check shouldn’t force us to write so much code.

Attempting to catch the error through try/except before saving the Person2Business relationship doesn’t work, an error appears: An error occurred in the current transaction. You can’t execute queries until the end of the ‘atomic’ block.

There’s another option to remove the constraint, save “as is”, and then check for duplicates post-factum and rollback the transaction if they exist. But it’s concerning that there won’t be a prohibition at the database level.

Are there any other options, or maybe advice on how to solve this seemingly simple problem?

Hey,

My guess is that you are using Django<5.2. In those versions there is a bug which led to constraints that include a foreign key field to the inline’s parent being ignored in form validation. In your case the constraint is ignored when we’re editing from Business, because the constraint includes a "business" field.

But I expect it should work in Django 5.2+ as the issue was fixed.