jeudi 25 juin 2009

Gestion de groupes d'utilisateurs


Billet centré cette fois la gestion des groupes d’utilisateurs (en prévision de la gestion des rôles).
Plusieurs techniques sont abordées ici :
- nested routes, resource & form
- multiple will_paginate sur une page
- ajax / rjs et effets scriptaculous
- in_place edit
- autocomplete avec renvoi d’id au lieu d’un string

NB : j’ai ouvert un dépôt Github (lien à droite) pour tracer le code et vous permettre de tester directement (cliquez sur ‘download’, décompressez et lancez directement à la racine mongrel_rails start –p 80. La config admin est dans la création de la table users (en attendant Rails v 3.0, ou il y aura un fichier assets.rb spécialement pour les configurations initiales).

La prochaine fois gestion des droits utilisateurs avec declarative_authorization.





1.Objectifs de l’article
Nous avons pour le moment 2 classes principales : User et Wiki. Pour préparer la gestion des droits d’accès plus finement, nous avons besoin de créer des groupes d’utilisateurs (pouvant contenir un ou plusieurs sous-groupes).

2.Préparation
Trois plugin seront utilisés, principalement pour faciliter une saisie plus naturelle coté utilisateur : will_paginate (pour la pagination), auto_complete (permet de suggerer une liste correspontante au texte frappé) et in_place_editing (pour editer sans passer par la page update).
Tous les trois sont relativement anciens (ils étaient directement intégrés à Rails 1.x) mais font ce qu’on leur demande.
Will_paginate s’intalle en gem (donc n’oubliez pas de mettre à jour le fichier environment.rb), les deux autres en plugins uniquement.

Une fois installer, on prépare les tables elles-même : Group, Group_member et Group_subgroup :
>ruby script/generate scaffold groups name:string user:references
>ruby script/generate model group_member group:references user:references
>ruby script/generate model group_subgroup group:references subgroup:references


3. Modèles

Voici les 3 modèles :
app/models/group.rb :
class Group < ActiveRecord::Base
belongs_to :user
has_many :group_members, :dependent => :destroy
has_many :group_subgroups, :dependent => :destroy
has_many :subgroup, :class_name => 'GroupSubgroup', :foreign_key => 'subgroup_id'
validates_uniqueness_of :name

def auto
end
end

app/models/group_member.rb :
class GroupMember < ActiveRecord::Base
belongs_to :group
belongs_to :user

validates_uniqueness_of :user_id, :scope => :group_id
end

app/models/group_subgroup.rb
class GroupSubgroup < ActiveRecord::Base
belongs_to :group
belongs_to :subgroup, :class_name => 'Group', :foreign_key => 'subgroup_id'

validates_uniqueness_of :group_id, :scope => :subgroup_id
end


Et le modèle user qui est modifié lui aussi por prendre en compte la dépendance :
  has_many :group_members
has_many :groups # group creator


En dehors des relations has_many/belongs_to classiques entre les 3 tables, il y a un premier point intéressant :
 validates_uniqueness_of :user_id, :scope => :group_id 

Cette déclaration permet de vérifier qu’il n’y a qu’un seul même utilisateur (user_id) par groupe (group_id).
Ensuite :
 belongs_to :subgroup, :class_name => 'Group', :foreign_key => 'subgroup_id'

Et sa correspondance dans la table group permet de définir une clef étrangère, puisque dans la table goup_subgroup, group_id et subgroup_id pointent tout deux vers la table group.

Dernier point : la définition de la méthode ‘auto’ dans la table group est la seule réponse rapide rapide que j’ai trouvé à un bug sur le auto_complete...

4. Routes
Pour mettre en places les routes :
  map.resources :users, :collection => { :list => :get } 
map.resources :groups, :has_many => [:group_members, :group_subgroups], :collection => { :list => :get }

L’ajout des collection servira pour les auto_complete ci-dessous ; la déclaration has_many sert à indiquer des resources sous-jascente.
Pour voir toutes les routes ainsi crées, faite rake routes.

5. In_place_editing
Utilisation très aisé du plugin (pas trop REST mais bon) : déclaration dans le controleur Group :
class GroupsController < ApplicationController
before_filter :require_user
in_place_edit_for :group, :name

Et utilisation dans la vue :
%h1 Groups
%h3 Show Group
%p
%b Name :
= in_place_editor_field :group, 'name'

Et c’est tout !

5. Auto_complete, Nested Form
Le plugin auto_complete fait un appel javascript pour trouver la liste de suggestion qu’il doit afficher. Dans le champs suivant nous voulons ajouter un utilisateur à un group (donc insérer un enregistrement dans group_member ensuite) :
    Add user :
= text_field_with_auto_complete :user, :auto, {:size => 25, :autocomplete => 'off'}, |
{:skip_style => true, :frequency => 0.25, |
:url => list_users_path, :method => :get, :with => "'user_query=' + element.value"} |

(les barres de fin sont une astuce concernant les lignes longues sous Haml).
text_field_auto_complete prend 4 paramètre : modèle visé, champ du modèle, options html, options auto_complete.
Il est possible de l’utiliser en mode ‘par défaut’, et dans ce cas là vous devez créer une fonction spécifique dans le controleur group renvoyant une liste d’utilisateur.
L’option html :autocomplete => 'off' permet de dire au browser de ne pas autocompléter lui-même le champ avec les valeurs qu’il garde en mémoire.
Comme c’est mieux de laisser le controlleur User faire le boulot pour lequel il est fait, j’ai spécifié l’url où il faut aller chercher les données : ici :url => list_users_path . Ls 2ème paramètres est donc complètement inutile, mais il faut les spécifier tout de même.

Construction de la méthode dans le controleur User (qu’il serait possible de fusionner avec la méthode index en faisant un test sur les paramètres d’entrées) :
  def list
@query = params[:user_query]
@users = User.find :all, :conditions => ['username LIKE ? OR lastname LIKE ?', "%#{@query}%", "%#{@query}%"]
respond_to do |format|
format.html
format.js { render :partial => 'list', :layout => false }
end
end

params[:user_query] est le paramètre passé par :with => "'user_query=' + element.value" Et la méthode renvoi en retour un partial. A noter que le contenu du partial doit impérativement être de la forme d'une liste ul / li.

app/views/users/list.js.haml
%ul
- @users.each do |u|
- text = u.entire_name
%li<
= link_to_function highlight(text,@query), "$('group_member_user_id').value = '#{u.id}';", :style =>"display:block;"


Deux astuces ici : d’abord %li< le signe inférieur indique à Haml de ‘coller’ les éléments li ... sans quoi vous aurez des espaces blancs avant et après le nom (il doit manquer un strip dans le plugin quelque part). Faites l’essai avec et sans vous verrez.

Ensuite on ne retourne pas directement le nom d’un User, mais une fonction javascript qui va modifier la valeur d’un champ 'group_member_user_id en y plaçant l’id de l’utilisateur. :
= f.hidden_field  :user_id

Ce champs est caché, mais permet de ne rien modifier à la méthode update standard lors du submit, sans avoir à rechercher l’utilisateur correspondant au nom renvoyé normalement.

D’où le formulaire final :
  %h3 Users member list
- remote_form_for [@group, GroupMember.new] do |f|
Add user :
= text_field_with_auto_complete :user, :auto, {:size => 25, :autocomplete => 'off'}, |
{:skip_style => true, :frequency => 0.25, |
:url => list_users_path, :method => :get, :with => "'user_query=' + element.value"} |
= f.hidden_field :user_id
= f.hidden_field :group_id, :value => @group.id
= f.submit

Notez l’appel particulier remote_form_for (et inspecter le nom des élément avec Firebug sous Firefox).

Une petite mise en forme ajoutée dans public/stylesheets/red.sass : (puisque qu’on utilise pas le css fournit avec auto_complete) :
// ------------------ autocomplete
div.auto_complete
:width 350px
:background #fff
ul
:border 1px solid #888
:margin 0
:padding 0
:width 100%
:list-style-type none
li
:margin 0
:padding 3px
&.selected
:background-color #ffb
a
:text-decoration none
&:hover
:background-color #ffb
:color black
strong.highlight
:color #800
:margin 0
:padding 0


Maintenant on veut afficher la liste des membres du groupes sous le formulaire :
= render @list_group_members

@list_group_member est défini dans le controleur gérant la vue :
@list_group_members = @group.group_members

render @list_group_members équivaut à render :partial => 'group_members/group_member', :collection => @list_group_members, ou encore @list_group_members.each{|g| render :partial => 'group_members/group_member', :object => g}
Cette nouvelle notation plus simple des render à été introduite avec Rails 2.3.2
Le partial est tout simple :
 %li#gp_user_id{:id => "#{group_member.id}" }
= link_to group_member.user.full_name, user_path(group_member.user)
= link_to_remote "[-]", :url => group_group_member_path(@group, group_member) , :method => :delete, :confirm => 'Are you sure ?'

Il met en place un lien pour supprimer directement un membre d’un groupe. On met un id sur l’élément li pour permettre d’y faire référence dans la suppression du membre ; à noter le path qui est de cette forme [modele parent]_[modele enfant]_path du fait de la délaration des routes.
Voici la fonction destroy appellée du controlleur app/controllers/group_members_controller.rb :
  def destroy
@group_member = GroupMember.find(params[:id])
@group_member.destroy

respond_to do |format|
format.html { redirect_to(group_members_url) }
format.xml { head :ok }
format.js { render :update do |page|
page.visual_effect :drop_out, "gp_user_id_#{params[:id]}"
end
}
end
end

Et c’est là que l’id sur l’élément li est utilisé, puisque qu’il est ‘effacé’ de l’écran par l’appel à page.visual_effect.

Revenons sur la création d’un membre, où là aussi on ajoute des effets ajax :
app/controllers/ group_members_controller.rb :
  def create
@group_member = GroupMember.new(params[:group_member])

respond_to do |format|
if @group_member.save
format.js {render :update do |page|
page.insert_html :bottom, 'wp_user_list', :partial => 'group_member', :object => @group_member
page.visual_effect :highlight, "gp_user_id_#{@group_member.id}", :duration => 5
page[:user_auto].value = ''
end }
else
format.js { render :nothing => true }
end
end
end

Cette fois on ajoute le nouveau membre à la fin de l’élément div spécifié (wp_user_list), on le met en valeur et on finit en effacant le contenu d la boite de saisie.

6. Will_paginate
On repete les mêmes opérations pour les sous-groupes d’un groupe (un groupe administrateur peut ainsi contennir le groupe wiki_managers par exemple) et on fait tenir le tout sur un même formulaire :

%h1 Groups
%h3 Show Group
%p
%b Name :
= in_place_editor_field :group, 'name'
%p
%b Created by
= @group.user.full_name
at
= @group.created_at.to_s :short
%br{:style => 'clear:both;'}
#group_user_member{:style => 'float: left;width:450px;'}
%h3 Users member list
- remote_form_for [@group, GroupMember.new] do |f|
Add user :
= text_field_with_auto_complete :user, :auto, {:size => 25, :autocomplete => 'off'}, |
{:skip_style => true, :frequency => 0.25, |
:url => list_users_path, :method => :get, :with => "'user_query=' + element.value"} |
= f.hidden_field :user_id
= f.hidden_field :group_id, :value => @group.id
= f.submit
%ol#wp_user_list
= render @list_group_members
= will_paginate @list_group_members, :param_name => :members_page

#group_group_member{:style => 'margin-left:500px;width: 450px;'}
%h3 Groups member list
- remote_form_for [@group, GroupSubgroup.new] do |f|
Add subgroup :
= text_field_with_auto_complete :group, :auto, {:size => 25, :autocomplete => 'off'}, |
{:skip_style => true, :frequency => 0.25, |
:url => list_groups_path, :method => :get, :with => "'group_query=' + element.value"} |
= f.hidden_field :subgroup_id
= f.hidden_field :group_id, :value => @group.id
= f.submit
%ol#wp_sub_list
= render @list_group_subgroups
= will_paginate @list_group_subgroups, :param_name => :subgroups_page
%br{:style => 'clear:both;'}

= link_to 'Back', groups_path


Deux boites donc : une avec les membres à gauche et une autre à droite avec les sous-groupes. C’est là qu’apparait le bug qui nécesite la définition de la méthode ‘auto’ pour le modèle Group, car le second auto_complete la réclame absolument (alors que celui sur les utilisateurs non ... ??).

On a donc 2 listes (users et subgroups) qui doivent être paginées si elles sont trop longues. D’où le
= will_paginate @list_group_subgroups, :param_name => :subgroups_page 

Avec deux nom de param_name différents pour permettre justement de les différencier :)

