Histoire de ne pas perdre le fil : TrackingFields


Ceci est un post invité de Foxmask posté sous licence creative common 3.0 unported.

Préambule

Je sais bien qu’une partie de ce billet ne plaira pas à Sam&Max (thanks to the Django CBV & Mixin:)

Introduction
Le but du billet sera de montrer comment, sans rien changer dans un formulaire, on peut arriver à pister les modifications des données effectuées dans l’application.

La première partie va planter le décor en commençant par vous montrer comment s’articule une application avec formulaire composé d’un sous formulaire en sus (j’expliquerai pourquoi après :)

Pour ce faire, je vous emmène dans l’univers du 7° art, viendez on va refaire StarWars!

Un modèle, un formulaire, une vue, un template et ca sera fini.

le models.py

    from django.db import models
 
 
    class Movie(models.Model):
        """
            Movie
        """
        name = models.CharField(max_length=200, unique=True)
        description = models.CharField(max_length=200)
 
        def __str__(self):
            return "%s" % self.name
 
 
    class Episode(models.Model):
        """
           Episode - for Trilogy and So on ;)
        """
        name = models.CharField(max_length=200)
        scenario = models.TextField()
        movie = models.ForeignKey(Movie)
 
        def __str__(self):
            return "%s" % self.name

le forms.py, tout rikiki :

    from django import forms
    from django.forms.models import inlineformset_factory
 
    from starwars.models import Movie, Episode
 
 
    class MovieForm(forms.ModelForm):
 
        class Meta:
            """
                As I have to use : "exclude" or "fields"
                As I'm very lazy, I dont want to fill the list in the "fields"
                so I say that I just want to exclude ... nothing :P
            """
            model = Movie
            exclude = []
 
    # a formset based on the model of the Mother "Movie" and Child "Episode" + 1 new empty lines
    # for more details, have a look at https://docs.djangoproject.com/fr/1.9/topics/forms/modelforms/#inline-formsets
    EpisodeFormSet = inlineformset_factory(Movie, Episode, fields=('name', 'scenario'), extra=1)

la vue views.py, très sèche, très DRY ;)

    from django.http import HttpResponseRedirect
    from django.core.urlresolvers import reverse
    from django.views.generic import CreateView, UpdateView, ListView
 
    from starwars.models import Movie
    from starwars.forms import MovieForm, EpisodeFormSet
 
 
    class MovieMixin(object):
        model = Movie
        form_class = MovieForm
 
        def get_context_data(self, **kw):
            """ init form with data if any """
            context = super(MovieMixin, self).get_context_data(**kw)
            if self.request.POST:
                context['episode_form'] = EpisodeFormSet(self.request.POST)
            else:
                context['episode_form'] = EpisodeFormSet(instance=self.object)
            return context
 
        def get_success_url(self):
            """ where to go back, once data are validated """
            return reverse("home")
 
        def form_valid(self, form):
            """ form validation """
            formset = EpisodeFormSet((self.request.POST or None), instance=self.object)
            if formset.is_valid():
                self.object = form.save()
                formset.instance = self.object
                formset.save()
 
            return HttpResponseRedirect(reverse('home'))
 
 
    class Movies(ListView):
        model = Movie
        context_object_name = "movies"
        template_name = "base.html"
 
 
    class MovieCreate(MovieMixin, CreateView):
        """
            MovieMixin manages everything for me ...
        """
        pass
 
 
    class MovieUpdate(MovieMixin, UpdateView):
        """
            ... and as I'm DRY I wont repeat myself myself myself ;)
        """
        pass

Pour finir de planter le décors et les costumes (merci Roger Hart et Donald Cardwell)

le template base.html

    <!DOCTYPE html>
    <html lang="fr">
    <head>
        <title>Manage stories for StarWars</title>
    </head>
    <body>
    <h1>Stories Manager for Starwars</h1>
    {% block content %}
    <a href="{% url 'movie_create' %}">Add a movie</a><br/>
    <h2>Movie list</h2>
    <ul>
    {% for movie in movies %}
    <li><a href="{% url 'movie_edit' movie.id %}">{{ movie.name }}</a></li>
    {% endfor %}
    </ul>
    {% endblock %}
    </body>
    </html>

