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 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
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 %}
Define a __str__
method in the model:
def __str__(self):
return self.name
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()
By default the user password hash, groups, tokens (if DRF) and more appear. Remove them with unregister
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 []
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"
)
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"
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,
]
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
Add ordering
to the model in question
class MyModel(models.Model):
...
class Meta:
ordering = ['username']
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")
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
)
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'
class TagAdmin():
search_fields = ("name",)
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 ""
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)
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.
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",)}),
)
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()
class CityAdmin(admin.ModelAdmin):
exclude = ('latitude', 'longitude')
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
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
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)
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]
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)
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"]
This cookbook is really helpful: https://buildmedia.readthedocs.org/media/pdf/django-admin-cookbook/latest/django-admin-cookbook.pdf