Prevent delete in Django model
Asked Answered
G

9

30

I have a setup like this (simplified for this question):

class Employee(models.Model):
    name = models.CharField(name, unique=True)

class Project(models.Model):
    name = models.CharField(name, unique=True)
    employees = models.ManyToManyField(Employee)

When an Employee is about to get deleted, I want to check whether or not he is connected to any projects. If so, deletion should be impossible.

I know about signals and how to work them. I can connect to the pre_delete signal, and make it throw an exception like ValidationError. This prevents deletion but it is not handled gracefully by forms and such.

This seems like a situation that other will have run into. I'm hoping someone can point out a more elegant solution.

Glandulous answered 28/1, 2011 at 7:20 Comment(2)
This is not feasible only using Python code; the database itself will need to be modified as well.Choleric
Thanks for your comment. I'm looking for the Python/Django part first and see how far that gets me in my app.Glandulous
R
21

For those referencing this questions with the same issue with a ForeignKey relationship the correct answer would be to use Djago's on_delete=models.PROTECT field on the ForeignKey relationship. This will prevent deletion of any object that has foreign key links to it. This will NOT work for for ManyToManyField relationships (as discussed in this question), but will work great for ForeignKey fields.

So if the models were like this, this would work to prevent the deletion of any Employee object that has one or more Project object(s) associated with it:

class Employee(models.Model):
    name = models.CharField(name, unique=True)

class Project(models.Model):
    name = models.CharField(name, unique=True)
    employees = models.ForeignKey(Employee, on_delete=models.PROTECT)

Documentation can be found HERE.

Reddish answered 16/1, 2018 at 0:51 Comment(1)
Note that PROTECT will still run query to find the linked objects, although they won't be deleted.Makowski
B
31

I was looking for an answer to this problem, was not able to find a good one, which would work for both models.Model.delete() and QuerySet.delete(). I went along and, sort of, implementing Steve K's solution. I used this solution to make sure an object (Employee in this example) can't be deleted from the database, in either way, but is set to inactive.

It's a late answer.. just for the sake of other people looking I'm putting my solution here.

Here is the code:

class CustomQuerySet(QuerySet):
    def delete(self):
        self.update(active=False)


class ActiveManager(models.Manager):
    def active(self):
        return self.model.objects.filter(active=True)

    def get_queryset(self):
        return CustomQuerySet(self.model, using=self._db)


class Employee(models.Model):
    name = models.CharField(name, unique=True)
    active = models.BooleanField(default=True, editable=False)

    objects = ActiveManager()

    def delete(self):
        self.active = False
        self.save()

Usage:

Employee.objects.active() # use it just like you would .all()

or in the admin:

class Employee(admin.ModelAdmin):

    def queryset(self, request):
        return super(Employee, self).queryset(request).filter(active=True)
Bride answered 25/9, 2013 at 14:25 Comment(6)
I do not understand how you delete any employee as I understood you set a flag without any checks on projects, but the question wants to delete(or deactive) an employee if it does not involve in any projects which you did not any check on that.Absinthe
@MohsenTamiz This solution is about the basic principle (and elegant way) of preventing a delete in Django. Overriding the delete method makes it easy to suit the asker's use case.Bride
Thanks for your response may be it is a starting point but I did this in an other way and have some questions about that. I would appreciate if you could check my question and get me some feedback.Absinthe
Your method that you suggested is not suitable for deletion because you have to check the project field inside the query set delete or you have to call the delete method in queryset delete function for each object in query which neither of them are a feasible and efficient way in a large scale data models(For example when you must check many ManyToMany fields)Absinthe
This method is obviously not suitable for large datasets when using extra queries to check constraints. Optimisation for such scenarios should be carefully though out depending on scenario. In Django most often solutions like a task server or raw queries are applied in such a case.Bride
Finally I had to use this method to implement deletion. But I think using an intermediary table is more efficient because it solvse the problem in database level not in django level. But I had problem with that solution which leads me to a question which I mentioned before.Absinthe
R
21

For those referencing this questions with the same issue with a ForeignKey relationship the correct answer would be to use Djago's on_delete=models.PROTECT field on the ForeignKey relationship. This will prevent deletion of any object that has foreign key links to it. This will NOT work for for ManyToManyField relationships (as discussed in this question), but will work great for ForeignKey fields.

So if the models were like this, this would work to prevent the deletion of any Employee object that has one or more Project object(s) associated with it:

class Employee(models.Model):
    name = models.CharField(name, unique=True)

class Project(models.Model):
    name = models.CharField(name, unique=True)
    employees = models.ForeignKey(Employee, on_delete=models.PROTECT)

Documentation can be found HERE.

Reddish answered 16/1, 2018 at 0:51 Comment(1)
Note that PROTECT will still run query to find the linked objects, although they won't be deleted.Makowski
D
12

This would wrap up solution from the implementation in my app. Some code is form LWN's answer.