enfin movie_form.html (le template utilisé par les UpdateView & CreateView)

    {% extends "base.html" %}
    {% block content %}
    <form method="post" action="">
        {% csrf_token %}
        {{ formset.management_form }}
        <table>
        {{ form.as_table }}
        </table>
        <table>
        {{ episode_form.as_table }}
        </table>
        <button>Save</button>
    </form>
    {% endblock %}

Mise à jour de la base de données

cela s’impose :

(starwars) foxmask@foxmask:~/DjangoVirtualEnv/starwars/starwars $  ./manage.py migrate
 
Operations to perform:
  Synchronize unmigrated apps: messages, starwars, staticfiles
  Apply all migrations: contenttypes, admin, sessions, auth
Synchronizing apps without migrations:
  Creating tables...
    Creating table starwars_movie
    Creating table starwars_episode
    Running deferred SQL...
  Installing custom SQL...

Voilà le tout est prêt (après le lancement du serveur bien sûr), je peux allégrement créer ma double trilogie pépère tel George Lucas.

Trackons l’impie

Seulement un jour arrive où, moi, George Lucas, je vends StarWars à Walt Disney, mais comme je ne veux pas rater de ce qu’ils vont faire de mon “bébé”, je rajoute un “tracker de modifications” à mon application, pour ne pas perdre le “field” de l’Histoire.

Installation de Tracking Fields

en prérequis django-tracking-fields requiert django-cuser, donc ze pip qui va bien donne :

    (starwars) foxmask@foxmask:~/DjangoVirtualEnv/starwars/starwars $ pip install django-tracking-fields django-cuser
    Collecting django-tracking-fields
      Downloading django-tracking-fields-1.0.6.tar.gz (58kB)
        100% |████████████████████████████████| 61kB 104kB/s 
    Collecting django-cuser
      Downloading django-cuser-2014.9.28.tar.gz
    Requirement already satisfied (use --upgrade to upgrade): Django>=1.5 in /home/foxmask/DjangoVirtualEnv/starwars/lib/python3.5/site-packages (from django-cuser)
    Installing collected packages: django-tracking-fields, django-cuser
      Running setup.py install for django-tracking-fields ... done
      Running setup.py install for django-cuser ... done
    Successfully installed django-cuser-2014.9.28 django-tracking-fields-1.0.6

comme de coutume, après un pip install, une modification dans le settings.py suit,

INSTALLED_APPS = (
        ...
        'cuser',
        'tracking_fields',
        ...
    )
    MIDDLEWARE_CLASSES = (
        'django.contrib.sessions.middleware.SessionMiddleware',
        'django.middleware.common.CommonMiddleware',
        'django.middleware.csrf.CsrfViewMiddleware',
        'django.contrib.auth.middleware.AuthenticationMiddleware',
        'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
        'django.contrib.messages.middleware.MessageMiddleware',
        'django.middleware.clickjacking.XFrameOptionsMiddleware',
        'django.middleware.security.SecurityMiddleware',
        'cuser.middleware.CuserMiddleware',  ## <=== ne pas oublier, pour chopper le user qui fait le con avec mes films;)
    )

le petit migrate qui va bien aussi pour ajouter les tables pour les modèles de django-tracking-fields

    (starwars) foxmask@foxmask:~/DjangoVirtualEnv/starwars/starwars $  ./manage.py migrate
    Operations to perform:
      Synchronize unmigrated apps: staticfiles, messages, cuser, starwars
      Apply all migrations: auth, sessions, contenttypes, tracking_fields, admin
    Synchronizing apps without migrations:
      Creating tables...
        Running deferred SQL...
      Installing custom SQL...
    Running migrations:
      Rendering model states... DONE
      Applying tracking_fields.0001_initial... OK
      Applying tracking_fields.0002_auto_20160203_1048... OK

et nous voilà prêts à joueur les trackers.

Utilisation

On ne peut pas rêver plus simple, cela se résume à un décorateur sur le modèle qui identifie quelles données sont modifiées, et un field histo qui va lier le modele TrackingEvent de l’application TrackingFields, à ma table à surveiller. Et là, bien que le modele ait été modifié, inutile de faire un nouveau python manage.py migrate, rien ne bougera, histo sera une GenericRelation(). En effet, TrackingEvent repose sur ContenType aka “L’Infrastructure des Types de Contenu”. Si vous avez déjà tripoté la gestion des permissions, vous avez déjà dû vous y frotter;)

