Il n’est pas rare d’avoir besoin de créer des tâches asynchrones dans vos applications. Si ces tâches sont à déclencher suite à l’action d’un utilisateur (édition d’une page, …), vous pouvez utiliser delayed job.
Mais si cette tâche doit être exécutée à interval régulier dans le temps, utiliser delayed job n’est pas faisable. Et il vous faudra créer un cron.

Pour cela je vais vous parler de Whenever.
L’utilisation est simpliste. Vous définissez tous vos crons dans config/schedule.rb. Et whenever se charge de générer automatiquement votre crontab.

Voici par exemple le document whenever pour RefStats.

every 1.minute do
  rake "crawler:work > #{RAILS_ROOT}/../shared/log/crawler.log"
end

every 1.day, :at => '2 am' do
  rake "maintenance:daily > #{RAILS_ROOT}/../shared/log/maintenance.log"
end

Toutes les minutes, le crawler récupérant les positions est lancé. Il s’agit d’une tâche rake.
Tous les jours à 2h du matin, la tâche « maintenance:daily » est lancée.

Suite à cela, depuis la base de votre projet, entrez en ligne de commande :

whenever

Vous verrez alors la crontab générée par whenever. Uniquement affichée, par mise à jour.
Si vous désirez mettre celle-ci à jour, c’est cependant simple.

whenever –update-crontab ApplicationName

L’option ApplicationName doit être différente pour chacune de vos applications. Ainsi whenever ne modifie que la crontab relative à cette application.

Du coup dans votre processus de déploiement Capistrano, vous ajoutez une tâche pour whenever :

after "deploy:symlink", "deploy:update_crontab"
namespace :deploy do
    desc "Update the crontab file"
    task :update_crontab, :roles => :db do
        run "cd #{release_path} && whenever --update-crontab #{application}"
    end
end

Et votre crontab sera remise automatiquement à jour à chaque fois que vous déploierez votre application. Plus à se soucier de cela manuellement :)

Dans toute application Rails, vous avez une tâche « rake » assez sympa : stats. Celle-ci vous retourne le nombre de lignes de code dans votre projet.
En séparant les contrôleurs, les modèles, les helpers et les librairies. Mais également en séparant le code applicatif du code de test.
Et en vous donnant un ratio code applicatif / code de test/

Voici un exemple de rake stats (avec des chiffres complètement fictifs).

+----------------------+-------+-------+---------+---------+-----+-------+
| Name                 | Lines |   LOC | Classes | Methods | M/C | LOC/M |
+----------------------+-------+-------+---------+---------+-----+-------+
| Controllers          |   419 |   289 |       7 |      43 |   6 |     4 |
| Helpers              |     9 |     9 |       0 |       1 |   0 |     7 |
| Models               |    34 |    29 |       4 |       2 |   0 |    12 |
| Libraries            |   359 |   212 |       2 |      36 |  18 |     3 |
| Model specs          |    78 |    60 |       0 |       0 |   0 |     0 |
| Controller specs     |   203 |   166 |       0 |       2 |   0 |    81 |
+----------------------+-------+-------+---------+---------+-----+-------+
| Total                |  1514 |  1028 |      13 |      85 |   6 |    10 |
+----------------------+-------+-------+---------+---------+-----+-------+
  Code LOC: 539     Test LOC: 489     Code to Test Ratio: 1:0.9

Ici, en utilisant RSpec, nous avons les statistiques de nos specs car l’outil modifie cette fonctionnalité afin d’avoir les bons chiffres.
Cependant si en plus de RSpec, et comme moi, vous utilisez Cucumber, vous n’aurez pas ces chiffres. Normal ! RSpec redéfinit la tâche stats. Mais pas cucumber.

Modifions donc cette tâche afin d’avoir nos chiffres. Ouvrez le fichier suivant :

lib/tasks/rspec.rake

La aux alentours de la ligne 110, vous allez trouver la définition des répertoires de code à include dans la tâche stats.

