Rzeczy które powinieneś znać w adminie Django


Zapoznaj się z kilkoma poradami które sprawią, że praca z adminem Django będzie jeszcze ciekawsza.


O frameworku Django można podziedzieć wiele. Ma on rzeszę swoich zwolenników i przeciwników jak każdy inny framework. Na pewno jedną z większych zalet jest szybkość tworzenia aplikacji. Prostego bloga można postawić na nim w mniej niż 30 minut. Wliczając w to czas na instalację i konfigurację. Dla każdego kto chce spróbować polecam zapoznać się z filmem przygotowanym przez kolegów z obecnej pracy dostępnego na YouTube pod adresem: https://www.youtube.com/watch?v=1Z5--nDwRFU

Aplikacje tworzy się bardzo szybko, bo kod jest podzielony na logiczne fragmenty zgodnie ze wzorcem MVT(model – view – template), który działa analogicznie do wzorca MVC . Ma wbudowany ORM, który jest bardzo wygodny w obsłudze. Zawiera również szereg elementów, których programista nie musi tworzyć. Należą do nich wbudowany system uwierzytelniania oraz panel administracyjny.  Panel ten jest bardzo rozbudowanym narzędziem i ma ogrom możliwości, które warto poznać. Właśnie dlatego chciałbym Wam go dziś nieco przybliżyć.

W tym wpisie podam kilka porad, które sam chciałbym przeczytać, gdy zaczynałem pracę w tej technologii.

Koniecznie używaj SSL

Jeżeli nie masz wdrożonego HTTPS na swojej stronie to jesteś bardzo łatwym celem różnych ataków, zwłaszcza jeżeli logujesz się do panelu w miejscach publicznych takich jak kawiarnie, lotniska czy centra handlowe. Więcej na temat SSL znajdziesz w dokumentacji .

Zmień adres URL

W większości poradników panel administracyjny zarejestrowany jest pod adresem /admin/. Zmiana adresu jest bardzo prosta a może stanowić kolejną cegiełkę w budowaniu bezpiecznej aplikacji. Poniżej przykład jak zarejestrować admina pod innym adresem:

urlpatterns = [
    path('my_secure_admin/', admin.site.urls),
]

Stwórzmy model artykułu

Do dalszych rozważań będzie nam potrzebny model. Poniżej zaprezentuję strukturę przykładowych modeli, które będziemy wykorzystywać w dalszej części wpisu. Są to bardzo proste modele. Kategoria składająca się z 3 pól oraz artykuł w skład którego wchodzi 7 pól z czego jedno to klucz do kategorii:

class Category(models.Model):

    active = models.BooleanField(verbose_name=u'aktywny', default=False)
    title = models.CharField(max_length=255, verbose_name=u'tytuł')
    slug = models.SlugField(unique=True)

    class Meta:
        verbose_name = u'Kategoria'
        verbose_name_plural = u'Kategorie'

    def get_absolute_url(self):
        return reverse('category-detail', kwargs={'slug': self.slug})


class Article(models.Model):

    active = models.BooleanField(verbose_name=u'aktywny', default=False)
    pub_date = models.DateTimeField(verbose_name=u'data publikacji', null=True, blank=True)
    title = models.CharField(max_length=255, verbose_name=u'tytuł')
    slug = models.SlugField(unique=True)
    body = models.TextField(verbose_name=u'treść')
    lead = models.TextField(verbose_name=u'zajawka')
    category = models.ForeignKey(Category, verbose_name=u'kategoria', on_delete=models.CASCADE)

    class Meta:
        verbose_name = u'Artykuł'
        verbose_name_plural = u'Artykuły'

    def get_absolute_url(self):
        return reverse('article-detail', kwargs={'slug': self.slug})

Przejdźmy do możliwości rejestracji panelu

Istnieje kilka rozwiązań dodawania panelu edycji naszego modelu. Najprostszym jest rejestracja całej klasy bez dodatkowej konfiguracji. Odbywa się to poprzez dodanie w pliku admins.py linijki:

admin.site.register(Article)

W ten oto sposób możemy cieszyć się pełnym panelem administracyjnym. Mówiłem, że praca z Django jest bardzo szybka ;)

