Hacking Django Admin

Robert Crowther Jan 2022
Last Modified: Feb 2023

There is a massive page of documentation on Django admin. Surely there is nothing more to say?

Overall Hints

Talking about the last point, I have found no satisfactory way to hack the ‘index’ menu, the current form setup can’t handle simple URL parameter passing (try sessions), alternative forms dispose of many of the ‘admin’ class facilities, and submission becomes a rewrite of ‘admin’ model provision. This is too much trouble.

If you create a separate form, the decisions are not simple. Do you hack a modelform to display and supplement the information you need, or do you construct a custom form? Noiw, I will note that adding a field and handling it is not too difficult, but the overrides are extensive. That said a modelform has no builtin facility to display‐only field data (‘admin’ has an ‘uneditable’ setting) so this needs more hacks. And adding data from other models makes locked dependencies. On the other hand, a ModelForm can be integrated into Django Admin. A custom form looses all Admin code, that is, you start with a basic form with no way to easily involve admin styling, and admin functionality like submission routing, permissions etc. Django documentation spends a little time discussing validation, but other fundamentals are not covered.

I’ve tried a few times, constructed custom admin forms from adapted models down to bare form elements, and between the implementation and documentation it is a difficult process, often proving impossible. You’ll have a long job getting a custom form even near to admin, and will never integrate it.

Documentation of forms. An access point, yet no mention of URLs,

https://docs.djangoproject.com/en/3.1/topics/forms/

Documentation of ModelForms,

https://docs.djangoproject.com/en/3.1/topics/forms/modelforms/#modelform

Django Form Reference,

https://docs.djangoproject.com/en/3.1/ref/forms/

Overview

Django users and Django documentation make a point that Django contains code to generate admin pages. I find this a dubious claim. Any CMS has admin pages. It’s near‐enough the definition of a CMS that it has a frontend for manipulation of data, so there should be admin pages. That said, Django is not a CMS, it is a hefty framework, and some frameworks are a bare minimum which will not include admin. So maybe there is a case.

Django’s Admin pages come wired into a permissions system and a full flow of change and delete forms. Again, standard for code that would call itself a CMS, but marking Django out from other ‘web frameworks’.

It’s possible to use Django’s admin without any interference at all, it works out of the box. For many projects that all that is needed.

Django documentation warns,

If you need to provide a more process‐centric interface that abstracts away the implementation details of database tables and fields, then it’s probably time to write your own views.

Some posts go so far as to say that Django Admin is not for front‐end work. I don’t know if the writers of those posts realise that they are pushing those documentation warnings further forward than the source documentation? Is that from bitter experience? There is no clear line line between ‘admin’, ‘trusted’ and ‘front‐end user’, as I’m sure any security consultant will tell you.

Django provision for customisation

The Admin code mainly lets you,

You can find information on all these actions in the Django documentation.

You could argue Django also lets you template the forms and apply new CSS. I would argue that on a coding level, that’s a hack. It’s available, but there’s no support for it (e.g. in Drupal, I could ‘change’ a logo).

The language of Django Admin

Usually I find glossaries a waste of time, but with code as undocumented as Admin, here’s a list of words,

Actions

Actions are the popdown box you see on the changelist. They are not tools

object tools

object tools are those buttons usually at the top right of both lists and forms e.g. ‘Add

Fieldset

Nothing to do with formset. It’s the Django implementation of the HTML ‘fieldset’ element that groups elements in a form. Note that Django admin can also make fields vanish using the ’fields’ attribute.

formset

Multiple forms on one page. In Django Admin, that means multiple instances of the same form, not multiple different forms building as partials https://docs.djangoproject.com/en/3.1/topics/forms/formsets/

Popup

A way of using near‐deprecated browser technology in the cause of engineering. See foreign keys

Inline Admin

How Django handles it’s foreign keys in Admin (Django foreign keys are another story)

Index

The list on the left of ModelAdmins. Admin users have access

Changing the look

This is fully documented. But a few words about,

Prepopulate

I said that prepopulate prepopulates slug fields. Can you use the prepopulate code to prepopulate anything other than slugs? For example, could a content box prepopulate a teaser field entry? No. Prepopulate is heavily fixed on slug fields, so much so that it calls a URL conversion on it’s projected contents.

Prepopulate is a nice idea, a halfway between fussy forms and generating content, or a dynamic substitute for defaulting content. If you would like to write your own prepopulate, beware. As I recall, the code slides into all areas of the Admin code, including context, template and Javascript.