task :statsetup do
    require 'code_statistics'
    ::STATS_DIRECTORIES << %w(Model\ specs spec/models) if File.exist?('spec/models')
    ::STATS_DIRECTORIES << %w(View\ specs spec/views) if File.exist?('spec/views')
    ::STATS_DIRECTORIES << %w(Controller\ specs spec/controllers) if File.exist?('spec/controllers')
    ::STATS_DIRECTORIES << %w(Helper\ specs spec/helpers) if File.exist?('spec/helpers')
    ::STATS_DIRECTORIES << %w(Library\ specs spec/lib) if File.exist?('spec/lib')
    ::STATS_DIRECTORIES << %w(Routing\ specs spec/routing) if File.exist?('spec/routing')
    ::STATS_DIRECTORIES << %w(Integration\ specs spec/integration) if File.exist?('spec/integration')
    ::CodeStatistics::TEST_TYPES << "Model specs" if File.exist?('spec/models')
    ::CodeStatistics::TEST_TYPES << "View specs" if File.exist?('spec/views')
    ::CodeStatistics::TEST_TYPES << "Controller specs" if File.exist?('spec/controllers')
    ::CodeStatistics::TEST_TYPES << "Helper specs" if File.exist?('spec/helpers')
    ::CodeStatistics::TEST_TYPES << "Library specs" if File.exist?('spec/lib')
    ::CodeStatistics::TEST_TYPES << "Routing specs" if File.exist?('spec/routing')
    ::CodeStatistics::TEST_TYPES << "Integration specs" if File.exist?('spec/integration')
  end

Il nous faut ajouter nos features Cucumber, et ce deux fois : une pour les statistiques « générales ». Et une seconde afin de comptabiliser ces données comme des données de test. La tâche statsetup sera donc la suivante :

task :statsetup do
    require 'code_statistics'
    ::STATS_DIRECTORIES << %w(Model\ specs spec/models) if File.exist?('spec/models')
    ::STATS_DIRECTORIES << %w(View\ specs spec/views) if File.exist?('spec/views')
    ::STATS_DIRECTORIES << %w(Controller\ specs spec/controllers) if File.exist?('spec/controllers')
    ::STATS_DIRECTORIES << %w(Helper\ specs spec/helpers) if File.exist?('spec/helpers')
    ::STATS_DIRECTORIES << %w(Library\ specs spec/lib) if File.exist?('spec/lib')
    ::STATS_DIRECTORIES << %w(Routing\ specs spec/routing) if File.exist?('spec/routing')
    ::STATS_DIRECTORIES << %w(Integration\ specs spec/integration) if File.exist?('spec/integration')
    ::CodeStatistics::TEST_TYPES << "Model specs" if File.exist?('spec/models')
    ::CodeStatistics::TEST_TYPES << "View specs" if File.exist?('spec/views')
    ::CodeStatistics::TEST_TYPES << "Controller specs" if File.exist?('spec/controllers')
    ::CodeStatistics::TEST_TYPES << "Helper specs" if File.exist?('spec/helpers')
    ::CodeStatistics::TEST_TYPES << "Library specs" if File.exist?('spec/lib')
    ::CodeStatistics::TEST_TYPES << "Routing specs" if File.exist?('spec/routing')
    ::CodeStatistics::TEST_TYPES << "Integration specs" if File.exist?('spec/integration')

    #
    # Adding Cucumber features to the stats
    #
    ::STATS_DIRECTORIES << %w(Cucumber\ features features) if File.exist?('features')
    ::CodeStatistics::TEST_TYPES << "Cucumber features" if File.exist?('features')
  end

Et du coup nous avons maintenant nos données Cucumber ajoutées à nos stats.
Avec les même données fictives que plus haut, nous aurons donc un rake stats équivalent à celui-ci :

+----------------------+-------+-------+---------+---------+-----+-------+
| Name                 | Lines |   LOC | Classes | Methods | M/C | LOC/M |
+----------------------+-------+-------+---------+---------+-----+-------+
| Controllers          |   419 |   289 |       7 |      43 |   6 |     4 |
| Helpers              |     9 |     9 |       0 |       1 |   0 |     7 |
| Models               |    34 |    29 |       4 |       2 |   0 |    12 |
| Libraries            |   359 |   212 |       2 |      36 |  18 |     3 |
| Model specs          |    78 |    60 |       0 |       0 |   0 |     0 |
| Cucumber features    |   412 |   263 |       0 |       1 |   0 |   261 |
| Controller specs     |   203 |   166 |       0 |       2 |   0 |    81 |
+----------------------+-------+-------+---------+---------+-----+-------+
| Total                |  1514 |  1028 |      13 |      85 |   6 |    10 |
+----------------------+-------+-------+---------+---------+-----+-------+
  Code LOC: 539     Test LOC: 489     Code to Test Ratio: 1:0.9

Nos statistiques de projet sont plus réalistes et le ratio entre le code applicatif et le code de test est plus révélateur de ce que vous avez réellement dans votre application.

Utiliser l’application Django Sites est assez sympatique afin de permettre de placer plusieurs sites différents sur le même projet.
Ainsi dans le cas d’un site multilingue avec un domaine par langue, j’ai un seul projet, un site par langue et tout roule comme en 40 :)

Mais c’est à ce moment que vient la problématique de la gestion des droits (oui. Après l’article sur les ACL dans Rails d’hier, je suis beaucoup dans les problèmes d’autorisation en ce moment).
En effet on veut pouvoir autoriser des utilisateurs à modifier des éléments sur une langue. Mais pas forcément sur une autre.