Rozwiązanie to ma kilka wad. Najważniejsza to to, co wspomniałem na początku czyli brak konfiguracji. Jeżeli natomiast nie zależy nam na dostosowaniu panelu dla tego modelu, to jest do dobre rozwiązanie. Inną wadą jest wyświetlana lista. Część z Was zapewne zauważyła, że wszystkie artykuły podpisane są jako Article object . Nie jest to wygodne, bo w razie edycji nie wiadomo, o który artykuł chodzi. Można to sprawnie poprawić dodając metodę __unicode__ w modelu:

class Article(models.Model):
    [...]
    def __unicode__(self):
        return self.title

Teraz lista stała się czytelniesza i każdy wiersz został opisany tytułem artykułu.

Rejestracja przez klasę

Jeżeli chcemy nieco dopasować panel do naszych potrzeb należy użyć innego sposobu. W pracy spotkałem się z dwoma zapisami, które stosuje się zamiennie w zależności od preferencji. Pierwszy polega na zaimplementowaniu nowej klasy z podstawową konfiguracją i we wcześniejszej metodzie rejestracyjnej dodaniu jej jako kolejny parametr. Brzmi strasznie ale jest bardzo proste, co widać na przykładzie poniżej:

class ArticleAdmin(admin.ModelAdmin): 
    model = Article
    list_display = ('id', 'title', 'category', 'active')
    
admin.site.register(Article, ArticleAdmin)

Dzięki takiej konfiguracji na liście mamy ID, tytuł, kategorię i informację o aktywności artykułu. Lista jest już znacznie bardziej przyjazna a nie stworzyliśmy jeszcze niczego specjalnego :)

Podobny efekt można uzyskać poprzez dekorator. Jeżeli interesuje Was wpis na temat dekoratorów to wpiszcie to proszę w komentarzach. Postaram się coś przygotować. W dużym uproszczeniu są to specjalnie przygotowane funkcje, których wykorzystywanie niejednokrotnie zwiększa czytelność kodu. Poniżej przykład jak to wygląda właśnie z rejestracją panelu administracyjnego:

@admin.register(Article) 
class ArticleAdmin(admin.ModelAdmin): 
    model = Article
    list_display = ('id', 'title', 'category', 'active')
    

Warto zapoznać się i dodać ten atrybut. Jeżeli go nie dodamy tego atrybutu to mamy bardzo dużo nadmiarowych zapytań do bazy. W moim przypadku było ich aż 100. Wynikało to z tego, że użyliśmy na liście do wyświetlania kategorii, która jest oddzielnym modelem i dla każdego wiersza wykonywało się zapytanie o konkretny obiekt. Przykład takiego zapytania poniżej:

SELECT
"blog_category"."id", "blog_category"."active", "blog_category"."title", "blog_category"."slug"
FROM "blog_category" WHERE "blog_category"."id" = '4'
  100 similar queries.   Duplicated 43 times.

Django Debug Toolbar podpowiada nam, że mamy właśnie 100 podobnych zapytań a aż 43 duplikaty. Te powtórzenia wynikają z tego, że 43 artykuły miały tę samą kategorię. Swoją drogą DJDT (bo taki jest skrót tego narzędzia) jest niezbędnikiem każdego dobrego dewelopera, więc jeżeli to również Was interesuje proszę o komentarz. Dodam wpis o konfiguracji oraz jego możliwościach. Uzupełnijmy zatem naszą klasę o ten atrybut i zobaczmy jaki będzie rezultat. Poniżej rozszerzenie klasy:

@admin.register(Article) 
class ArticleAdmin(admin.ModelAdmin): 
    model = Article
    list_display = ('id', 'title', 'category', 'active')
    list_select_related = ('category', )

Wyniki poprawiły się znacząco, bo ze 105 wszystkich zapytań mamy teraz tylko 6. Skąd taka różnica? Otóż Django sprytnie dołożył w zapytaniu INNER JOIN dzięki czemu można było pobrać kategorię od razu.

SELECT
"blog_article"."id", "blog_article"."active", "blog_article"."pub_date", "blog_article"."title", "blog_article"."slug", "blog_article"."body", "blog_article"."lead", "blog_article"."category_id", "blog_category"."id", "blog_category"."active", "blog_category"."title", "blog_category"."slug"
FROM "blog_article" INNER JOIN "blog_category" ON ("blog_article"."category_id" = "blog_category"."id") ORDER BY "blog_article"."id" DESC LIMIT 400

Do nawigacji i edycji przydają się linki