Foreign keys

A foreign key is a connection between two database tables. Admin provides an automatic link between the two tables. If you click on the foreign key, a popup box appears that enables you to edit the table entry the foreign key points to. You can adapt this to have a simple Chooser, which chooses from the records. Select boxes do not scale well, as they can only hold a few items, which is why there are some alternative widget options. Overall, this a useful set of options.

Can you just choose to show the key itself? No. Can you choose to show fragments of the table, or just a single field to be edited? No.

Going further

Now this is where it gets interesting. You have a project where you want different forms or a new workflow.

If you want to go further, I must give you a big warning. Djangos admin code lives in contrib/admin. If you delve into there you are in for a shock. The code is tiring and extensive. It is littered with hooks, protection, all‐purpose code, and convenience classes/methods. It is largely undocumented. Yes I’ve seen other code handle this with way more clarity (though, you may argue, with less security or whatever).

And don’t forget the warning from the overview. Django Admin documentation says,

If you need to provide a more process‐centric interface that abstracts away the implementation details of database tables and fields, then it’s probably time to write your own views.

To me this sounds convenient. Everyone has written documentation like this at some time or another. There’s a resonance from it, ‘We got this far, it was enough, after that, you’re on your own’. What we want to know is, what kind of things can be done with the admin forms themselves? Can flow be altered, how much, and where? Due to the code, which bristles with hooks and overloads and whatever, I’m not promising to cover everything. But I don’t see any documentation that tries, so here we go.

Overview of the Django Admin operation

First thing to say, Admin mainly deals with models. If you want it to do anything else you are in for big problems (say, some really custom forms that simply use and integrate into Django admin styling and workflow, or cross‐model forms).

The entry point for Django admin is to make a ModelAdmin object. Each one will deal with a model.

ModelAdmins get wrapped in an AdminSite, which is where the ModelAdmins are registered, and URLs are derived.

Instances of ModelAdmins sit it in an app file ‘admin.py’. If you’ve followed the Django Tutorials and all that, you probably have something like,

class TermParentAdmin(admin.ModelAdmin):
    fields = ('tid', 'pid')
admin.site.register(TermParent, TermParentAdmin)

Onwards.

The ‘options.py’ file

To be honest, the main interest lies in ‘contrib/admin/options.py’. This creates a ModelAdmin.

A ModelAdmin is at base a view, or multiple views in one file. It accepts Model objects, generates new forms, and runs admin actions like ‘delete’. At the time of writing, ‘options.py’ is 2000+ lines of super‐precise Python code.

Here’s a way in. Possibly the most interesting method is,

def _changeform_view(self, request, object_id, form_url, extra_context):
    ...

Note that this is an internal method. Note also that it handles both ‘add’ and ‘update’ actions. The external method changeform_view() wraps this in a database transaction.

To go with this is,

def changelist_view(self, request, extra_context=None):
    ...

Nearly everything on changelist is overridable. Why, I am not sure. Surely what is needed is the ability to react to URL queries and deliver a queryset? Plus some column presentation. But what you get is the ability to override the Changelist class, which is only a helper anyhow. But Django code is always there for a reason, so someone must have felt the need. I must have missed something.

A few notes on getting to grips with the ‘options.py’ file,

The issue with deletion

Now, here it is. To change how deletion is done, you override delete, yes? Works as expected. Until you try the bulk delete in ‘actions’ on the changelist. Shock—it’s an SQL‐only action. Won’t trigger your custom delete method on the model. Leaves the database in a mess.

Why would you be doing a custom delete? Probably to clear up data across related tables. Which is not only natural in SQL, but normalisation thinking encourages you to split data like this.

There is a hint in documentation about deletion, that the post‐delete signal will be fired on singular model deletes and also on bulk changelist deletes. So you can use a signal. And people do, I’ve done it myself and seen code. Problem is, as I’m sure everyone who has done this is aware, signals are often bad code. For one thing, signals are expensive. If that doesn’t grab you, the aesthetic of putting often close‐coupled actions in different places is bad—if my record of an author disappears, I want her/his books to disappear too, and this should be stated in one block of code. And signals can make a mess of transactions on the database.

Anyway, there is a method in the admins,

def queryset_delete(self)

At simplest, you can override like this,

def queryset_delete(self)
    for o in self.queryset():
        self.delete(o)

Instant‐DRY code. But slow. Remember, optimise only when necessary.

Preserved_filters

