35

ForeignKeys on django have the attribute on_delete to specify the behavior when the referenced object is deleted. Is there any way to get something similar for ManyToManyField?

Suppose I have the following model

class House(models.Model):
    owners = models.ManyToManyField(Person)

The default behavior is to cascade, so if I delete a person that happens to own a house, it just vanishes from owners (that is, obviously, it no longer owns any houses). What I'd like to have is that if a person is an owner, it can not be deleted. That is, I want on_delete=models.PROTECT. Is this possible?

I know internally ManyToManyField is translated to another model with two ForeignKeys (in this case one to house and one to person), so it should be possible to achieve this. Any ideas how to? I'd like to avoid setting the through attribute to a new model, because this would result in a new table (I'd like to keep the old one).

Edit: I've tracked where django creates the appropriate m2m model:

def create_many_to_many_intermediary_model(field, klass):
    from django.db import models
    # ... 
    # Construct and return the new class.
    return type(name, (models.Model,), {
        'Meta': meta,
        '__module__': klass.__module__,
        from_: models.ForeignKey(klass,
                                 related_name='%s+' % name,
                                 db_tablespace=field.db_tablespace),
        to: models.ForeignKey(to_model,
                              related_name='%s+' % name,
                              db_tablespace=field.db_tablespace)
    })

The relevant line is

to: models.ForeignKey(to_model,
                      related_name='%s+' % name,
                      db_tablespace=field.db_tablespace)

I'd like it to be

to: models.ForeignKey(to_model,
                      related_name='%s+' % name,
                      db_tablespace=field.db_tablespace,
                      on_delete=models.PROTECT)

Any way to do this other than monkey patching the whole thing and creating a new class for ManyToManyField?

Chris Martin
  • 30,334
  • 10
  • 78
  • 137
Clash
  • 4,896
  • 11
  • 47
  • 67

3 Answers3

9

I think the smartest thing to do is use an explicit through table. I realise that you've stated you would prefer not to "because this would result in a new table (I'd like to keep the old one)."

I suspect your concern is over losing the data you have. If you're using South, you can easily "convert" your existing, automatic intermediate table to an explicit one OR, you can create a completely new one, then migrate your existing data to the new table before dropping your old one.

Both of these methods are explained here: Adding a "through" table to django field and migrating with South?

Considering the change you'd like to make to its definition, I'd probably go with the option of creating a new table, then migrating your data over. Test to make sure all your data is still there (and that your change does what you want), then drop the old intermediate table.

Considering that these tables will both only hold 3 integers per row, this is likely to be a very manageable exercise even if you have a lot of houses and owners.

Community
  • 1
  • 1
mkoistinen
  • 7,724
  • 3
  • 41
  • 56
  • thanks for the answer. I didn't know I could migrate the data so easily, but an explicit through table would generate too much bloat code for me. I have multiple ManyToManyField and I don't want to have an explicit table for each one of them and therefore I decided to go with monkey patching the code and simply replacing ManyToManyField with a new class ProtectedManyToManyField – Clash May 07 '13 at 00:07
  • 1
    @Clash. Hello how did you manage to implement ProtectedManyToManyField? – Paul R Feb 04 '15 at 18:05
  • @AndrewFount - I don't think you'll get a notification of this otherwise, so I'm mentioning you in this comment. Clash posted it here: http://stackoverflow.com/a/35827978/901641 – ArtOfWarfare Mar 07 '16 at 02:40
4

If I understand you want, this is similar to what I need some time ago.

Your problem: you need to protect a record that is used in another table from accidental deletion.

I solved it from this way (tested on Django 2 and Django 3).

Imagine, you have:

TABLE1 and TABLE 2, and they are under M2M relationship where TABLE1 has ManyToManyField.

I put the main keys to you understand at uppercase, you will need to adjust to what you want.

Look at views.py that use the exists() method and rise the exception are crucial.

models.py

class TABLE1(models.Model):
    FIELD_M2M = models.ManyToManyField(
        TABLE2,
        blank=False,
        related_name='FIELD_M2M',
    )
#put here your code

models.py

class TABLE2(models.Model):
#Put here your code

views.py

# Delete
@login_required
def delete(request, pk=None):
    try:  # Delete register selected
        if TABLE1.objects.filter(FIELD_M2M=pk).exists():
            raise IntegrityError
        register_to_delete = get_object_or_404(TABLE2, pk=pk)
        # register_to_delete.register_to_delete.clear() // Uncomment this, if you need broken relationship M2M before delete
        register_to_delete.delete()
    except IntegrityError:
        message = "The register couldn't be deleted!"
        messages.info(request, message)