Pour la faire courte, en clair ça donne :

le models.py arrangé pour l’occasion du décorateur

    from django.db import models
    from django.contrib.contenttypes.fields import GenericRelation
 
    from tracking_fields.decorators import track
    from tracking_fields.models import TrackingEvent
 
 
    @track('name', 'description')   # decorator added
    class Movie(models.Model):
        """
            Movie
        """
        name = models.CharField(max_length=200, unique=True)
        description = models.CharField(max_length=200)
        # to get the changes made on movie
        histo = GenericRelation(TrackingEvent, content_type_field='object_content_type')
 
        def episodes(self):
            return Episode.objects.filter(movie=self)
 
        def __str__(self):
            return "%s" % self.name
 
    @track('name', 'scenario')   # decorator added
    class Episode(models.Model):
        """
           Episode - for Trilogy and So on ;)
        """
        name = models.CharField(max_length=200)
        scenario = models.TextField()
        movie = models.ForeignKey(Movie)
        # to get the changes made on episode
        histo = GenericRelation(TrackingEvent, content_type_field='object_content_type')
 
        def __str__(self):
            return "%s" % self.name

bon là c’est simplissime comme une recette de pate à crêpes: 3 imports de rigueur, le décorateur et la GenericRelation() on mélange le tout et ca donne ce qui suit J’ai, au passage, rajouté une fonction episodes à ma classe Movie, dont je vous reparlerai plus bas.

le template de la DetailView (pour afficher uniquement les details d’un film) qui va bien

    <table>
       <caption>History of the modification of {{ object }} </caption>
       <thead>
       <tr><th>Old Value</th><th>New Value</th><th>By</th><th>at</th></tr>
       </thead>
       <tbody>
    {% for h in object.histo.all %}
       {% for f in h.fields.all %}
           <tr><td>{{ f.old_value }}</td><td>{{ f.new_value }}</td><td>{{ h.user }}</td><td>{{ h.date }}</td></tr>
       {% endfor %}
    {% endfor %}
       </tbody>
    </table>

A présent si je me rends dans ma page pour modifier le scénario d’un Episode, mon template ci dessus, ne m’affichera pas ces modications ! Pourquoi bou diou ? Parce qu’ici j’affiche “l’histo” de Movie pas de Episode… On comprend à présent ici mon intéret pour le sous formulaire. Le “problème” aurait été masqué si je m’étais arrêté à un seul simple formulaire.

Corrigeons

c’est là qu’entre en jeu la fonction episodes à ma classe Movie, pour me permettre d’itérer dessus et afficher tout le toutim

le template de la DetailView qui va bien (bis)

    <table>
        <caption>History of the modifications of {{ object }} </caption>
        <thead>
            <tr><th>Old Value</th><th>New Value</th><th>By</th><th>at</th></tr>
        </thead>
        <tbody>
    {% for h in object.histo.all %}
       {% for f in h.fields.all %}
           <tr><td>{{ f.old_value }}</td><td>{{ f.new_value }}</td><td>{{ h.user }}</td><td>{{ h.date }}</td></tr>
       {% endfor %}
    {% endfor %}
        </tbody>
    </table>
    {% for ep in object.episodes %}
        {% if ep.histo.all %}
    <table>
        <caption>history of the modifications of Episode</caption>
        <thead>
            <tr><th>Old Value</th><th>New Value</th><th>By</th><th>at</th></tr>
        </thead>
        <tbody>
            {% for h in ep.histo.all %}
                {% for f in h.fields.all %}
                {% if f.old_value == f.new_value %} {# they are the same when the new value is created to avoid to display "null" #}
                {% else %}
                <tr><td>{{ f.old_value }}</td><td>{{ f.new_value }}</td><td>{{ h.user }}</td><td>{{ h.date }}</td></tr>
                {% endif %}
                {%  endfor %}
            {% endfor %}
        </tbody>
     </table>
        {% endif %}
    {% endfor %}

Voili voilou ! Et en prime, si vous êtes curieux, coté admin, vous avez aussi la liste de toutes les modifications si besoin ;)

Aux utilisateurs avertis qui diraient :

pourquoi l’avoir recodé coté front puisque c’est déjà géré coté admin sans lever le petit doigt ?