Alors déjà, mise en contexte :
J’ai un modèle « Language », qui contient toutes mes langues, leur nom et un slug qui les définit pour mettre les chaines appropriées.

class Language(models.Model):
  slug = models.SlugField(max_length=4)
  name = models.CharField(max_length=200)
  site = models.ForeignKey(Site)

  group = models.ManyToManyField(Group)

  def __unicode__(self):
    return self.name

  def get_absolute_url(self):
    return self.site.get_absolute_url()

Vous constaterez que cette langue est reliée à un site. Ainsi chaque langue a un site.
De même chaque langue peut avoir un ou plusieurs groupes.
J’ai créé un middleware afin de définir le site et la langue appropriés en fonction du domaine.
Je ne rentre pas dans les détails de ce middleware ici. Si ça vous intéresse, merci de le signaler dans les commentaires. Je ferai un autre article.

Maintenant que nous avons une langue qui est en relation avec un ou plusieurs groupes, nous pouvons n’autoriser l’accès que aux utilisateurs étant dans le groupe relatif à cette langue.
Pour la gestion de nos accès, nous allons avoir besoin de deux méthodes :

from django.http import HttpResponse
from app.pages.models import Language

def group_required(func):
  def _decorator(request, *args, **kwargs):
    if not in_group(request):
      return HttpResponse("Access denied")
    return func(*args, **kwargs)
  return _decorator

def in_group(request):
  language = Language.objects.get(site__domain__exact=request.get_host())
  for group in language.group.all():
    if request.user in group.user_set.all():
      return True
  return False

La méthode in_group retourne vrai si l’utilisateur est dans l’un des groupes autorisés pour la langue.
Elle retournera faux si ce n’est pas le cas.

la méthode group_required est un décorateur, qui retournera la méthode passée en paramètres si l’utilisateur y a accès. Et un access denied dans le cas contraire.

Deux possibilités d’implémentation maintenant.

Dans une vue

Supposons que vous ayez une méthode de vue bateau :

def single(request, slug):
  return object_detail(
    request,
    queryset = Page.objects.filter(published=True).all(),
    slug = slug
  )

Nous allons utiliser le décorateur afin de vérifier que notre utilisateur a bien accès à cette langue.

def single(request, slug):
  return object_detail(
    request,
    queryset = Page.objects.filter(published=True).all(),
    slug = slug
  )
single = group_required(single)

Si l’utilisateur a accès au groupe, la méthode single restera inchangée. Dans le cas contraire, elle retournera un « Access Denied ».

Dans l’admin

Dans l’application django admin, on ne peut redéfinir les méthodes de cette façon. On peut faire la chose de manière bien plus class ! :)

Voici mon admin :

class PageAdmin(admin.ModelAdmin):
  list_display = ('title',)
  search_fields = ['title']

  fieldsets = [
    (None, {'fields': ['title', 'slug', 'published']}),
  ]

  def has_add_permission(self, request):
    if not in_group(request):
      return False
    return super(PageAdmin, self).has_add_permission(request)

Toute la partie jusqu’aux fieldsets est basique. La ou ça devient intéressant c’est la méthode has_add_permission.
Méthode normalement définie par ModelAdmin, que l’on redéfinit ici afin de vérifier que notre utilisateur a bien accès à la langue :)

Si l’utilisateur n’a pas accès à la langue, on retourne directement faux. On ne cherche pas à aller plus loin.
Dans le cas contraire … On fait appel à la classe parente. Si l’utilisateur a le droit de modifier cet élément lui même, il peut le faire !

La méthode has_add_permission n’est bien évidemment pas la seule à réecrire. Il nous faut également redéfinir :
has_change_permission et has_delete_permission

A vous ensuite de définir vos règles d’autorisation en fonction des besoins précis de votre application. Plutôt cool non ? :)

Avec rails comme avec tout framework, vous allez rapidement avoir besoin de gérer l’identification des utilisateurs et leurs autorisations.
Pour leur identification, il existe divers plugins. Notamment Restful Authentication, AuthLogic et (mon préféré) Clearance.
Pour la gestion des autorisations, je vais vous parler de ACL9.

L’idée est simple. Après avoir identifié votre utilisateur, vous avez accès à un objet current_user, qui contient l’objet relatif à l’utilisateur actif (généralement une instance du model User. Mais cela peut être n’importe quoi).
ACL9 va ajouter deux tables :
- Role, qui contiendra tous les rôles utilisateurs existants. Un peu comme des groupes. Chaque rôle pouvant être associé à une instance d’un objet.
Vous pourrez ainsi avoir un rôle « admin », global, qui permettra de définir les accès de l’administrateur.
Et un rôle « éditeur », relatif uniquement à l’objet ayant pour id « 1″ du modèle « News ».

