Django admin

This is part of the Semicolon&Sons Code Diary - consisting of lessons learned on the job. You're in the python category.

Last Updated: 2024-11-23

Django Admin

Django provides a 1st-party admin interface via admin.py

To set up a basic admin area for a model, use the @admin.register decorator as follows:

from .models import Experience
from django.contrib import admin

@admin.register(Experience)
class ExperienceAdmin(admin.ModelAdmin):
  pass

How to change the admin area title or logo etc.?

To change the title go to urls.py and add/modify

from django.contrib import admin
# Admin Site Config
admin.sites.AdminSite.site_header = "MySiteName"
admin.sites.AdminSite.site_title = "MySiteName"
admin.sites.AdminSite.index_title = "MySiteName"

To add a custom logo, it is a bit more complicated.

Create a file in ./templates/admin called base.html

Then just extend the built in admin/base.html file by overriding the branding block

% extends "admin/base.html" %}
{% block branding %}
<h1 id="site-name">
    <a href="{% url 'admin:index' %}">
        <img src="{% static 'my_logo.png' %}" height="40px" />
    </a>
</h1>
{% endblock %}

How to control the string representation of the model in list view

Define a __str__ method in the model:

def __str__(self):
    return self.name

How to make the string representation of a choice field use the display vs db version

Here the DB version is lower case and the display is capitalized on the first letter. To get the capitalizer version we use the method get_ATTRIBUTE_display()

NARRATOR_NAME_CHOICES = [
    ("anna", "Anna"),
    ("jan", "Jan"),
]

name = models.CharField(max_length=512, choices=NARRATOR_NAME_CHOICES)

def __str__(self):
    return self.get_name_display()

Remove unwanted defaults

By default the user password hash, groups, tokens (if DRF) and more appear. Remove them with unregister

Autocomplete

You can use autocomplete fields if the referenced model defines autocomplete_fields

Class VenueAdmin():
  # This assumes that the `Venue` model has an `experience` attribute
  # with a belongs_to relationship.
  autocomplete_fields = ['experience']

If you want to filter the results shown in autocomplete based on some other attribute, here is some inspriation code:

# The situation was that we wanted the autocomplete fields on the Playlist Element.experience related model (many to many overall) to filter based on the city of the playlist element. The approach was to hide the inline/related elements on object creation and then show them only when editing existing objects. Then we override the `get_search_results` method to take action depending on the referer (i.e. which admin action is doing the calling)

class ExperienceAdmin
    # order to filter results.
    def get_search_results(self, request, queryset, search_term):
        http_referer = request.META.get("HTTP_REFERER")
        # Only trigger this code if we are editing a playlist. Then
        # store the playlist id in a regex matching group.
        match = re.match(r".+playlist\/(\d+)", http_referer)
        if match:
            playlist_id = int(match[1])
            playlist = Playlist.objects.get(pk=playlist_id)
            queryset, may_have_duplicates = super().get_search_results(
                request,
                queryset.filter(venue__city=playlist.city),
                search_term,
            )
        # Otherwise use default behavior
        else:
            queryset, may_have_duplicates = super().get_search_results(
                request,
                queryset,
                search_term,
            )
        return queryset, may_have_duplicates

class PlaylistAdmin(admin.ModelAdmin):
    inlines = [
        PlaylistElementInline,
    ]

    # This code hides the inline instances during creation
    # of the Playlist object. This is necessary here
    # because we want the selection of Experiences shown
    # in the Playlist elements to be filtered by the city
    # the Playlist is associated with. But we need a saved
    # Playlist in order to do this.
    def get_inline_instances(self, request, obj=None):
        return obj and super(self.__class__, self).get_inline_instances(request, obj) or []

How to add hint text to a field in the admin area

Add help_text to the model itself (vs. admin area)

date = models.CharField(
    max_length=160, blank=True, default="", help_text="e.g. 21st Century"
)

How to change the name of the model as it appears in the primary admin page

E.g. If you model is called "Playlist" how to change to "Audio Playlists"?

# Within the models.py
class Playlist(...):
    class Meta:
        verbose_name = "Audio Playlist"
        verbose_name_plural = "Audio Playlists"

How to add associations (e.g. add sub-form elements corresponding to a has-many item)

An Experience could be something like a concert. It has many occurrences (e.g. different dates on which the concerts happen). This is how you integrate the two in the admin rea

class ExperienceOccurrenceInline(admin.TabularInline):
    model = ExperienceOccurrence

    # This controls the number of extra forms the formset will display in addition to
    # the initial forms. If you want to customize this such that a different number
    # show up on new records vs edited records, see the commented `get_extra `method
    extra = 3
    # def get_extra(self, request, obj=None, **kwargs):
    #    extra = 3
    #    if not obj:
    #         return extra
    #    else:
    #         min(0, obj.some_association.count())

class ExperienceAdmin(model.ModelAdmin):
    ...
    inlines = [
        ExperienceOccurrenceInline,
    ]

How to control what columns appear in the list display

 Class SomethingAdmin():
   list_display = ("id", "body", "upvote_ratio",) # this last one is calculated instead of on column

    # we use a custom query set which carries out the annotation of data used for the calculated stuff
    def get_queryset(self, request):
        return super().get_queryset(request).with_upvote_ratio()