There are 4 situations that your data get deleted:

  • SQL query
  • Calling delete() on Model instance: project.delete()
  • Calling delete() on QuerySet innstance: Project.objects.all().delete()
  • Deleted by ForeignKey field on other Model

While there is nothing much you can do with the first case, the other three can be fine grained controlled. One advise is that, in most case, you should never delete the data itself, because those data reflect the history and usage of our application. Setting on active Boolean field is prefered instead.

To prevent delete() on Model instance, subclass delete() in your Model declaration:

    def delete(self):
        self.active = False
        self.save(update_fields=('active',))

While delete() on QuerySet instance needs a little setup with a custom object manager as in LWN's answer.

Wrap this up to a reusable implementation:

class ActiveQuerySet(models.QuerySet):
    def delete(self):
        self.save(update_fields=('active',))


class ActiveManager(models.Manager):
    def active(self):
        return self.model.objects.filter(active=True)

    def get_queryset(self):
        return ActiveQuerySet(self.model, using=self._db)


class ActiveModel(models.Model):
    """ Use `active` state of model instead of delete it
    """
    active = models.BooleanField(default=True, editable=False)
    class Meta:
        abstract = True

    def delete(self):
        self.active = False
        self.save()

    objects = ActiveManager()

Usage, just subclass ActiveModel class:

class Project(ActiveModel):
    ...

Still our object can still be deleted if any one of its ForeignKey fields get deleted:

class Employee(models.Model):
    name = models.CharField(name, unique=True)

class Project(models.Model):
    name = models.CharField(name, unique=True)
    manager = purchaser = models.ForeignKey(
        Employee, related_name='project_as_manager')

>>> manager.delete() # this would cause `project` deleted as well

This can be prevented by adding on_delete argument of Model field:

class Project(models.Model):
    name = models.CharField(name, unique=True)
    manager = purchaser = models.ForeignKey(
        Employee, related_name='project_as_manager',
        on_delete=models.PROTECT)

Default of on_delete is CASCADE which will cause your instance deleted, by using PROTECT instead which will raise a ProtectedError (a subclass of IntegrityError). Another purpose of this is that the ForeignKey of data should be kept as a reference.

Duma answered 7/7, 2016 at 10:58 Comment(2)
that's a good summary there, but what happens when this error is raised? Will deleting an employee fail? How do we allow deletion but still let the dependancy fall through and protect the ProjectJiggermast
@Jiggermast I'm having this same issue now. Django will will see the PROTECT and never check the 'deleted' key, so it will block ever being able to delete anything with PROTECT referenced. Did you ever find a way to have it query/check against deleted field and then move forward? I have a feeling it just has to be done in a custom delete check on the referencing model.Rhomb
T
5

If you know there will never be any mass employee delete attempts, you could just override delete on your model and only call super if it's a legal operation.

Unfortunately, anything that might call queryset.delete() will go straight to SQL: http://docs.djangoproject.com/en/dev/topics/db/queries/#deleting-objects

But I don't see that as much of a problem because you're the one writing this code and can ensure there are never any queryset.delete() on employees. Call delete() manually.

I hope deleting employees is relatively rare.

def delete(self, *args, **kwargs):
    if not self.related_query.all():
        super(MyModel, self).delete(*args, **kwargs)
Temperament answered 28/1, 2011 at 7:48 Comment(3)
Thanks. I know about this, and it will probably the solution I go for if pre_delete signal doesn't work out. +1 for describing this with pros and cons.Glandulous
You can take care of mass deletes by writing 2 classes : one which inherits models.Manager and another inheriting models.query.QuerySet The first will override get_query_set, returning an instance of the second class. The QuerySet derived class will override the delete() method. This delete method will iterate over the class instance and call delete() on each item. Hope this is clear.Matherly
self.related_query.exists() will generate a better and optimized query than using the all() method.Clardy
D
4

I would like to propose one more variation on LWN and anhdat's answers wherein we use a deleted field instead of an active field and we exclude "deleted" objects from the default queryset, so as to treat those objects as no longer present unless we specifically include them.

class SoftDeleteQuerySet(models.QuerySet):
    def delete(self):
        self.update(deleted=True)


class SoftDeleteManager(models.Manager):
    use_for_related_fields = True

    def with_deleted(self):
        return SoftDeleteQuerySet(self.model, using=self._db)

    def deleted(self):
        return self.with_deleted().filter(deleted=True)

    def get_queryset(self):
        return self.with_deleted().exclude(deleted=True)


class SoftDeleteModel(models.Model):
    """ 
    Sets `deleted` state of model instead of deleting it
    """
    deleted = models.NullBooleanField(editable=False)  # NullBooleanField for faster migrations with Postgres if changing existing models
    class Meta:
        abstract = True

    def delete(self):
        self.deleted = True
        self.save()

    objects = SoftDeleteManager()


class Employee(SoftDeleteModel):
    ...