Czasem zachodzi potrzeba edycji powiązanych obiektów. Na przykład gdy zauważyliśmy, że zrobiliśmy literówkę w kategorii do której przypisany jest artykuł chcielibyśmy to jak najszybciej poprawić. Normalna droga byłaby taka, że sprawdzilibyśmy nazwę i po niej wyszukali naszą kategorię. Dokłada to nam kawałek dodatkowej, niepotrzebnej pracy. Można to zrobić szybciej poprzed dodanie jednej metody, która ułatwi korzystanie z całego panelu. Powoli wchodzimy w nieco bardziej zaawansowane kwestie ale po chwili zrozumiecie, że i to nie jest niczym skomplikowanym jak zna się solidne podstawy. Poniżej przykład jak to dodać.

class ArticleAdmin(admin.ModelAdmin):
    model = Article
    list_display = ('id', 'title', 'category_link', 'active')
    
    def category_link(self, obj):                                               
        category = obj.category                                                 
        url = admin_change_url(obj.category)                                    
        return u'<a href="{}" target="_blank">{}</a>'.format(url, category.title)
    category_link.short_description = u'Kategoria'                              
    category_link.allow_tags = True 

Warto zwrócić uwagę na trzy kluczowe elementy.

Po pierwszej zmieniliśmy w list_display category na category_link. Czyli teraz zamiast atrybutu wyświetlamy metodę. Ta również jest przedstawiona  w powyższym przykładzie. Jest to standardowa metoda w adminie, która jako parametr dostaje instancję obiektu. W naszym przypadku admin zarejestrowany jest na artykuł, więc dostajemy obiekt artykułu. Z niego wyciągamy kategorię i zwracamy adres url do edycji.

Drugą ważną kwestią są dodatkowe atrybuty do naszej metody, czyli short_description , pełniący rolę nagłówka kolumny oraz allow_tags , który zezwala na renderowanie tagów HTML.

Dociekliwi zapewne zauważyli, że metodzie wykorzystałem metodę admin_change_url . I to jest właśnie ostatni, najbardziej kluczowy element. Poza tym, przed chwilą pisałem, że wystarczy dodać jedną metodę a tu dodaję aż dwie. Jeżeli chcemy dodać takie linkowanie tylko w tym jednym modelu to faktycznie sprowadza się to do dodania dwóch metod. Proponuję jednak wynieść tę drugą na przykład do pliku utils.py , bo gwarantuję, że będziecie tego używać częściej niż myślicie ;)

Jest to w zasadzie funkcja a nie metoda i generuje ona link do edycji obiektu w adminie. W najprostrzej postaci wygląda następująco:

def admin_change_url(obj):                                                      
    if not obj:
        return ''    
    app_label = obj._meta.app_label                                             
    model_name = obj._meta.model.__name__.lower()                               
    return reverse('admin:{}_{}_change'.format(app_label, model_name), args=(obj.pk,))

Tę samą cechę można wykorzystać do jeszcze innego pomysłu. Mianowicie można na liście kategorii zrobić przycisk który przenosi do listy artykułów które ta zawiera. Brzmi ciekawie? A jest równie proste co poprzednio. Analogicznie jak wcześniej definiujemy funkcję w pliku utils.py , bo prawdopodobnie ona równiez przyda nam się kilka razy.

def admin_changelist_url(obj):
    if not obj:
        return obj                                       
    app_label = obj._meta.app_label                                           
    model_name = obj.__name__.lower()                                         
    return reverse('admin:{}_{}_changelist'.format(app_label, model_name))

I teraz w klasie kategorii dodajemy metod podpiętą na listę:

@admin.register(Category) 
class CategoryAdmin(admin.ModelAdmin):
    list_display = ('id', 'title', 'articles_link')
    
    def articles_link(self, obj):
        url = admin_changelist_url(Article)
        return u'<a href="{}?category_id={}" target="_blank">Artykuły</a>'.format(url, obj.pk)
    articles_link.short_description = u'Kategoria'
    articles_link.allow_tags = True

Filtrowanie artykułów

W powyższym przykładach widać, że aby odfiltrować obiekty wystarczy dodać odpowiedni parametr w adresie. Tu akurat dodany został category_id, dzięki czemu lista artykułów została ograniczona do tych z wybranej kategorii. Nasuwa się zatem pytanie czy można zrobić taki mechanizm nie przez przycisk w kategorii ale jako jakiś filtr w artykule? Owszem i jest to jeszcze prostsze. Dodajmy filtr po polu aktywny i kategorii.