If you want to have columns showing related model, this is possible but a little roundabout becaues weirdly this do not support the __ operator used for example in the filters. Anyway let's create an example: imagine if SomethingAdmin belonged to a Group which belonged to a User. Then write this

class SomethingAdmin(admin.ModelAdmin):
    list_display = ("get_user",)

    # You can do translations here
    @admin.display(description=_("user"))
    def get_user(self, obj):
        return obj.group.user

How to get select fields (for foreign keys) to be non-randomly ordered?

Add ordering to the model in question

class MyModel(models.Model):
    ...

    class Meta:
        ordering = ['username']

How to get a reference to the id of the instance being changed in the admin area?

Do this within a method of your admin model. The core idea is to work through the resolver on the request object

learning_item_id = request.resolver_match.kwargs.get("object_id")

How to filter what foreign key records show up in a select box?

The big idea is to override formfield_for_foreignkey and then modify the kwargs['queryset'] bit

class LearningItemAdmin(BaseCollectibleAdmin):
    def formfield_for_foreignkey(self, db_field, request=None, **kwargs):
        """
        We use this to customize the drop-down menu for `featured_image` such that
        it only contains images connected to this learning item.
        """
        if db_field.name == "featured_image":
            learning_item_id = request.resolver_match.kwargs.get("object_id")
            kwargs["queryset"] = LearningImage.objects.filter(parent=learning_item_id)
            breakpoint()
        return super(LearningItemAdmin, self).formfield_for_foreignkey(
            db_field, request, **kwargs
        )

How to order by a calculated field

 Class SomethingAdmin():
    list_display = ("id", "upvote_ratio",) # this last one is calculated

    # we use a custom query set which carries out the annotation of data
    def get_queryset(self, request):
        return super().get_queryset(request).with_upvote_ratio()

    # We define a method to extra the data on individual objects
    # Sorting in admin fails if this is a static method
    def upvote_ratio(self, obj):  # pylint: disable=no-self-use
        return obj.upvote_ratio

    # most important: we set the admin_order_field on that function we just defined
    upvote_ratio.admin_order_field = 'upvote_ratio'

How to add a search through a field

class TagAdmin():
    search_fields = ("name",)

How to show rich HTML like a video or audio player

from django.utils.encoding import escape_uri_path
from django.utils.html import format_html, mark_safe

Class SomethingAdmin():
  readonly_fields = ("image_preview",)

  def image_preview(self, instance):  # pylint: disable=no-self-use
      # The formset (group of forms) displays a certain number of forms for new items
      # to be added. When these forms are displayed, they still use this property,
      # but, the instance is empty (as in ModelName())
      if instance.upload:
          return format_html(
              '<img src="{url}" width={width}/>',
              url=mark_safe(escape_uri_path(instance.upload.url)),
              width=300,
          )
      return ""

How to add a list of related items with just name link on show page?

Use readonly_fields and generate HTML in an admin method

from django.utils.html import mark_safe

class ExperienceAdmin(..):
  readonly_fields = ("experiences_list",)

  @staticmethod
  def experiences_list(obj):
      # each obj will be an Order obj/instance/row
      to_return = "<ul>"
      # I'm assuming that there is a name field under the event.Product model. If not change accordingly.
      to_return += "\n".join(
          "<li><a href='/admin/core/experience/{}'/>{}</a></li>".format(id, name)
          for id, name in obj.venues.values_list(
              "experiences__id", "experiences__name"
          )
      )
      to_return += "</ul>"
      return mark_safe(to_return)

How to add filters in list areas?

If you are working with the model ExperienceOccurrence and want to filter by starts_at and ends_at, then use the following:

class ExperienceOccurrenceAdmin(admin.ModelAdmin):
    list_filter = ("starts_at", "ends_at",)

If you want to filter by some other model that is connected, this is possible too by using the __ operator. For example, imagine if ExperienceOccurrence belonged to an Experience which belonged to a Venue. Then write this filter:

class ExperienceOccurrenceAdmin(admin.ModelAdmin):
    list_filter = ("experience__venue",)

If you want this filter to be autocomplete, look at the django-admin-autocomplete-filter package.

How to modify the User admin

Be careful - this must inherit from a different class than the usual admin.ModelAdmin. This is because it has to support safe password additions. If we inherited from the normal admin.ModelAdmin, we would have unhashed passwords, which is not what we want!

from django.contrib.auth.admin import UserAdmin

@admin_register(User)
class CustomUserAdmin(UserAdmin):
  pass

  class Meta:
      model = models.User

If you want to add extra fields for when you are creating a user from the admin area, override add_fieldsets. Below is how we would add a single attribute, is_teacher. The stuff for username, and password1 and password2 are the Django defaults which you will probably want to include.

class CustomUserAdmin(UserAdmin):
    ...

    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("username", "password1", "password2", "is_teacher"),
            },
        ),
    )

