6

I have the following models:

class Item(models.Model):
    # fields
    # ...

class Collection(models.Model):
    items = models.ManyToManyField(Item, related_name="collections")
    # other fields
    # ...

Now I want two things:

  1. I want to control if an Item can be added to a Collection.
  2. I want the Collection to update some of its fields if an Item was added or removed.

For the second issue I know that there is the django.db.models.signals.m2m_changed which I can use to hook into changes of the relation. Is it allowed/ok to change the Collection within the signal callback? Can I use the signal also for "aborting" the insertion for issue 1?

Constantinius
  • 34,183
  • 8
  • 77
  • 85
  • For issue 1, you should probably use the form's cleaning cycle to validate the data (which eases validation messaging), which is then send to [save_m2m](https://docs.djangoproject.com/en/dev/topics/forms/modelforms/#the-save-method) – Hedde van der Heide May 24 '13 at 09:15
  • 1
    @Hedde: I would prefer a solution close to the models, because my data will most likely not be altered from a form. (More likely through CLI tools and an exposed API). – Constantinius May 24 '13 at 12:17
  • You could overwrite the model save methods, at least for part of the logic, but if you're implementing an API it seems such logic belongs in the API's authorization layer. Tastypie is a great rich API that plays well with Django. – Hedde van der Heide May 27 '13 at 13:13
  • 1
    Hm, my problem has nothing to do with *user* authorization. It is application logic whether or not the `Collection` `Item` relation shall be allowed or not. – Constantinius May 27 '13 at 14:14
  • I am not talking about _authentication_, but a framework like tastypie lets you check if a post or put is allowed on certain conditions (which you can specify yourself). – Hedde van der Heide May 27 '13 at 14:31
  • @Hedde: I said *authorization* not *authentication*. The only reference I found was this: http://django-tastypie.readthedocs.org/en/latest/authorization.html#the-authorization-api This concept is based on web based access. I need a solution that also works from command line tools (CLI). – Constantinius May 27 '13 at 15:58
  • Can you specify what criteria there are for adding Items to Collections? Depending on this, I would opt for using database constraints, editing `save()` on `Item` or using `m2m_changed`. – jnns May 27 '13 at 16:04
  • @jnns: `Item`s and `Collection`s are just placeholders for my architecture. In my case `Collection`s are and in a specific `Collection`-type I need to only insert `Item`s with specific field values. Can I add DBMS independent constraints via Django? Is `save()` on `Item` even called when I add it to a `Collection`? – Constantinius May 28 '13 at 06:57
  • @jnns: I just checked, when I add an `Item` to a `Collection`, `save()` is never called, on neither model. – Constantinius May 28 '13 at 07:33

2 Answers2

8

I think the best way to approach both of your desired behaviors is not with signals, but rather with an overridden save() and delete() method on the through table which you would define explicitly using the argument through see: https://docs.djangoproject.com/en/dev/ref/models/fields/#django.db.models.ManyToManyField.through. and this: https://docs.djangoproject.com/en/dev/topics/db/models/#overriding-predefined-model-methods

Something like this:

# -*- coding: utf-8 -*-

from django.db import models


class Item(models.Model):
    # fields
    # ...

class Collection(models.Model):
    items = models.ManyToManyField(Item, related_name="collections", through="CollectionItem")
    # other fields
    # ...

class CollectionItem(models.Model):
    collection = models.ForeignKey(Collection)
    item = models.ForeignKey(Item)

    def save(self, *args, **kwargs):
        # Only allow this relationship to be created on some_condition
        # Part 1 of your question.
        if some_condition:
            super(CollectionItem, self).save(*args, **kwargs)

            # Update some fields on Collection when this
            # relationship is created
            # Part 2 of your question (1/2)
            self.Collection.updateSomeFields()

    def delete(self, *args, **kwargs):
        collection = self.collection
        super(CollectionItem, self).delete(*args, **kwargs)

        # Update some fields on Collection when this relationship
        # is destroyed.
        # Part 2 of your question (2/2)
        collection.updateSomeFields()

Incidentally, you'll find that adding a relationship will cause a save-signal on this through model.

And, regarding signals, once you have the through table in place, you'd be able to listen for pre_save and/or post_save signals, but neither of them will allow you to directly veto the creation of the relationship.

If one or both of your models are supplied by a 3rd party and you really cannot create the through table, then, yes, the signal route may be the only way to go.

https://docs.djangoproject.com/en/dev/ref/signals/#m2m-changed

In which case, you could listen for the m2m_changed event and trigger updates to your collection objects (part 2 of your question) and retroactively delete inappropriately created relationships (part 1 of your question). However, as this latter bit is a fugly kludgy, I'd stick with the explicit through table if you can.

mkoistinen
  • 7,724
  • 3
  • 41
  • 56
  • 1
    I like this approach very much. I never thought of using the `trhough` model as the place to handle this, but it makes sense. The only drawback I see is that now I cannot use the `add` method of the `ManyToManyField`, but need to create the `CollectionItem` itself. This is no problem, just something to get used to. Thanks for your answer. – Constantinius Jun 03 '13 at 07:39
  • 1
    this is a nice alternative too and provides more flexibility – Alp Jun 03 '13 at 08:57
  • 1
    This is particularly useful, seeing as how the m2m_changed signal does not fire for changes made in the admin [https://code.djangoproject.com/ticket/16073] – trubliphone Nov 19 '14 at 00:57
3
  1. The pre_save signal is called before saving an instance. But you are not able to abort the save operation from there. A better solution would be to add a new method to your Collection model, which is responsible for checking if an Item can be added:

    class Collection(models.Model):
        items = models.ManyToManyField(Item, related_name="collections")
    
        ...
    
        def add_item(self, item):
            if check_if_item_can_be_added(item):
                items.add(item)
                self.save()
    
    def check_if_item_can_be_added(self, item):
        # do your checks here
    
  2. When adding an instance to a m2m field, the save method does not get called. You are right, the m2m_changed signal is the way to go. You can safely update the collection instance in there.

Alp
  • 29,274
  • 27
  • 120
  • 198