Parce que George Lucas veut montrer les modifications apportées à son bébé StarWars par Walt Disney, au monde entier pardi !

Ah un détail en passant : dans l’admin la vue qui affiche la liste des modifications donne : “Episode Object” ou “Movie Object”. Pour éviter ça, zavez dû remarquer que j’ai mis la fonction __str__ dans mes modèles ce qui vous rendra une valeur plus “lisible” sur ce qui a été modifié.

Conclusion :

Dans la vraie vie de votre serviteur, ne se voyait pas créer un modele “history” lié “physiquement” par une FK à chaque modèle, entreprenait de chercher au travers de la toile quelques ressources.

C’est finallement sur #django-fr@freenode qu’il a posé la question et a obtenu de Gagaro le grââl : une application nommée tracking-fields, dont il est l’auteur.

Pour une fois qu’il fait sa faignasse en ne codant pas tout by himself, ça fait plaisir de tomber sur une appli pareille !

Si vous voulez jouer avec le code de ce gestionnaire de films c’est par ici la bonne soupe

16 thoughts on “Histoire de ne pas perdre le fil : TrackingFields

  • quelqu'un

    décors => décor

    modele => modèle

    pardis => pardi

    eviter => éviter

    nous voilà prêt à joueur => nous voilà prêts à jouer

    Bon article sinon, comme on les aime.

  • Spout

    @foxmask: comme je te l’ai signalé hier sur IRC, dans MovieForm, au lieu de mettre exclude = [], tu peux mettre fields = '__all__' (source)

  • foxmask Post author

    @spout je trouve que ça colle au flemmard complet d’en mettre le moins (pour une fois;) et que ça marche !

  • HarmO

    Ah ça fait plaisir de voir que l’IRC ne sert pas qu’à raconter des bêtises :)

    Excellent article !

  • frague

    Pour faire du tracking côté admin, j’utliise plutôt django-reversion qui retient des versions de chaque modifications. Il y a des méthodes du côté “front” pour faire le même genre de trucs, et surtout retenir l’historique des modifs.

  • foxmask Post author

    @frague ça à l’air sympa (si 1.500 personnes l’ont ajouté dans leur favoris) mais de bute en blanc, en parcourant la doc, le coté front ne saute pas aux yeux.

    Par contre je vois bien son utilisation comme sur un blog façon wordpress où on gere des versions des billets également

  • boblinux

    hmm hmm hmm django est encore un joli mystère pour moi, un jour peut-être je me ferais la main (jee c bien)

  • Zyzaze

    J’utilise aussi django-reversion ! Plus simple encore à mettre en place mais pas très axé front. Cependant, y’a moyen d’afficher avec des couleurs la différence entre deux révisions. L’idée derrière est simplement efficace, serializer l’objet à chaque modification.

  • batisteo

    Une question conne, pourquoi "%s" % self.name dans __str__()?

    C’est si le nom est un entier (genre 300) et pour éviter d’utiliser str(self.name) ou quoi ?

  • Marc

    Pour notre projet personnel, ni django-reversion ni tracking-fields ne suffisait, donc on a créé un produit qui mélangeait les deux et nous sommes très satisfait, fonctionne à la fois en front/back/admin/console/celery.

  • cocksucker

    @Marc

    C’est bien mais ça nous apporte quoi si tu partages pas ;) à part confirmer que c’est toi qui a la plus grosse ;)

    Est-ce que c’est le genre de feature qu’on laisse en prod sur un site à forte fréquentation ?

  • Marc

    @cocksucker

    C’était surtout pour souligner le fait qu’il n’existe pas de solution mixte de ces deux produits et que l’on a du construire notre propre solution. J’accepte de la partager cependant elle n’a pas été pensée pour sortir de notre infrastructure et n’est évidemment pas parfaitement documentée, à l’occasion nous en feront peut être un package distribuable.

    Pour répondre à l’autre question, pour la production nous n’avons pas le choix, le suivi de l’ensemble des modifications fait partie de notre réglementation, pour limiter au maximum les dégradations de performances, nous avons décidé de déléguer la gestion à des tâches asynchrones grâce à Celery. Ce n’est pas une solution parfaite, mais jusqu’à présent le fonctionnement est invisible mais sur des gros volumes.

  • Marc

    s/feront/ferons

    s/mais/même

    Ca m’apprendra à valider sans me relire…

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.