‘preserved_filters’ is inside the forest of Admin. And it’s not well documented.

You have a list of models. You move to edit one of the models. After editing, or deletion, the natural place to return is to the list of models. However, if you follow the stateless nature of the web, the list of models will return to whatever is default. Sorts and other adaptions are lost.

The way the list is ordered, in Django, is using a querystring after the URL. You’ll notice, if you seek all objects by date, a little ‘?date’ querystring will appear. To preserve this order over edits, the querystring is stripped and compacted into a special querystring called ‘_changelist_filters’. You’ll see this appear on the edit page URL. Say you are deleting a Model called Page,

http://host/admin/taxonomy/page/delete/?_changelist_filters=date

and admin will check for, then rebuild, the filter on return.

Adding data to an Admin‐generated form

Meaning adding extra info to the display, say, the list form.

At first, this would seem impossible. Messing with a Django form means messing with the declarative syntax and so forth, and as for what happens to that form in a ModelAdmin, it will be chewed up. But there is an answer. Personally, I find this ugly programming, and if it seems that way to you, that’s maybe why it didn’t occur to you on first base. Ignore the details and plaster data into the template via a context,

def _changeform_view(self, request, object_id, form_url, extra_context):
    # add data to context
    r = super()._changeform_view(request, object_id, form_url, extra_context)
    # Personally, I think this is horrible
    r.context_data['tree_name'] = ...some data...
    return r

def changelist_view(self, request, extra_context=None):
    # add taxonomy_id to context
    # Personally, I think this is horrible
    r = super().changelist_view(request, extra_context=None)
    if (not (isinstance(r, HttpResponseRedirect))):
        r.context_data['tree_name'] = self.model._meta.verbose_name_plural
    return r

Contexts are large and contain model and object details and so forth. Sometimes the data you need is present.

Adding/removing fields on forms

Removing fields is pretty easy. It’s controlled by the ‘fields’ attribute of an admin, which is documented.

Adding a field is also not as hard as it seems. A ModelForm is able to accept an extra field by overriding. Then you set this new form as the form on the ModelAdmin, then override the ‘delete’ or ‘changeview’ methods. This is one of the few feasible adaptions to a form in Django Admin—though it needs new files and hefty overrides.

Advanced displays in Django Admin

Once you get beyond displaying a couple of items of data, you may want to make a more visually refined display, such as a chart or a map. Despite the warnings given by Django documentation, it is clear some people find this useful—a lot of time, code and talent has been thrown at the issue.

Most solutions use Javascript to deliver information passed through a context or, more advanced, served up by JSON. For some handcrafted solutions using HTML and injected data, this post and this post show how people add charts created with JavaScript. Or try a web search for,

django admin dashboard

And you’ll find apps that can make grid displays of graphs, maps etc.

Changing workflow

This means, really, creating URLs for forms, the forms they show, then handling the returns from those forms. Bear in mind, I’m saying this is a dead‐end, a cul‐de‐sac, a blind alley. Best you can hope for is that the process will refine your expectations.

Linking

Creating changelist columns of links is easy. The changelists are bristling with hooks. You can href externally,

from django.contrib import admin
from django.utils.html import format_html

class PageAdmin(admin.ModelAdmin):
    # add new column definition here
    list_display = ('title', 'licence_link',)

    # define a new column of links
    def licence_link(self, obj):
        return format_html('<a href="{}" class="button">Add</a>',
            obj.license_terms_url()
        )
    # Set a column header text
    licence_link.short_description = 'Licence Terms'

Note the sad inline class ‘button’ to avoid re‐templating or multiple‐templates. If you think I want to re‐template Admin for a button… You can point to something internal using similar constructions or a reverse URL.

Can we adapt the navigation list on the left of admin pages, the Index? No. AsFarAsIKnow. See you are dealing with the class AdminSite, as described above (note that, confusingly, AdminSite has nothing to do with the app Sites). AdminSite is realised into one instance. This provides all the links that get templated into the Admin ‘index’. So how are we going to change the default AdminSite? Are there any hooks? No. Well, you could make a new AdminSite. The class seems to encourage that. But do you want that? To alter a link or two? With it’s new base URL (not ‘django‐admin’)? I think not. It’s too heavyweight for purpose. But that said, there is no way into AdminSite. A few options on ‘register’ would help, such as ‘no‐link’, but you don’t have those.

So AdminSite is intractable. You cant use it to add or modify links. It only works to auto‐gather links from ModelAdmins.

Changing how forms react