If you want to add an extra field for when you are editing a user (this does NOT come for free when you override add_fieldsets for creating a user, then you'll need to override the fieldsets field. Here is how to add is_teacher there:

class CustomUserAdmin(UserAdmin):
    ...
    fieldsets = (
        (None, {"fields": ("username", "password")}),
        (_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
        (
            _("Permissions"),
            {
                "fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions"),
            },
        ),
        (_("Important dates"), {"fields": ("last_login", "date_joined")}),
        # NB: The important bit
        (_("Additional info"), {"fields": ("is_teacher",)}),
    )

How to add computed fields to the list view?

class VoterAdmin(admin.ModelAdmin):
    list_display = ("last_voted", "num_likes" )

    @staticmethod
    def last_voted(obj):
        return obj.most_recent_vote().created_at

    @staticmethod
    def num_likes(obj):
        return obj.liked_experiences().count()

How to exclude some fields from being seen/edited in the admin area?

class CityAdmin(admin.ModelAdmin):
    exclude = ('latitude', 'longitude')

How to translate model name in admin area?

For most fields, pass in as the first argument to XField, the gettext function (usually used as _) with a translation string

from django.utils.translation import gettext as _

latitude = models.DecimalField(
    _("latitude")
)

However there is an exceptional format for ForeignKey, ManyToManyField and OneToOneField, where we must use verbose_name as an argument instead:


narration = models.ForeignKey(
    "Narration", blank=True, null=True, on_delete=models.PROTECT, verbose_name=_("narration")
)

You'll also want the same for the verbose_name stuff (etc.) in Meta

class Place(BaseModel):
    class Meta:
      verbose_name = _("place")
      verbose_name_plural = _("places")

And don't forget help_text

models.CharField(help_text=_('e.g. 21st Century'))

You might also want to change some stuff in the admin area (e.g. for custom fields)

# admin.py
class AudioGuideAdmin(BaseCollectibleAdmin):
    readonly_fields = ("audio_preview",)

    # The @staticmethod needs to come first for some reason
    @staticmethod
    # NB: This is the critical bit!!
    @admin.display(description=_("audio_preview"))
    def audio_preview(instance):
        if instance.upload:
            return format_html(
                '<audio controls><source src="{url}"/></audio>',
                url=mark_safe(instance.upload.url),
            )
        return ""

Now to create the po files where you enter in the translations, run the following

django-admin makemessages -l de --ignore=.venv # Usually `make add_missing_translation_keys` in our code

Now add the missing translations. Lastly you need to compile them:

django-admin compilemessages # Usually `make recompile_translations`

You'll also need to restart your server

Now, to try out the translations, make sure the settings has

USE_I18N=True

How to prevent admin generally from adding or editing a field?

class SystemReportAdmin(admin.ModelAdmin):
  # We don't allow admin to create reports
  def has_add_permission(self, request, obj=None):  # pylint: disable=arguments-differ,unused-argument
      return False

  # We don't allow admin to edit reports
  def has_change_permission(self, request, obj=None):
      return False

How to add a custom filter?

Let's say we want to add a list filter that turns certain word values into database query scopes:

class ExperienceOccurrenceFilter(admin.SimpleListFilter):
    # Human-readable title which will be displayed in the
    # right admin sidebar just above the filter options.
    title = 'Occurrence time'

    # Parameter for the filter that will be used in the URL query.
    parameter_name = 'occurrence_time'

    def lookups(self, request, model_admin):
        """
        Returns a list of tuples. The first element in each
        tuple is the coded value for the option that will
        appear in the URL query. The second element is the
        human-readable name for the option that will appear
        in the right sidebar.
        """
        return (
            ('no_data', 'No data available'),
            ('past', 'Past'),
            ('future', 'Future'),
        )

    def queryset(self, request, queryset):
        if self.value() == 'past':
            return queryset.in_past()
        if self.value() == 'future':
            return queryset.in_future()
        if self.value() == "no_data":
            return queryset.no_occurrences_data()

class ExperienceAdmin(ImportExportModelAdmin):
    # Add the filter here
    list_filter = (ExperienceOccurrenceFilter)

How to add a special action that can be run on records?

def send_special_offer_push_notifications(_, request, queryset):
    for experience in queryset:
        batch_send_special_offer_notifications(experience)

class ExperienceAdmin(ImportExportModelAdmin):
    actions = [send_special_offer_push_notifications]

How to modify something before it is saved by the admin?

Change the save_model method in the admin class

@admin.register(Venue)
class VenueAdmin(admin.ModelAdmin):
    # This makes sure every admin item has `already_seen` set if processed by admin
    def save_model(self, request, obj, form, change):
        obj.already_seen = True
        super().save_model(request, obj, form, change)

Performance Gotcha: Never use the default select field for associated records

For example we had code for Venue and each venue has at attached default_experience. There were 50k of these in the DB. By default, Django creates a select box with 50k options. This crashes chrome.

Avoid by doing by either excluding or by converting it to an autocomplte field


class VenueAdmin(admin.ModelAdmin):
    exclude = ("default_experience")
    # OR
    autocomplete_fields = ["default_experience"]

Resources

This cookbook is really helpful: https://buildmedia.readthedocs.org/media/pdf/django-admin-cookbook/latest/django-admin-cookbook.pdf