9
class Badge(SafeDeleteModel):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL,
                              blank=True, null=True,
                              on_delete=models.PROTECT)
    restaurants = models.ManyToManyField(Restaurant)
    identifier = models.CharField(max_length=2048)  # not unique at a DB level!

I want to ensure that for any badge, for a given restaurant, it must have a unique identifier. Here are the 4 ideas I have had:

  • idea #1: using unique_together -> Does not work with M2M fields as explained [in documentation] (https://docs.djangoproject.com/en/2.1/ref/models/options/#unique-together)
  • idea #2: overriding save() method. Does not fully work with M2M, because when calling add or remove method, save() is not called.
  • idea #3: using an explicite through model, but since I'm live in production, I'd like to avoid taking risks on migrating important structures like theses. EDIT: after thinking of it, I don't see how it could help actually.

  • idea #4: Using a m2m_changedsignal to check the uniqueness anytime the add() method is called.

I ended up with the idea 4 and thought everything was OK, with this signal...

@receiver(m2m_changed, sender=Badge.restaurants.through)
def check_uniqueness(sender, **kwargs):
    badge = kwargs.get('instance', None)
    action = kwargs.get('action', None)
    restaurant_pks = kwargs.get('pk_set', None)

    if action == 'pre_add':
        for restaurant_pk in restaurant_pks:
            if Badge.objects.filter(identifier=badge.identifier).filter(restaurants=restaurant_pk):
                raise BadgeNotUnique(MSG_BADGE_NOT_UNIQUE.format(
                    identifier=badge.identifier,
                    restaurant=Restaurant.objects.get(pk=restaurant_pk)
                ))

...until today when I found in my database lots of badges with the same identifier but no restaurant (should not happend at the business level) I understood there is no atomicity between the save() and the signal. Which means, if the user have an error about uniqueness when trying to create a badge, the badge is created but without restaurants linked to it.

So, the question is: how do you ensure at the model level that if the signal raises an Error, the save() is not commited?

Thanks!

David Dahan
  • 10,576
  • 11
  • 64
  • 137
  • 2
    Why not make an `IdentifiedBadge` with a m2m to restaurant. In such way you can guarantee this *by design*. Usually it is better to enforce things *by design* then aiming to patch these. Signals, `.save()`, etc. can be *bypassed* (for example when updating in bulk). – Willem Van Onsem Sep 03 '18 at 09:58
  • That could be a 5th idea indeed thanks. Yet, I don't like the idea of adding a new class just to handle other class models integrity. How this solution would be superior of **idea #3**? – David Dahan Sep 03 '18 at 10:08
  • save shouldn't catch error that raised in signal, this is source code where signal was send https://github.com/django/django/blob/stable/2.1.x/django/db/models/fields/related_descriptors.py#L1082 as you can see it wrapped to transaction atomic and doesn't catch errors. Are you sure that you signal is executed? – Dima Kudosh Sep 18 '18 at 13:14
  • Is it a hard requirement that the identifier not be unique at a database level? Or is that just a preference? – ubadub Sep 18 '18 at 21:40
  • @ubadub hard requirement – David Dahan Sep 18 '18 at 21:41
  • @DavidD. how is the badge identifier generated? – BitParser Sep 18 '18 at 22:25
  • @TGO entered by human. – David Dahan Sep 18 '18 at 22:49
  • It seems like you're gonna have to bite the bullet and go with idea 3. Proceed cautiously and test often before deployment – ubadub Sep 19 '18 at 19:01
  • @ubadub That's exactly what I'm gonna do except if someone find a more elegant solution in between :D – David Dahan Sep 20 '18 at 08:48

3 Answers3

3

I see two separate issues here:

  1. You want to enforce a particular constraint on your data.

  2. If the constraint is violated, you want to revert previous operations. In particular, you want to revert the creation of the Badge instance if any Restaurants are added in the same request that violate the constraint.

Regarding 1, your constraint is complicated because it involves multiple tables. That rules out database constraints (well, you could probably do it with a trigger) or simple model-level validation.

Your code above is apparently effective at preventing adds that violate the constraint. Note, though, that this constraint could also be violated if the identifier of an existing Badge is changed. Presumably you want to prevent that as well? If so, you need to add similar validation to Badge (e.g. in Badge.clean()).

Regarding 2, if you want the creation of the Badge instance to be reverted when the constraint is violated, you need to make sure the operations are wrapped in a database transaction. You haven't told us about the views where these objects area created (custom views? Django admin?) so it's hard to give specific advice. Essentially, you want to have this:

with transaction.atomic():
    badge_instance.save()
    badge_instance.add(...)

If you do, an exception thrown by your M2M pre_add signal will rollback the transaction, and you won't get the leftover Badge in your database. Note that admin views are run in a transaction by default, so this should already be happening if you're using the admin.

