Django >= 2.1 : comment faire une administration « sur mesure » et « proprement »

Comment faire simplement une interface d’administration pour le projet entier

Sur les versions de Django < 2.1, il était impossible de surcharger « correctement » l’administration.

  • Créez un fichier admin.py à la racine de votre projet (« à la racine », parce qu’il est « global » à tout le projet)
  • Mettez-y ce code:
    from django.contrib import admin
    class MyProjectAdminSite(admin.AdminSite):
        title_header = 'My project Admin'
        site_header = 'My project administration'

    Bien sûr, changez « MyProject » par le nom de votre projet…
  • Dans settings.py, remplacez 'django.contrib.admin' par 'my_app.apps.MyAppAdminConfig',
  • Dans le fichier apps.py de votre projet, soit my_project/my_app/apps.py, déclarez l’administration comme suit :

    from django.apps import AppConfig
    from django.contrib.admin.apps import AdminConfig
    class MyAppConfig(AppConfig):
        name = 'my_app'
    class MyAppAdminConfig(AdminConfig):
        default_site = 'admin.MyProjectAdminSite'

Quand utiliser __repr__ ou __str__ ?

Cette astuce vient de Dan Bader. Du code, rapidement :

# Émuler ce que fait la std lib :
>>> import datetime
>>> aujourdhui = datetime.date.today()
# Le résultat de __str__ doit être lisible = comme une chaîne :
>>> str(today)
'2017-02-02'
# Le résultat de __repr__ doit lever les ambiguïtés possibles :
>>> repr(today)
'datetime.date(2017, 2, 2)'
# L'interpréteur Python en ligne de commande
# utilise __repr__ pour inspecter les objets :
>>> today
datetime.date(2017, 2, 2)

Django : le polymorphisme : astuces

Un excellent article écrit par Dan Bader (j’ai pas mal discuté avec lui et j’ai son livre, il est vraiment aussi bon que modeste, je vous recommande vivement tous ses articles – et n’hésitez pas à lui parler en Anglais (tant qu’il n’est pas encore trop connu !), il vous répondra sûrement).

Il manque juste ces « petits » détails techniques lorsqu’on se base sur les modèles abstraits et l’héritage au sens « modèle » de Django (Abstract Base Model).

Prenons un exemple simple : vous créez un modèle « simple » Media(models.Model) dont vous allez hériter sur deux modèles : Book(Media) et Dvd(Media).

Ce qui va vous prendre beaucoup de temps (et qui n’est expliqué nulle part de manière claire et directe), c’est que Django, de manière cachée, va créer des propriétés qui vont dans les deux sens entre le modèle parent et le modèle enfant.

Pour reprendre notre exemple, avec les trois modèles : Media = parent, et les deux enfants Book et Dvd.
Imaginez que pour chaque enfant, vous créiez une méthode spécifique, par exemple Book.nb_pages() et Dvd.is_blue_ray()
De manière cachée, vous pourrez à partir du parent, accéder à l’enfant et ses propriété via son nom comme ceci :
my_media = Media.object.get(pk=1)
if hasattr(self, 'book'):  # this Media is a Book:
    my_media.book.nb_pages()
elif hasattr(self, 'dvd'):  # this media is a Dvd:
    my_media.dvd.is_blue_ray()
else:  # direct Media model access:
    pass

A l’inverse, à partir des modèles enfants, pour accéder au parent c’est le nom du modèle parent avec _ptr :
my_book = Book.object.get(pk=21)
my_dvd = Dvd.object.get(pk=75)
# imaginons que "price" est une propriété de Media property. On peut écrire :
print(my_book.media_ptr.price)
print(my_dvd.media_ptr.price)

Django : favicon.ico : comment le gérer facilement

En mode débug, un serveur Django renvoie tous les fichiers, y compris les fichiers statique. Seul problème : tous les fichiers statique on leur URL qui commence par /static/.

Mais on rencontre un problème : le favicon.ico n’est pas un fichier statique « normal » = qui commence par /static/. C’est un nom « en dur », qui ressemble à http://monsite/favicon.ico.

Il vous faut donc la coder en dur dans les URLs : ainsi : url(r'^favicon.ico/$',...)
Et pour renvoyer directement le contenu, il suffit de faire une fonction immédiate (= lambda) qui renvoie directement le contenu :
urlpatterns = [
    # ...blabla... all path and now:
    url(r'^public/(?P.*)$', static.serve, {
        'document_root': settings.MEDIA_ROOT
    }, name='url_public'),
    url(r'^favicon.ico/$', # google chrome favicon fix :
        lambda x: HttpResponseRedirect(settings.STATIC_URL+'favicon.ico')),

]

Django : comment passer une constante à tous vos templates ?

Django, nativement, a la possibilité de passer des dictionnaires clé-valeurs à tous les templates, et dans les templates on accède aux valeurs via la clé.

Exemple concret : je voulais passer le nom de mon site à tous les templates, mais sous forme de constante.

Dans settings.py j’ai défini ma constante : WEBSITE_NAME = 'mywebsite'

Il suffit de créer une fonction qui renvoie un dictionnaire avec une clé nommée « correctement », par exemple :

def context_processor_website_name(request):
    return {'website_name': WEBSITE_NAME}

Et ensuite, dans les context_processors, juste ajouter le nom de la fonction par rapport au package où elle est.
J’ai mis cette fonction dans settings.py.
Je ne sais pas si c’est la meilleure place mais l’idée c’est que comme c’est en rapport avec la configuration de mon site, c’est le meilleur endroit…