class ArticleAdmin(admin.ModelAdmin):
    [...]
    list_filter = ('active', 'category')

Ze swojej strony dodałbym tylko, że filtry są wygodne jeżeli opcji do filtrowania nie jest dużo. W przypadku gdy kategorii miałoby być więcej niż 50 czy 100 to lista ta stałaby się zbyt długa przez co nie dałoby się z niej racjonalnie korzystać. Dlatego sam najczęściej filtry stosuję do prostych pól logicznych, lub tych wybieranych ale ze skończoną liczbą możliwości do wyboru, czyli np płeć, województwo, itd.

Jeżeli zaś wiemy, że kategorii nie będzie dużo to możemy napisać własny filtr. Polecam go wynieść do oddzielnego pliku, np filters.py. Niby wszystko OK, ale po co tworzyć własny filtr, skoro ten wyżej spełnia nasze oczekiwania? Wyobraźmy sobie sytuację, że ponad połowa kategorii nie ma podpiętego żadnego artykułu. Po co wyświetlać je na liście do filtrowania skoro i tak będę puste? Stwórzmy zatem prosty filtr niepustych kategorii.

class CategoryFilter(SimpleListFilter):
    title = 'Kategorie'
    parameter_name = 'category_id'

    def lookups(self, request, model_admin):
        return model_admin.model.objects.values_list('category__pk', 'category__title').distinct()

    def queryset(self, request, queryset):
    if self.value():
        return queryset.filter(category_id=self.value())
    return queryset 

A teraz jak się zapewne domyślacie wystarczy w adminie ten filtr dodać w odpowiednim atrybucie, który znamy z poprzedniego przykładu. Proste, szybkie i daje znacznie większą elastyczność. Podobnych zastosowań jest mnóstwo. Czy przekonało to Was to do korzystania z własnych filtrów? Jeżeli nie mam jeszcze jeden argument. Wyobraźmy sobie, że pomimo używania tylko niepustych kategorii ich lista dalej jest dość długa i staje się nieczytelna. Może tak ukryć część kategorii nadpisując szablon? Zmieńmy zatem szablon w naszej klasie:

class CategoryFilter(SimpleListFilter):
    [...]
    template = 'article/category_filter.html'

A teraz przejdźmy do edycji samego szablonu ( wiem, że części z Was może nie spodobać się fakt, że trzymam w jednym miejscu html, css i js. Zdaję sobię sprawę, że docelowo powinno to być rozbite na 3 pliki, jednak do celów edukacyjnych jest to wysta rczające). Najprościej jeśli skopiujemy szablon z django/contrib/admin/templates/admin/filter.html i dodamy kilka modyfikacji:

{% load i18n %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul>
{% for choice in choices|slice:":3" %}
    <li{% if choice.selected %} class="selected"{% endif %}>
    <a href="{{ choice.query_string|iriencode }}">{{ choice.display }}</a></li>
{% endfor %}
{% for choice in choices|slice:"3:" %}
    <li class="more {% if choice.selected %}selected{% endif %}">
    <a href="{{ choice.query_string|iriencode }}">{{ choice.display }}</a></li>
{% endfor %}
<button onclick="myFun('none')">Ukryj</button>
<button onclick="myFun('block')">Pokaż więcej</button>
</ul>


<style>
    .more {
        display: none;
    }
</style>


<script>
    function myFun(class_name){
        var divs = document.getElementsByClassName("more");
        for(var i = 0; i < divs.length; i++){
            divs[i].style.display = class_name;
        }
    }
</script>

Wspomniane modyfikacje polegały na podzieleniu listy na dwie. Pierwsza część do trzeciego elementu a druga od trzeciego. Wszystkie elementy drugiej listy maja klasę more. Stylami schowałem te elementy. Pod spodem są jeszcze dwa przyciski, którym dzięki JavaScript dodałem zdarzenia ukrywania i pokazywania elementów. W ten sposób filtr stał się bardziej kompaktowy, zachowując przy tym najważniejsze cechy.

Filtrowanie po datach

Czasem zachodi potrzeba filtrowania właśnie po polach daty. Twórcy Django i tutaj wyszli z ciekawym rozwiązaniem. Wystarczy w atrybucie date_hierarchy wpisać atrybut po którym chcemy filtrować. W naszym przypadku niech będzie to data publikacji:

class ArticleAdmin(admin.ModelAdmin):
    [...]
    date_hierarchy = 'pub_date'

Dzięki takiemu zabiegowi na górze strony pojawił się specjalny filtr z datami, gdzie na początku możemy wybrac lata, następnie schodzimy głębej przez miesiące aż do dni. Z tym należy jednak uważać, gdyż w znacznym stopniu potrafi spowolnić pobierany danych z bazy. Do testów dodałem 2.5 mln rekordów. Po dodaniu tej opcji czas ładowania wydłużył się do ponad minuty. W normalnych warunkach dostalibysmy TimeOut. Poniżej zrzut z logów o których piszę:

SELECT
DISTINCT django_datetime_trunc('year', "blog_article"."pub_date", 'None') AS "datetimefield"
FROM "blog_article" WHERE "blog_article"."pub_date" IS NOT NULL ORDER BY "datetimefield" ASC
37469,58 MS
SELECT
MAX("blog_article"."pub_date") AS "last", MIN("blog_article"."pub_date") AS "first"
FROM "blog_article"
31364,03 MS

Jak widać tylko te dwa zapytania wykonywały się po 30 sekund każde. Dlatego używajcie tego rozsądnie. I nie bójcie się też jakoś specjalnie. Do standardowych celów, np bloga gdzie ilość wpisów nie przekracza kilku tysięcy nie macie się czym przejmować. Uczulam Was tylko, bo miałem takie przypadki w pracy zawodowej i spędziłam cały tydzień tylko nad optymalizacją jednego listingu. Właśnie wtedy poznałem uroki i niuanse jakie niosą za sobą możliwości wbudowane przez twórców tego framework.

Wyłącz niepotrzebny licznik

Pozostańmy jeszcze w temacie optymalizacji. Domyślnie włączona jest opcja wyświetlania liczby obiektów na listingu. Możemy to wyłączyć przez co pozbywając się nadmiarowego zapytania. W nowszych wersjach (od 1.10) jest to bardzo proste, bo sterowane jedną linijką. Ja pamiętam jak podczas wspomnianej wcześniej walki z optymalizacją musiałem ręcznie przechodzić przez kilkanascie plików w celu zaoszczędzenia chociaż jednego zapytania. Nie było to łatwe zadanie ale dało mi wiele do myślenia i nie ukrywam, że był to pierwszy impuls do napisania tego wpisu. Poniżej instrukcja jak to wyłączyć:

class ArticleAdmin(admin.ModelAdmin):
    show_full_result_count = True

Zobaczmy jakie wcześniej szło zapytanie:

SELECT
COUNT(*) AS "__count"
FROM "blog_article" WHERE "blog_article"."active" = 'True'

810,03 MS

Ten problem jest niestety bardzo trudny do zlokalizowania, bo Django bardzo lubi zapisywać w cache zapytania, tak żeby kolejne wejścia były szybsze. Dlatego właśnie wybrałem tylko aktywne artykuły, żeby wymusić nowe zapytanie. Błędy często występują przy pierwszym wejściu a nie chcemy przecież kilka razy odświeżać strony, żeby zobaczyć listing ;)

Planowałem dość krótki wpis ale się rozrósł. Nie będę zatem przedłużać i na tym zakończę a pozostałe elementy opiszę w kolejnym wpisie. Wiecie już jak bezpiecznie zbudować dobry i zoptymalizowany panel administracyjny. Umiecie filtrować obiekty i znacie kilka fajnych sztuczek, które przyspieszą pracę Waszą lub klientów dla których będziecie strony tworzyć. Potraficie bezpiecznie zbudować dobry i zoptymalizowany panel administracyjny.

Sam listing jest już niemal omówiony i zostało kilka fajnych kwestii, które wyjaśnię następnym razem. W kolejnym wpisie skupię się głównie na dodawaniu i edycji elementów, bo tam też można się trochę pobawić.

Tymczasem zapraszam do kolejnego wpisu który pojawi się w ciąguy kilku dni.

Wykorzystany kod napisany został w Python2.7 oraz Django1.11.
Jan 20, 2019

Najnowsze wpisy

Zobacz wszystkie