Another approach is to do the validation before the Badge object is created. See, for example, this answer about using ModelForm validation in the Django admin.

Kevin Christopher Henry
  • 46,175
  • 7
  • 116
  • 102
  • Thanks for your answer. 
I didn’t talk about the views because I definitely want this integrity rule to be handled at the model level. I can’t rely on views to ensure that role.
May I ask you what do you think of the suggested #idea3 as a solution? – David Dahan Sep 20 '18 at 08:52
  • @DavidD.: The integrity rule (point 1) can be handled at the model level, but you also want to make atomic a series of operations on different database tables (point 2). The `Badge` object has to exist in the database before you can try (and potentially fail) to add any `Restaurants` to it. As for idea #3, could you edit your question to be more explicit? What would the `through` model look like and how would it solve the problem? – Kevin Christopher Henry Sep 20 '18 at 09:31
  • I think you're right, thinking of idea #3, it seems id does not solve the problem. I wanted to use `unique_together` but Django does not allow to write something like `unique_together=(badge__identifier, restaurant)`. – David Dahan Sep 20 '18 at 10:02
  • I have a new idea #5: what about deleting the badge itself in the signal? It's kind of dirty because an object would be created then deleted just after, but it should work as expected I guess... ? – David Dahan Sep 20 '18 at 10:04
  • @DavidD.: Deleting the badge might work, though you'd have to be careful to construct your condition in such a way that you only do so on new `Badges` (as opposed to adding `Restaurants` to existing `Badges`). There may be unexpected interactions with the signal system, and you'd miss out on the benefits of using Django's validation system. I wouldn't recommend it, but it might work. – Kevin Christopher Henry Sep 20 '18 at 11:41
  • Your answer allowed me to understand that it is more a view related problem that a model one. In the end, I just used a `with transaction.atomic()` block in my `form_valid` CBV method. It solved the problem. I just need to be careful when working at model level. Thanks. Happy bounty. – David Dahan Sep 24 '18 at 21:26
1

You can specify your own connecting model for your M2M-models, and then add a unique_together constraint in the meta class of the membership model

class Badge(SafeDeleteModel):
    ...
    restaurants = models.ManyToManyField(Restaurant, through='BadgeMembership')

class BadgeMembership(models.Model):
    restaurant = models.ForeignKey(Restaurant, null=False, blank=False, on_delete=models.CASCADE)
    badge = models.ForeignKey(Badge, null=False, blank=False, on_delete=models.CASCADE)

    class Meta:
        unique_together = (("restaurant", "badge"),)

This creates an object that's between the Badge and Restaurant which will be unique for each badge per restaurant.

Badge and restaurant membership

Optional: Save check

You can also add a custom save function where you can manually check for uniqueness. In this way you can manually raise an exception.

class BadgeMembership(models.Model):
    restaurant = models.ForeignKey(Restaurant, null=False, blank=False, on_delete=models.CASCADE)
    badge = models.ForeignKey(Badge, null=False, blank=False, on_delete=models.CASCADE)

    def save(self, *args, **kwargs):
        # Only save if the object is new, updating won't do anything
        if self.pk is None:
            membershipCount = BadgeMembership.objects.filter(
                Q(restaurant=self.restaurant) &
                Q(badge=self.badge)
            ).count()
            if membershipCount > 0:
                raise BadgeNotUnique(...);
            super(BadgeMembership, self).save(*args, **kwargs)
Johan
  • 3,577
  • 1
  • 14
  • 28
  • Hi Johan, thanks for your help. However, I'm not sure `unique_together = (("restaurant", "badge"),)` statement will ensure uniqueness. It's remains possible to create 2 badges with the same identifier and the same restaurants. I would need something like `unique_together = (("restaurant", "badge__identifier"),)` but it's not possible to write this. – David Dahan Sep 24 '18 at 15:05
  • @DavidD. Have you tried with the `unique_together` on the membership model that connects `Badge` and `Restaurant`? (ie not on the `Badge` or the `Restaurant` model). – Johan Sep 24 '18 at 15:30
1

I'm afraid the correct way to achieve this really is by adapting the "through" model. But remember that at database level this "through" model already exists, and therefore your migration would simply be adding a unique constraint. It's a rather simple operation, and it doesn't really involve any real migrations, we do it often in production environments.

Take a look at this example, it pretty much sums everything you need.

Anoyz
  • 7,431
  • 3
  • 30
  • 35
  • Can you please elaborate on how the "through" model would help here to ensure integrity at the model level? For me, even with this intermediary model, we will still be able to createa badge with no restaurant linked because of atomicity (see the initial problem in the question). How is this solution better than the signals I'm already using? – David Dahan Sep 24 '18 at 12:57
  • @DavidD. I have updated my answer on how a custom `through` model will solve your problem. – Johan Sep 24 '18 at 14:59