- UserRole, qui fera la relation entre un rôle et un utilisateur (has_and_belongs_to_many).

Voici les migrations nécessaires pour ces deux tables :

create_table :roles, :force => true do |t|
    t.column   :name,                            :string,                    :limit => 40
    t.column      :authorizable_type",      :string,                    :limit => 40
    t.column      :authorizable_id,           :integer
    t.timestamps
end
create_table :roles_users", :id => false, :force => true do |t|
    t.column :user_id,                          :integer
    t.integer  :role_id,                           :integer
    t.timestamps
end

Vous devez bien évidemment créer un modèle « Role », qui représentera cette table. Il est inutile de créer un modèle RolesUser. Cela sera géré automatiquement par Rails.

class Role < ActiveRecord::Base
    acts_as_authorization_role
end

Vous noterez l’ajout de « acts_as_authorization_role », qui va implémenter les méthodes de rôles relatives à ACL9.
Dans notre table user, nous allons également devoir ajouter un appel à une méthode spécifique de ACL9 afin d’ajouter les méthodes relatives à l’utilisateur.

class User < ActiveRecord::Base
    acts_as_authorization_subject
end

Et dans tous les modèles ou nous désirons avoir une gestion des droits d’accès, nous allons ajouter :

class Foo < ActiveRecord::Base
    acts_as_authorization_object
end

ACL9 vous a maintenant ajouté diveres méthodes dans vos objets :
Dans votre modèle utilisateur :

  • has_role?(role, object = nil) – Retournera « true » si l’utilisateur a le rôle demandé en global ou sur l’objet précisé.
  • has_role!(role, object = nil) – Donnera le rôle nommé à l’utilisateur
  • has_no_role!(role, object = nil) – Supprimera le rôle nommé pour l’utilisateur
  • has_roles_for?(object) – Retourne true si l’utilisateur a un quelconque rôle sur l’objet
  • has_role_for?(object) – Alias de has_roles_for?
  • roles_for(object) – Retourne tous les rôles de l’utilisateur pour cet objet
  • has_no_roles_for!(object) – Supprime tous les rôles de l’utilisateur pour l’objet
  • has_no_roles! – Supprime tous les rôles de l’utilisateur

Et dans chacun de vos modèles objets (Foo plus haut) :

  • accepts_role?(role, subject) – Retourne true si subject a accès au rôle courant
  • accepts_role!(role, subject) – Donne accès à subject au rôle courant
  • accepts_no_role!(role, subject) – Supprime l’accès utilisateur au rôle
  • accepts_roles_by?(subject) – Retourne true si l’utilisateur a un rôle quelconque sur l’objet
  • accepts_role_by?(subject) – Alias de accepts_roles_by?
  • accepted_roles_by(subject) – Retourne tous les utilisateurs ayant un rôle pour cet objet

Maintenant que nous avons implémenté nos autorisations et que nous avons vu les diverses méthodes pour les utiliser, limitons les accès à nos actions !

class MyOwnController < ApplicationController
    access_control do
        # Autorise tous les utilisateurs ayant le rôle "superadmin" à accéder à l'objet
        allow :superadmin

        # Autorise tous les utilisateurs ayant le rôle "creator" sur @news
        allow :creator, :at => :news

        # Autorise les utilisateurs anonymes et ceux qui sont enregistrés sur la page d'accueil
        # Et refuse les utilisateurs ayant le rôle "banned"
        action :index do
            allow anonymous, logged_in
            deny :banned
        end

        # Autorise les utilisateurs ayant le rôle "manager" sur l'objet @news. Sauf pour l'action "destroy"
        allow :manager, :at => :news, :except => [:destroy]
    end

    def index
        @news = News.find_by_id params[:id]
        # Le code relatif à l'action "index"
    end
end

Du coup on peut maintenant gérer de manière particulièrement avancée les rôles dans notre application.

A l’heure ou vous lisez ces lignes, je suis en train de faire mon atelier « Test des applications Javascript avec QUnit et Selenium » à Paris Web, la conférence des gens bien du web (qui sont plus beau en vrai).
Mais si vous lisez ces lignes pendant la conférence, cela signifie que vous n’y êtes pas (ou alors je vous conseille fortement de fermer la fenêtre et de m’écouter :mrgreen: ).

Pour vous permettre également de profiter de ce que je raconte, je vous invite donc à voir deux choses :

D’abord mon support de slides, avec lequel j’explique ce que sont les tests automatisés.

Puis le code autour duquel je présente le cas concret de test d’applications. Il vous suffit de cloner le projet sur GitHub.

 
Fork me on GitHub