Usage:

Employee.objects.all()           # will only return objects that haven't been 'deleted'
Employee.objects.with_deleted()  # gives you all, including deleted
Employee.objects.deleted()       # gives you only deleted objects

As stated in anhdat's answer, make sure to set the on_delete property on ForeignKeys on your model to avoid cascade behavior, e.g.

class Employee(SoftDeleteModel):
    latest_project = models.ForeignKey(Project, on_delete=models.PROTECT)

Note:

Similar functionality is included in django-model-utils's SoftDeletableModel as I just discovered. Worth checking out. Comes with some other handy things.

Darkling answered 15/11, 2017 at 22:27 Comment(0)
H
2

I have a suggestion but I'm not sure it is any better than your current idea. Taking a look at the answer here for a distant but not unrelated problem, you can override in the django admin various actions by essentially deleting them and using your own. So, for example, where they have:

def really_delete_selected(self, request, queryset):
    deleted = 0
    notdeleted = 0
    for obj in queryset:
        if obj.project_set.all().count() > 0:
            # set status to fail
            notdeleted = notdeleted + 1
            pass
        else:
            obj.delete()
            deleted = deleted + 1
    # ...

If you're not using django admin like myself, then simply build that check into your UI logic before you allow the user to delete the object.

Hiers answered 28/1, 2011 at 7:44 Comment(1)
Thanks. I'm not using Django admin for this, although a solution that included both Django admin and custom UI code would be awesome. If it were Django admin only, your solution and reference would be excellent. +1 for that.Glandulous
R
0

For anyone who finds this and wants to know how you can add PROTECT to you model fields, but have it ignore any soft deleted objects, you can do this by simply overriding the PROTECT that comes with Django:

def PROTECT(collector, field, sub_objs, using):
if sub_objs.filter(deleted=False).count() > 0:
    raise ProtectedError(
        "Cannot delete some instances of model '%s' because they are "
        "referenced through a protected foreign key: '%s.%s'"
        % (
            field.remote_field.model.__name__,
            sub_objs[0].__class__.__name__,
            field.name,
        ),
        sub_objs.filter(deleted=False),
    )

This will check whether there are any objects that have not been soft deleted, and only return those objects in the error. This has not been optimized.

Rhomb answered 17/11, 2021 at 6:28 Comment(0)
G
0

Can't believe it's been 10 years since I asked this question. A similar issue came up again, and we ended up packing our solution in a small toolkit we use internally. It adds a ProtectedModelMixin which is related to the question asked here. See https://github.com/zostera/django-marina

Glandulous answered 18/11, 2021 at 7:50 Comment(0)
C
0

I had the same problem and just found a great solution for this.

There are two ways that someone can try to delete your model instances: by deleting an instance or by calling delete on an entire queryset. You don't need to worry about someone calling delete on the manager because the delete method is not exposed in the manager. To quote Django docs:

Note that delete() is the only QuerySet method that is not exposed on a Manager itself. This is a safety mechanism to prevent you from accidentally requesting Entry.objects.delete(), and deleting all the entries. If you do want to delete all the objects, then you have to explicitly request a complete query set: Entry.objects.all().delete()

To prevent instance-level deletion, you can simply override the delete method in your model class:

from django.db import models

class Undeletable(models.Model):
    # your fields here

    def delete(self, using=None, keep_parents=False):
        raise models.ProtectedError("You can't delete this model!", self)

Now onto the next concern: queryset deletions. To tackle this problem, we need to know a few things about Django:

  • When you call a method on a queryset that creates another queryset, for example queryset.filter() or queryset.exclude(), a new queryset is created, but its type is the same as the type of the older queryset. So if queryset q is an instance of MyQuerySet, q.filter(#filters) will create a new object of type MyQuerySet
  • The models.Manager class which is the default manager for models.Model classes, has an attribute _queryset_class that determines what kind of queryset is returned from methods like .all() or .filter()
  • In order to create a manager class with a custom _queryset_class, you can look at the source code for models.Manager class, which is the default manager for models.Model classes:
class Manager(BaseManager.from_queryset(QuerySet)):
    pass

So, in order to prevent querysets from bulk-deleting our model, we can use the following code:

from django.db import models

class UndeletableQueryset(models.QuerySet):
    def delete(self):
        raise models.ProtectedError("You can't delete this model!", self)

class Undeletable(models.Model):
    objects = models.manager.BaseManager.from_queryset(UndeletableQueryset)()
    
    # your fields here

    def delete(self, using=None, keep_parents=False):
        raise models.ProtectedError("You can't delete this model!", self)

And just like that, you have blocked all the ways that your model instances can be deleted, and on top of that you raise an error whenever someone tries to delete them.

Note how this behavior is preserved through the chaining of filters and excludes because each queryset that is returned is still an instance of UndeletableQueryset which overrides the delete method.

Chon answered 24/7, 2023 at 9:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.