That is a ugly solution, but it works.

  • Note that while this works, it only works when using the delete view method. This offers no protection in the Admin System or at the shell. A better solution (like what @Clash provided) would add the protection in the database or at least at the model level to cover more possible ways of deleting something. – PaulR Jul 13 '23 at 15:28
2

Posting my own solution as requested by @Andrew Fount. Quite an ugly hack just to change a single line.

from django.db.models import ManyToManyField
from django.db.models.fields.related import ReverseManyRelatedObjectsDescriptor, add_lazy_relation, create_many_to_many_intermediary_model, RECURSIVE_RELATIONSHIP_CONSTANT
from django.utils import six
from django.utils.functional import curry


def create_many_to_many_protected_intermediary_model(field, klass):
    from django.db import models
    managed = True
    if isinstance(field.rel.to, six.string_types) and field.rel.to != RECURSIVE_RELATIONSHIP_CONSTANT:
        to_model = field.rel.to
        to = to_model.split('.')[-1]

        def set_managed(field, model, cls):
            field.rel.through._meta.managed = model._meta.managed or cls._meta.managed
        add_lazy_relation(klass, field, to_model, set_managed)
    elif isinstance(field.rel.to, six.string_types):
        to = klass._meta.object_name
        to_model = klass
        managed = klass._meta.managed
    else:
        to = field.rel.to._meta.object_name
        to_model = field.rel.to
        managed = klass._meta.managed or to_model._meta.managed
    name = '%s_%s' % (klass._meta.object_name, field.name)
    if field.rel.to == RECURSIVE_RELATIONSHIP_CONSTANT or to == klass._meta.object_name:
        from_ = 'from_%s' % to.lower()
        to = 'to_%s' % to.lower()
    else:
        from_ = klass._meta.object_name.lower()
        to = to.lower()
    meta = type('Meta', (object,), {
        'db_table': field._get_m2m_db_table(klass._meta),
        'managed': managed,
        'auto_created': klass,
        'app_label': klass._meta.app_label,
        'db_tablespace': klass._meta.db_tablespace,
        'unique_together': (from_, to),
        'verbose_name': '%(from)s-%(to)s relationship' % {'from': from_, 'to': to},
        'verbose_name_plural': '%(from)s-%(to)s relationships' % {'from': from_, 'to': to},
        })
    # Construct and return the new class.
    return type(name, (models.Model,), {
        'Meta': meta,
        '__module__': klass.__module__,
        from_: models.ForeignKey(klass, related_name='%s+' % name, db_tablespace=field.db_tablespace),

        ### THIS IS THE ONLY LINE CHANGED
        to: models.ForeignKey(to_model, related_name='%s+' % name, db_tablespace=field.db_tablespace, on_delete=models.PROTECT)
        ### END OF THIS IS THE ONLY LINE CHANGED
    })


class ManyToManyProtectedField(ManyToManyField):
    def contribute_to_class(self, cls, name):
        # To support multiple relations to self, it's useful to have a non-None
        # related name on symmetrical relations for internal reasons. The
        # concept doesn't make a lot of sense externally ("you want me to
        # specify *what* on my non-reversible relation?!"), so we set it up
        # automatically. The funky name reduces the chance of an accidental
        # clash.
        if self.rel.symmetrical and (self.rel.to == "self" or self.rel.to == cls._meta.object_name):
            self.rel.related_name = "%s_rel_+" % name

        super(ManyToManyField, self).contribute_to_class(cls, name)

        # The intermediate m2m model is not auto created if:
        #  1) There is a manually specified intermediate, or
        #  2) The class owning the m2m field is abstract.
        #  3) The class owning the m2m field has been swapped out.
        if not self.rel.through and not cls._meta.abstract and not cls._meta.swapped:
            self.rel.through = create_many_to_many_protected_intermediary_model(self, cls)

        # Add the descriptor for the m2m relation
        setattr(cls, self.name, ReverseManyRelatedObjectsDescriptor(self))

        # Set up the accessor for the m2m table name for the relation
        self.m2m_db_table = curry(self._get_m2m_db_table, cls._meta)

        # Populate some necessary rel arguments so that cross-app relations
        # work correctly.
        if isinstance(self.rel.through, six.string_types):
            def resolve_through_model(field, model, cls):
                field.rel.through = model
            add_lazy_relation(cls, self, self.rel.through, resolve_through_model)
Clash
  • 4,896
  • 11
  • 47
  • 67