Donc pour préciser où est la fonction c’est 'myproject.settings.context_processor_website_name' :

On retrouve donc le code final comme ceci :

TEMPLATES = [{
    'BACKEND': 'django.template.backends.django.DjangoTemplates',
    'DIRS': [os.path.join(BASE_DIR, 'templates')],
    'APP_DIRS': True,
    'OPTIONS': {
        'context_processors': [
            'django.template.context_processors.debug',
            # ... and:
            'myproject.settings.context_processor_website_name'
    ],
    },
}, ]

Et voilà, à partir de maintenant, avec exactement 3 lignes de code, j’ai accès à ma variable website_name, que je peux utiliser ainsi :

<title>{% block title %}{% if title %}{{ title|safe }} - {% endif %}{{ website_name }}{% endblock %}</title>

Petite parenthèse : grâce à ce code, si la page qu’on affiche a un titre, elle aura pour titre : [titre] - [nom du site] et si elle n’a aucun titre, elle affichera : [nom du site]

Django : un modèle sur mesure : DaysField code source complet

Code source complet de DaysOfWeek

Après avoir écrit en trois parties (1, 2 et 3) comment faire un modèle sur mesure, voici le code source au complet.

from django.core.exceptions import ValidationError
from django import forms
from django.db import models
from django.utils.translation import ugettext_lazy as _
class DaysOfWeek:
    DAYS = {1: _("Monday"),
            2: _("Tuesday"),
            3: _("Wednesday"),
            4: _("Thursday"),
            5: _("Friday"),
            6: _("Saturday"),
            7: _("Sunday"), }
    DAYS_SHORT = {1: _("Mo"),
                  2: _("Tu"),
                  3: _("We"),
                  4: _("Th"),
                  5: _("Fr"),
                  6: _("Sa"),
                  7: _("Su"), }
    CHOICES = [(idx, value) for idx, value in DAYS.items()]
    @staticmethod
    def summary_from_list(tab, empty=' '):
        if tab is None:
            return '-'.join([empty for a in range(len(DaysOfWeek.DAYS))])
        return '-'.join([str(DaysOfWeek.DAYS_SHORT.get(i, empty)) for i in tab])
class DaysFormField(forms.TypedMultipleChoiceField):
    # different widget, comment to change interface:
    widget = forms.CheckboxSelectMultiple
    def __init__(self, *args, **kwargs):
        if 'max_length' in kwargs:
            kwargs.pop('max_length')
        kwargs['choices'] = DaysOfWeek.CHOICES
        super().__init__(*args, **kwargs)
class DaysField(models.CharField):
    description = _("Comma-separated integers between 1 and 7")
    def __init__(self, *args, **kwargs):
        kwargs['max_length'] = 13 # max. len = all days = "1,2,3,4,5,6,7" = 13
        super().__init__(*args, **kwargs)
    @staticmethod
    def value_to_array(value):
        if value is None:
            return None
        try:
            if isinstance(value, list):
                return [int(a) for a in value]
            elif isinstance(value, str):
                return [int(a) for a in value.split(',')]
        except (TypeError, ValueError):
            raise ValidationError(_("Unexpected value"))
        raise ValidationError(_("Unexpected value"))
    @staticmethod
    def from_db_value(value, expression, connection):
        return DaysField.value_to_array(value)
    def to_python(self, value):
        return DaysField.value_to_array(value)
    def get_prep_value(self, value):
        return ','.join([str(a) for a in value]) if value is not None else None
    def formfield(self, **kwargs):
        # ignore the admin directives: directly override with our custom form:
        return super().formfield(form_class=DaysFormField,
                                 initial=[])
    # region - Validator -
    class ListBetween1And7Validator:
        def __call__(self, value):
            try:
                if isinstance(value, list):
                    loop = [int(a) for a in value]
                elif isinstance(value, str):
                    loop = [int(a) for a in value.strip('[]').split(',')]
                else:
                    raise ValueError()
                for v in loop:
                    if not (7 >= v >= 1):
                        raise ValueError()
            except (TypeError, ValueError):
                raise ValidationError(
                    _("Enter a list if coma-separated values between 1 and 7."),
                    code='invalid')
    # endregion - Validator -
    default_validators = [ListBetween1And7Validator()]

Django : un modèle sur mesure : DaysField étape 3/3

Formulaire spécifique pour un modèle sur-mesure

Après les conversions de notre modèle dans les deux sens :

Il ne nous reste qu’à faire le validateur = s’assurer que ce qui arrive (peu importe la source : base de données ou code Python) :

  • est bien un tableau :
  • que ce tableau n’est rempli que d’entiers ;
  • que chacun de ces entiers est compris entre 1 et 7.

Il faut créer une classe, qui doit implémenter __call__. Vous ne savez pas ce que c’est ? L’explication sur SO ici est excellente.
En une phrase : __init__() est appelé lorsque la classe est instanciée, __call__ est appelé lorsque l’instance est appelée (avec des ())

class ListBetween1And7Validator:
    def __call__(self, value):
        try:
            if isinstance(value, list):
                loop = [int(a) for a in value]
            elif isinstance(value, str):
                loop = [int(a) for a in value.strip('[]').split(',')]
            else:
                raise ValueError()
            for v in loop:
                if not (7 >= v >= 1):
                    raise ValueError()
        except (TypeError, ValueError):
            raise ValidationError(
                _("Enter a list if coma-separated values between 1 and 7."),
                  code='invalid')

Puis il faut le déclarer dans la liste des validateurs de notre classe :

    default_validators = [ListBetween1And7Validator()]

Dans le prochain et dernier article, je mets le code source au complet.