Il faut par contre mettre à jour dans le controlleur la définition de la liste :
app/controllers/groups_controller.rb
@list_group_subgroups = @group.group_subgroups.paginate :page => params[:subgroups_page], :per_page => 10 


L’appel complet de la méthode devient donc :
  def show
@group = Group.find(params[:id])

@list_group_subgroups = @group.group_subgroups.paginate :page => params[:subgroups_page], :per_page => 10
@list_group_members = @group.group_members.paginate :page => params[:members_page], :per_page => 10
respond_to do |format|
format.html # show.html.erb
format.xml { render :xml => @group }
end
end



7. Conclusion
Voilà, tout est prêt maintenant pour gérer le droits d’accès dans la prochaine étape. Le code est téléchargeable (tag version 0.1) sur Github avec les plugins installés pour vous permettre de tester tout ça rapidement. (A voir si il ne faut pas faire un rake db:drop / rake db:create / rake db:migrate et rake gems:install d’abord ... je vérifierai pour les prochaines releases).



4 commentaires:

  1. Bon le prochain tuto prend plus de temps que prévu en essayant d'abstraire au maximum la gestion des rôles, et je suis pas un king dans les modules et tuti quanti ... en plus c'est chargé IRL donc pas avant le WE prochain :)

    RépondreSupprimer
  2. Merci pour ce tutoriel fort utile ^^

    RépondreSupprimer
  3. A propos de "(en attendant Rails v 3.0, ou il y aura un fichier assets.rb spécialement pour les configurations initiales)"

    http://railscasts.com/episodes/179-seed-data

    RépondreSupprimer
  4. Bonjour,
    juste pour relancer.
    il viendra un jour le prochain tuto?? ça fait 3 ans déjà quand même :P
    merci

    RépondreSupprimer