This is a killer. The usual way and fast way, given what we have and potential users, would be to pass URL queries along. But Django is fussy. Not possible to deconstruct a URL query in a form, is it? Yes, but the cost is high,

def __init__(self, *args, **kwargs):
    someFieldName = self.get_field_data(self, 'someFieldName', args, kwargs)
    # Do something with the retrieved value...

def get_field_data(self, fieldname, args, kwargs):
    # Get field data which may be on instance or in a query URL
    field_data = None
    instance = kwargs.get('instance')
    if (instance):
        # probably an 'update'
        field_data = getattr(instance, fieldname)
    else:
        # An 'add' or base form (used by admin)
        # May have field_data on the initial, if a query URL
        field_data = kwargs['initial'].get(fieldname, None)

        if (not field_data and args):
            # No such luck. Maybe data was passed in Djangos
            # involuted parameter-passing URL gear.
            # This happens for example after 'add another'
            field_data = args[0].get(fieldname, None)
        if (not field_data):
            # Maybe the data is in a changlist preserved
            # filter, which happens on ''add model'?
            # Need to parse for that
            if ('_changelist_filters' in kwargs['initial']):
                clf = QueryDict(query_string=kwargs['initial']['_changelist_filters'])
                field_data = clf.get(fieldname, None)
    return field_data

No problem, huh?

Or you could use sessions. Here is a Guide to Django sessions. Django sessions with forms? Good luck, see you next millennium.

Form to form

Now, how to get from one form to another? Not too hard with Django’s URL system. If you are ok with hard links. Need some URLs for the forms themselves (though you will loose templating), then some redirect responses in a form template. But you will loose Django Admin styling on every extra form.

Summary of potential for adapting form workflow in Admin

You can not get forms started with the ’Index’ list, but you could get started with a changelist button. Making the forms is the usual weary job, Django or otherwise. Wiring between them should be easy enough, though it would get more difficult if you wanted semi‐automatic URL loading—for example, ‘contrib.auth’ uses a homemade ModelAdmin. But passing information across the forms is not possible by URL, pushing you back on sessions, which are a job. So huge, you can regard this as not possible.

Mocking Admin

One approach that has some appeal is to construct a new set of forms that hang off new URLs i.e. have nothing to do with Admin at all, but mimic the look.

This may be promising, but has two big downsides. First, you will loose all the Admin infrastructure. One serious loss is Permissions. Another is the bulk operations on changelists. Not to mention the knee‐deep issues with foreign keys. Of course, you can build these back in, but in Django that is not a trivial job.

The second problem may not seem like much, but as it turns out, it is. Mocking Django admin styling is not easy. It involves a a stack of CSS files, and the templates are impossible to reuse in exact simulation. For the never‐say‐die amongst you, start with,

{% extends "admin/base_site.html" %}

It’s at this point that you start to see why I feel the documentation is disingenuous.

How Admin handles a Delete

This is a description of a particular action builtin to Django Admin. Implementing similar activity over some other Admin may be possible. Take a look at ‘contrib.auth.admin.py’ if you want to try this. ‘auth.admin’ is a single‐model, single‐form, paradigm yet lists seventeen imports to get started. Meanwhile, the following may give you some clues about how to conceive of what you want to do.

This is all within a ModelAdmin. Delete starts with,

def delete_view

But there is a trick in this. If it gets a POST, it’s a confirmed submission. If not, it’s going to be a confirm form. This avoids transferring data across multiple form handlers. Starting with the general GET, moving to the POST, this leads to,

def render_delete_form

Which renders,

delete_confirmation.html

which consists of an,

Note that Cancel is a Javascript action that accounts for closing a window if using popup forms.

The Confirm takes us back to,

def delete_view

Which now acts,

def log_deletion(self, request, object, object_repr):

Then,

def delete_model(self, request, obj):
    ...
def delete_queryset(self, request, queryset):
    ...

Finishing with,

def response_delete(self, request, obj_display, obj_id):

Which is mostly a HttpResponseRedirect to someplace appropriate, depending on if the form was a popup, if the object was successfully deleted, etc.

Summary

Well, I don’t know about the word ‘summary’. I’ve not even covered the multiform/inline form thing.

References

Django documentation, start page for Admin,

https://docs.djangoproject.com/en/3.1/ref/contrib/admin/

Less weary version of this page, bit more info on Permissions, same conclusions,apparently sourced from near Django development,

https://django-book.readthedocs.io/en/latest/chapter06.html