Poster image for this article
Source: LizzyLemonade on Imgur

Protecting Django Model Instances

Share this post:

Recently, I had a task where I needed to create a ‘Category’ model for a user, such that a default option would always be present. Users could create, edit, and delete any other instance of this ‘Category’ model, except for deleting the default option (users still could edit the default). Django field types and field options give developers a great degree of control, but I couldn’t quite assemble what I needed from stock options. Rather, I found that I needed a three step approach: write a migration to ensure the default option is present, register a pre_delete signal on the ‘Category’ model to raise an exception, and catch the exception on the admin interface.

Migration

The data model for the feature in quesiton consists of ‘Graph’ which has a ‘Category’. In this case, my migration had to do two things, prepare the ‘Category’ model, and modify the foreign relationship on ‘Graph’ to set a default value and an “on_delete” option. Preparing the ‘Category’ model required some cleanup. I first deleted all existing instances from the database, and created my default value at pk 1, like so:

from django.db import migrations

def create_general_topic(apps, schema_editor):
    Graph = apps.get_model('main', 'Graph')
    Topic = apps.get_model('main', 'Topic')

    Graph.objects.all().update(topic=None)
    Topic.objects.all().delete()
    t = Topic.objects.create(name='General', pk=1, order=1)
    Graph.objects.all().update(topic=t)


class Migration(migrations.Migration):
    dependencies = [
        ('main', '0051_set_graph_order')
    ]
    operations = [
        migrations.RunPython(create_general_topic),
    ]

This is all pretty standard, straight from Django’s docs. You can’t import models directly into a migration, rather you access them via the apps object passed in.

pre_delete Signal

Django models have a number of signals, which are a manifestation of an Observer design pattern. A programmer can register function(s) to be called at various stages of a Django model’s lifetime. In this case, I wanted my function to check that the ‘General’ topic wasn’t being deleted. In the general case, there’s much a developer could do at this point. Django’s docs explain how to use signals, and explain which signals are available.

Django offers a handy decorator to register methods to signals. In particular, I was interested in checking that the instance being deleted wasn’t pk 1:

@receiver(pre_delete, sender=Topic)
def default_topic_handler(sender, instance, **kwargs):
    if instance.id is 1:
        raise ProtectedError('The General topic can not be deleted', instance)

Admin Interface

If I try to delete the ‘General’ category, it will raise a ProtectedError. Great, except that if the exception is left unhandled it will percolate up, and return a 500 error to the user. This is not what we want.

We have to override three methods of the ModelAdmin class to ensure that we catch the exception and handle it appropriately. Specifically delete_view, response_action, has_delete_permission:

class TopicAdmin(OrderedModelAdmin):
    list_display = ('name', 'move_up_down_links')

    def delete_view(self, request, object_id, extra_context=None):
        try:
            return super().delete_view(request, object_id, extra_context=None)
        except ProtectedError:
            msg = "{} can not be deleted." 
                .format(self.model.objects.get(id=object_id).name)
            self.message_user(request, msg, messages.ERROR)
            opts = self.model._meta
            return_url = reverse(
                'admin:{}_{}_change'.format(opts.app_label, opts.model_name),
                args=(object_id,),
                current_app=self.admin_site.name,
            )
            return HttpResponseRedirect(return_url)

    def response_action(self, request, queryset):
        try:
            return super().response_action(request, queryset)
        except ProtectedError:
            msg = "This object can not be deleted."
            self.message_user(request, msg, messages.ERROR)
            opts = self.model._meta
            return_url = reverse(
                'admin:{}_{}_change'.format(opts.app_label, opts.model_name),
                current_app=self.admin_site.name,
            )
            return HttpResponseRedirect(return_url)

    def has_delete_permission(self, request, obj=None):
        return super().has_delete_permission(request, obj) and (
                not obj or obj.id is not 1
            )

The above does two main things. The delete_view and response_action handle the exception if the user takes some action on the admin interface. The last method override, has_delete_permission is used to show/hide the delete button on the admin interface. In this case, its used to hide the delete button when viewing the default model that we’d like to preserve.

For the full context, you can see the pull request on Github.

End of this article.

Printed from: https://compiled.ctl.columbia.edu/articles/django-protect-model-instances/