From 7707457145442d6177ce57c956dbe09af65df1b4 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Lang Date: Sat, 12 Sep 2009 08:36:46 +0000 Subject: [PATCH] User groups branch merged. git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@2869 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/controllers/groups_controller.rb | 162 ++++++++++++++++++ app/controllers/members_controller.rb | 16 +- app/controllers/users_controller.rb | 15 +- app/helpers/application_helper.rb | 14 +- app/helpers/custom_fields_helper.rb | 3 +- app/helpers/groups_helper.rb | 34 ++++ app/helpers/users_helper.rb | 1 + app/models/group.rb | 48 ++++++ app/models/group_custom_field.rb | 22 +++ app/models/member.rb | 38 ++-- app/models/member_role.rb | 27 +++ app/models/principal.rb | 38 ++++ app/models/project.rb | 7 +- app/models/user.rb | 15 +- app/views/admin/index.rhtml | 5 + app/views/groups/_form.html.erb | 8 + app/views/groups/_general.html.erb | 4 + app/views/groups/_memberships.html.erb | 56 ++++++ app/views/groups/_users.html.erb | 49 ++++++ .../groups/autocomplete_for_user.html.erb | 1 + app/views/groups/edit.html.erb | 23 +++ app/views/groups/index.html.erb | 25 +++ app/views/groups/new.html.erb | 8 + app/views/groups/show.html.erb | 7 + .../members/autocomplete_for_member.rhtml | 1 + .../autocomplete_for_member_login.rhtml | 5 - app/views/projects/settings/_members.rhtml | 43 +++-- app/views/users/_groups.rhtml | 9 + app/views/users/_memberships.rhtml | 8 +- config/locales/en.yml | 3 + config/routes.rb | 3 +- .../20090704172350_populate_users_type.rb | 8 + .../20090704172355_create_groups_users.rb | 13 ++ ...4172358_add_member_roles_inherited_from.rb | 9 + lib/redmine.rb | 2 +- public/images/22x22/groups.png | Bin 0 -> 1543 bytes public/images/22x22/users.png | Bin 1543 -> 1052 bytes public/stylesheets/application.css | 15 +- test/fixtures/groups_users.yml | 5 + test/fixtures/member_roles.yml | 20 ++- test/fixtures/members.yml | 12 ++ test/fixtures/users.yml | 8 + test/functional/groups_controller_test.rb | 107 ++++++++++++ test/functional/members_controller_test.rb | 17 +- test/integration/admin_test.rb | 7 +- test/unit/group_test.rb | 77 +++++++++ test/unit/project_test.rb | 12 ++ 47 files changed, 920 insertions(+), 90 deletions(-) create mode 100644 app/controllers/groups_controller.rb create mode 100644 app/helpers/groups_helper.rb create mode 100644 app/models/group.rb create mode 100644 app/models/group_custom_field.rb create mode 100644 app/models/principal.rb create mode 100644 app/views/groups/_form.html.erb create mode 100644 app/views/groups/_general.html.erb create mode 100644 app/views/groups/_memberships.html.erb create mode 100644 app/views/groups/_users.html.erb create mode 100644 app/views/groups/autocomplete_for_user.html.erb create mode 100644 app/views/groups/edit.html.erb create mode 100644 app/views/groups/index.html.erb create mode 100644 app/views/groups/new.html.erb create mode 100644 app/views/groups/show.html.erb create mode 100644 app/views/members/autocomplete_for_member.rhtml delete mode 100644 app/views/members/autocomplete_for_member_login.rhtml create mode 100644 app/views/users/_groups.rhtml create mode 100644 db/migrate/20090704172350_populate_users_type.rb create mode 100644 db/migrate/20090704172355_create_groups_users.rb create mode 100644 db/migrate/20090704172358_add_member_roles_inherited_from.rb create mode 100644 public/images/22x22/groups.png create mode 100644 test/fixtures/groups_users.yml create mode 100644 test/functional/groups_controller_test.rb create mode 100644 test/unit/group_test.rb diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb new file mode 100644 index 00000000..54b5d1b9 --- /dev/null +++ b/app/controllers/groups_controller.rb @@ -0,0 +1,162 @@ +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class GroupsController < ApplicationController + layout 'base' + before_filter :require_admin + + helper :custom_fields + + # GET /groups + # GET /groups.xml + def index + @groups = Group.find(:all, :order => 'lastname') + + respond_to do |format| + format.html # index.html.erb + format.xml { render :xml => @groups } + end + end + + # GET /groups/1 + # GET /groups/1.xml + def show + @group = Group.find(params[:id]) + + respond_to do |format| + format.html # show.html.erb + format.xml { render :xml => @group } + end + end + + # GET /groups/new + # GET /groups/new.xml + def new + @group = Group.new + + respond_to do |format| + format.html # new.html.erb + format.xml { render :xml => @group } + end + end + + # GET /groups/1/edit + def edit + @group = Group.find(params[:id]) + end + + # POST /groups + # POST /groups.xml + def create + @group = Group.new(params[:group]) + + respond_to do |format| + if @group.save + flash[:notice] = l(:notice_successful_create) + format.html { redirect_to(groups_path) } + format.xml { render :xml => @group, :status => :created, :location => @group } + else + format.html { render :action => "new" } + format.xml { render :xml => @group.errors, :status => :unprocessable_entity } + end + end + end + + # PUT /groups/1 + # PUT /groups/1.xml + def update + @group = Group.find(params[:id]) + + respond_to do |format| + if @group.update_attributes(params[:group]) + flash[:notice] = l(:notice_successful_update) + format.html { redirect_to(groups_path) } + format.xml { head :ok } + else + format.html { render :action => "edit" } + format.xml { render :xml => @group.errors, :status => :unprocessable_entity } + end + end + end + + # DELETE /groups/1 + # DELETE /groups/1.xml + def destroy + @group = Group.find(params[:id]) + @group.destroy + + respond_to do |format| + format.html { redirect_to(groups_url) } + format.xml { head :ok } + end + end + + def add_users + @group = Group.find(params[:id]) + users = User.find_all_by_id(params[:user_ids]) + @group.users << users if request.post? + respond_to do |format| + format.html { redirect_to :controller => 'groups', :action => 'edit', :id => @group, :tab => 'users' } + format.js { + render(:update) {|page| + page.replace_html "tab-content-users", :partial => 'groups/users' + users.each {|user| page.visual_effect(:highlight, "user-#{user.id}") } + } + } + end + end + + def remove_user + @group = Group.find(params[:id]) + @group.users.delete(User.find(params[:user_id])) if request.post? + respond_to do |format| + format.html { redirect_to :controller => 'groups', :action => 'edit', :id => @group, :tab => 'users' } + format.js { render(:update) {|page| page.replace_html "tab-content-users", :partial => 'groups/users'} } + end + end + + def autocomplete_for_user + @group = Group.find(params[:id]) + @users = User.active.like(params[:q]).find(:all, :limit => 100) - @group.users + render :layout => false + end + + def edit_membership + @group = Group.find(params[:id]) + @membership = params[:membership_id] ? Member.find(params[:membership_id]) : Member.new(:principal => @group) + @membership.attributes = params[:membership] + @membership.save if request.post? + respond_to do |format| + format.html { redirect_to :controller => 'groups', :action => 'edit', :id => @group, :tab => 'memberships' } + format.js { + render(:update) {|page| + page.replace_html "tab-content-memberships", :partial => 'groups/memberships' + page.visual_effect(:highlight, "member-#{@membership.id}") + } + } + end + end + + def destroy_membership + @group = Group.find(params[:id]) + Member.find(params[:membership_id]).destroy if request.post? + respond_to do |format| + format.html { redirect_to :controller => 'groups', :action => 'edit', :id => @group, :tab => 'memberships' } + format.js { render(:update) {|page| page.replace_html "tab-content-memberships", :partial => 'groups/memberships'} } + end + end +end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index eb598952..3bfa606e 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -16,8 +16,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class MembersController < ApplicationController - before_filter :find_member, :except => [:new, :autocomplete_for_member_login] - before_filter :find_project, :only => [:new, :autocomplete_for_member_login] + before_filter :find_member, :except => [:new, :autocomplete_for_member] + before_filter :find_project, :only => [:new, :autocomplete_for_member] before_filter :authorize def new @@ -59,17 +59,17 @@ class MembersController < ApplicationController end def destroy - @member.destroy - respond_to do |format| + if request.post? && @member.deletable? + @member.destroy + end + respond_to do |format| format.html { redirect_to :controller => 'projects', :action => 'settings', :tab => 'members', :id => @project } format.js { render(:update) {|page| page.replace_html "tab-content-members", :partial => 'projects/settings/members'} } end end - def autocomplete_for_member_login - @users = User.active.find(:all, :conditions => ["LOWER(login) LIKE ? OR LOWER(firstname) LIKE ? OR LOWER(lastname) LIKE ?", "#{params[:user]}%", "#{params[:user]}%", "#{params[:user]}%"], - :limit => 10, - :order => 'login ASC') - @project.users + def autocomplete_for_member + @principals = Principal.active.like(params[:q]).find(:all, :limit => 100) - @project.principals render :layout => false end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e9fdfaf7..0abda0cd 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -63,7 +63,7 @@ class UsersController < ApplicationController if @user.save Mailer.deliver_account_information(@user, params[:password]) if params[:send_information] flash[:notice] = l(:notice_successful_create) - redirect_to :action => 'list' + redirect_to :controller => 'users', :action => 'edit', :id => @user end end @auth_sources = AuthSource.find(:all) @@ -75,6 +75,7 @@ class UsersController < ApplicationController @user.admin = params[:user][:admin] if params[:user][:admin] @user.login = params[:user][:login] if params[:user][:login] @user.password, @user.password_confirmation = params[:password], params[:password_confirmation] unless params[:password].nil? or params[:password].empty? or @user.auth_source_id + @user.group_ids = params[:user][:group_ids] if params[:user][:group_ids] @user.attributes = params[:user] # Was the account actived ? (do it before User#save clears the change) was_activated = (@user.status_change == [User::STATUS_REGISTERED, User::STATUS_ACTIVE]) @@ -85,17 +86,18 @@ class UsersController < ApplicationController Mailer.deliver_account_information(@user, params[:password]) end flash[:notice] = l(:notice_successful_update) - # Give a string to redirect_to otherwise it would use status param as the response code - redirect_to(url_for(:action => 'list', :status => params[:status], :page => params[:page])) + redirect_to :back end end @auth_sources = AuthSource.find(:all) @membership ||= Member.new + rescue ::ActionController::RedirectBackError + redirect_to :controller => 'users', :action => 'edit', :id => @user end def edit_membership @user = User.find(params[:id]) - @membership = params[:membership_id] ? Member.find(params[:membership_id]) : Member.new(:user => @user) + @membership = params[:membership_id] ? Member.find(params[:membership_id]) : Member.new(:principal => @user) @membership.attributes = params[:membership] @membership.save if request.post? respond_to do |format| @@ -111,7 +113,10 @@ class UsersController < ApplicationController def destroy_membership @user = User.find(params[:id]) - Member.find(params[:membership_id]).destroy if request.post? + @membership = Member.find(params[:membership_id]) + if request.post? && @membership.deletable? + @membership.destroy + end respond_to do |format| format.html { redirect_to :controller => 'users', :action => 'edit', :id => @user, :tab => 'memberships' } format.js { render(:update) {|page| page.replace_html "tab-content-memberships", :partial => 'users/memberships'} } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f1b088c4..581ea2fe 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -46,7 +46,11 @@ module ApplicationHelper # Display a link to user's account page def link_to_user(user, options={}) - (user && !user.anonymous?) ? link_to(user.name(options[:format]), :controller => 'account', :action => 'show', :id => user) : 'Anonymous' + if user.is_a?(User) + !user.anonymous? ? link_to(user.name(options[:format]), :controller => 'account', :action => 'show', :id => user) : 'Anonymous' + else + user.to_s + end end def link_to_issue(issue, options={}) @@ -190,6 +194,14 @@ module ApplicationHelper end s end + + def principals_check_box_tags(name, principals) + s = '' + principals.each do |principal| + s << "\n" + end + s + end # Truncates and returns the string as a single line def truncate_single_line(string, *args) diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index a8778a6c..eb39b4c0 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -21,7 +21,8 @@ module CustomFieldsHelper tabs = [{:name => 'IssueCustomField', :label => :label_issue_plural}, {:name => 'TimeEntryCustomField', :label => :label_spent_time}, {:name => 'ProjectCustomField', :label => :label_project_plural}, - {:name => 'UserCustomField', :label => :label_user_plural} + {:name => 'UserCustomField', :label => :label_user_plural}, + {:name => 'GroupCustomField', :label => :label_group_plural} ] end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb new file mode 100644 index 00000000..f139a726 --- /dev/null +++ b/app/helpers/groups_helper.rb @@ -0,0 +1,34 @@ +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module GroupsHelper + # Options for the new membership projects combo-box + def options_for_membership_project_select(user, projects) + options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---") + options << project_tree_options_for_select(projects) do |p| + {:disabled => (user.projects.include?(p))} + end + options + end + + def group_settings_tabs + tabs = [{:name => 'general', :partial => 'groups/general', :label => :label_general}, + {:name => 'users', :partial => 'groups/users', :label => :label_user_plural}, + {:name => 'memberships', :partial => 'groups/memberships', :label => :label_project_plural} + ] + end +end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 5b8ecaf8..9581bbdc 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -47,6 +47,7 @@ module UsersHelper def user_settings_tabs tabs = [{:name => 'general', :partial => 'users/general', :label => :label_general}, + {:name => 'groups', :partial => 'users/groups', :label => :label_group_plural}, {:name => 'memberships', :partial => 'users/memberships', :label => :label_project_plural} ] end diff --git a/app/models/group.rb b/app/models/group.rb new file mode 100644 index 00000000..80e096be --- /dev/null +++ b/app/models/group.rb @@ -0,0 +1,48 @@ +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class Group < Principal + has_and_belongs_to_many :users, :after_add => :user_added, + :after_remove => :user_removed + + acts_as_customizable + + validates_presence_of :lastname + validates_uniqueness_of :lastname, :case_sensitive => false + validates_length_of :lastname, :maximum => 30 + + def to_s + lastname.to_s + end + + def user_added(user) + members.each do |member| + user_member = Member.find_by_project_id_and_user_id(member.project_id, user.id) || Member.new(:project_id => member.project_id, :user_id => user.id) + member.member_roles.each do |member_role| + user_member.member_roles << MemberRole.new(:role => member_role.role, :inherited_from => member_role.id) + end + user_member.save! + end + end + + def user_removed(user) + members.each do |member| + MemberRole.find(:all, :include => :member, + :conditions => ["#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids]).each(&:destroy) + end + end +end diff --git a/app/models/group_custom_field.rb b/app/models/group_custom_field.rb new file mode 100644 index 00000000..b7c199cb --- /dev/null +++ b/app/models/group_custom_field.rb @@ -0,0 +1,22 @@ +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class GroupCustomField < CustomField + def type_name + :label_group_plural + end +end diff --git a/app/models/member.rb b/app/models/member.rb index 2dc91cba..6fffb216 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -17,40 +17,50 @@ class Member < ActiveRecord::Base belongs_to :user - has_many :member_roles, :dependent => :delete_all + belongs_to :principal, :foreign_key => 'user_id' + has_many :member_roles, :dependent => :destroy has_many :roles, :through => :member_roles belongs_to :project - validates_presence_of :user, :project + validates_presence_of :principal, :project validates_uniqueness_of :user_id, :scope => :project_id def name self.user.name end - # Sets user by login - def user_login=(login) - login = login.to_s - unless login.blank? - if (u = User.find_by_login(login)) - self.user = u - end - end + alias :base_role_ids= :role_ids= + def role_ids=(arg) + ids = (arg || []).collect(&:to_i) - [0] + # Keep inherited roles + ids += member_roles.select {|mr| !mr.inherited_from.nil?}.collect(&:role_id) + + new_role_ids = ids - role_ids + # Add new roles + new_role_ids.each {|id| member_roles << MemberRole.new(:role_id => id) } + # Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy) + member_roles.select {|mr| !ids.include?(mr.role_id)}.each(&:destroy) end def <=>(member) a, b = roles.sort.first, member.roles.sort.first - a == b ? (user <=> member.user) : (a <=> b) + a == b ? (principal <=> member.principal) : (a <=> b) + end + + def deletable? + member_roles.detect {|mr| mr.inherited_from}.nil? end def before_destroy - # remove category based auto assignments for this member - IssueCategory.update_all "assigned_to_id = NULL", ["project_id = ? AND assigned_to_id = ?", project.id, user.id] + if user + # remove category based auto assignments for this member + IssueCategory.update_all "assigned_to_id = NULL", ["project_id = ? AND assigned_to_id = ?", project.id, user.id] + end end protected def validate - errors.add_to_base "Role can't be blank" if roles.empty? + errors.add_to_base "Role can't be blank" if member_roles.empty? && roles.empty? end end diff --git a/app/models/member_role.rb b/app/models/member_role.rb index 46777cd1..5a31c17c 100644 --- a/app/models/member_role.rb +++ b/app/models/member_role.rb @@ -19,9 +19,36 @@ class MemberRole < ActiveRecord::Base belongs_to :member belongs_to :role + after_destroy :remove_member_if_empty + + after_create :add_role_to_group_users + after_destroy :remove_role_from_group_users + validates_presence_of :role def validate errors.add :role_id, :invalid if role && !role.member? end + + private + + def remove_member_if_empty + if member.roles.empty? + member.destroy + end + end + + def add_role_to_group_users + if member.principal.is_a?(Group) + member.principal.users.each do |user| + user_member = Member.find_by_project_id_and_user_id(member.project_id, user.id) || Member.new(:project_id => member.project_id, :user_id => user.id) + user_member.member_roles << MemberRole.new(:role => role, :inherited_from => id) + user_member.save! + end + end + end + + def remove_role_from_group_users + MemberRole.find(:all, :conditions => { :inherited_from => id }).each(&:destroy) + end end diff --git a/app/models/principal.rb b/app/models/principal.rb new file mode 100644 index 00000000..a4a946da --- /dev/null +++ b/app/models/principal.rb @@ -0,0 +1,38 @@ +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class Principal < ActiveRecord::Base + set_table_name 'users' + + has_many :members, :foreign_key => 'user_id', :dependent => :destroy + has_many :memberships, :class_name => 'Member', :foreign_key => 'user_id', :include => [ :project, :roles ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name" + has_many :projects, :through => :memberships + + # Groups and active users + named_scope :active, :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status = 1)" + + named_scope :like, lambda {|q| + s = "%#{q.to_s.strip.downcase}%" + {:conditions => ["LOWER(login) LIKE ? OR LOWER(firstname) LIKE ? OR LOWER(lastname) LIKE ?", s, s, s], + :order => 'type, login, lastname, firstname' + } + } + + def <=>(principal) + self.to_s.downcase <=> principal.to_s.downcase + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 0b0c47a2..a6b1ee48 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -20,8 +20,13 @@ class Project < ActiveRecord::Base STATUS_ACTIVE = 1 STATUS_ARCHIVED = 9 - has_many :members, :include => :user, :conditions => "#{User.table_name}.status=#{User::STATUS_ACTIVE}" + has_many :members, :include => :user, :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}" + has_many :member_principals, :class_name => 'Member', + :include => :principal, + :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})" has_many :users, :through => :members + has_many :principals, :through => :member_principals, :source => :principal + has_many :enabled_modules, :dependent => :delete_all has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position" has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker] diff --git a/app/models/user.rb b/app/models/user.rb index 0caaf34f..6922cb51 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,5 @@ -# redMine - project management software -# Copyright (C) 2006-2007 Jean-Philippe Lang +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -17,7 +17,7 @@ require "digest/sha1" -class User < ActiveRecord::Base +class User < Principal # Account statuses STATUS_ANONYMOUS = 0 @@ -33,9 +33,8 @@ class User < ActiveRecord::Base :username => '#{login}' } - has_many :memberships, :class_name => 'Member', :include => [ :project, :roles ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name" - has_many :members, :dependent => :delete_all - has_many :projects, :through => :memberships + has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)}, + :after_remove => Proc.new {|user, group| group.user_removed(user)} has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify has_many :changesets, :dependent => :nullify has_one :preference, :dependent => :destroy, :class_name => 'UserPreference' @@ -50,7 +49,7 @@ class User < ActiveRecord::Base attr_accessor :password, :password_confirmation attr_accessor :last_before_login_on # Prevents unauthorized assignments - attr_protected :login, :admin, :password, :password_confirmation, :hashed_password + attr_protected :login, :admin, :password, :password_confirmation, :hashed_password, :group_ids validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) } validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? } @@ -317,7 +316,7 @@ class User < ActiveRecord::Base end private - + # Return password digest def self.hash_password(clear_password) Digest::SHA1.hexdigest(clear_password || "") diff --git a/app/views/admin/index.rhtml b/app/views/admin/index.rhtml index 32364174..1a0684a1 100644 --- a/app/views/admin/index.rhtml +++ b/app/views/admin/index.rhtml @@ -12,6 +12,11 @@ <%= link_to l(:label_new), :controller => 'users', :action => 'add' %>

+

+<%= link_to l(:label_group_plural), :controller => 'groups' %> | +<%= link_to l(:label_new), :controller => 'groups', :action => 'new' %> +

+

<%= link_to l(:label_role_and_permissions), :controller => 'roles' %>

diff --git a/app/views/groups/_form.html.erb b/app/views/groups/_form.html.erb new file mode 100644 index 00000000..433abdab --- /dev/null +++ b/app/views/groups/_form.html.erb @@ -0,0 +1,8 @@ +<%= error_messages_for :group %> + +
+

<%= f.text_field :lastname, :label => :field_name %>

+ <% @group.custom_field_values.each do |value| %> +

<%= custom_field_tag_with_label :group, value %>

+ <% end %> +
diff --git a/app/views/groups/_general.html.erb b/app/views/groups/_general.html.erb new file mode 100644 index 00000000..19c6f888 --- /dev/null +++ b/app/views/groups/_general.html.erb @@ -0,0 +1,4 @@ +<% labelled_tabular_form_for :group, @group, :url => { :controller => 'group', :action => 'update', :tab => nil } do |f| %> +<%= render :partial => 'form', :locals => { :f => f } %> +<%= submit_tag l(:button_save) %> +<% end %> diff --git a/app/views/groups/_memberships.html.erb b/app/views/groups/_memberships.html.erb new file mode 100644 index 00000000..613f05e2 --- /dev/null +++ b/app/views/groups/_memberships.html.erb @@ -0,0 +1,56 @@ +<% roles = Role.find_all_givable %> +<% projects = Project.active.find(:all, :order => 'lft') %> + +
+<% if @group.memberships.any? %> + + + + + + + + <% @group.memberships.each do |membership| %> + <% next if membership.new_record? %> + + + + + + +<% end; reset_cycle %> +
<%= l(:label_project) %><%= l(:label_role_plural) %>
<%=h membership.project %> + <%=h membership.roles.sort.collect(&:to_s).join(', ') %> + <% remote_form_for(:membership, :url => { :action => 'edit_membership', :id => @group, :membership_id => membership }, + :html => { :id => "member-#{membership.id}-roles-form", :style => 'display:none;'}) do %> +

<% roles.each do |role| %> +
+ <% end %>

+

<%= submit_tag l(:button_change) %> + <%= link_to_function l(:button_cancel), "$('member-#{membership.id}-roles').show(); $('member-#{membership.id}-roles-form').hide(); return false;" %>

+ <% end %> +
+ <%= link_to_function l(:button_edit), "$('member-#{membership.id}-roles').hide(); $('member-#{membership.id}-roles-form').show(); return false;", :class => 'icon icon-edit' %> + <%= link_to_remote l(:button_delete), { :url => { :controller => 'groups', :action => 'destroy_membership', :id => @group, :membership_id => membership }, + :method => :post }, + :class => 'icon icon-del' %> +
+<% else %> +

<%= l(:label_no_data) %>

+<% end %> +
+ +
+<% if projects.any? %> +
<%=l(:label_project_new)%> +<% remote_form_for(:membership, :url => { :action => 'edit_membership', :id => @group }) do %> +<%= select_tag 'membership[project_id]', options_for_membership_project_select(@group, projects) %> +

<%= l(:label_role_plural) %>: +<% roles.each do |role| %> + +<% end %>

+

<%= submit_tag l(:button_add) %>

+<% end %> +
+<% end %> +
diff --git a/app/views/groups/_users.html.erb b/app/views/groups/_users.html.erb new file mode 100644 index 00000000..e471f52c --- /dev/null +++ b/app/views/groups/_users.html.erb @@ -0,0 +1,49 @@ +
+<% if @group.users.any? %> + + + + + + + <% @group.users.sort.each do |user| %> + + + + + <% end %> + +
<%= l(:label_user) %>
<%= link_to_user user %> + <%= link_to_remote l(:button_delete), { :url => { :controller => 'groups', :action => 'remove_user', :id => @group, :user_id => user }, + :method => :post }, + :class => 'icon icon-del' %> +
+<% else %> +

<%= l(:label_no_data) %>

+<% end %> +
+ +
+<% users = User.active.find(:all, :limit => 100) - @group.users %> +<% if users.any? %> + <% remote_form_for(:group, @group, :url => {:controller => 'groups', :action => 'add_users', :id => @group}, :method => :post) do |f| %> +
<%=l(:label_user_new)%> + +

<%= text_field_tag 'user_search', nil, :size => "40" %>

+ <%= observe_field(:user_search, + :frequency => 0.5, + :update => :users, + :url => { :controller => 'groups', :action => 'autocomplete_for_user', :id => @group }, + :with => 'q') + %> + +
+ <%= principals_check_box_tags 'user_ids[]', users %> +
+ +

<%= submit_tag l(:button_add) %>

+
+ <% end %> +<% end %> + +
diff --git a/app/views/groups/autocomplete_for_user.html.erb b/app/views/groups/autocomplete_for_user.html.erb new file mode 100644 index 00000000..de1b0073 --- /dev/null +++ b/app/views/groups/autocomplete_for_user.html.erb @@ -0,0 +1 @@ +<%= principals_check_box_tags 'user_ids[]', @users %> diff --git a/app/views/groups/edit.html.erb b/app/views/groups/edit.html.erb new file mode 100644 index 00000000..93e89088 --- /dev/null +++ b/app/views/groups/edit.html.erb @@ -0,0 +1,23 @@ +

<%= link_to l(:label_group_plural), groups_path %> » <%= h(@group) %>

+ +<% selected_tab = params[:tab] ? params[:tab].to_s : group_settings_tabs.first[:name] %> + +
+ +
+ +<% group_settings_tabs.each do |tab| -%> +<%= content_tag('div', render(:partial => tab[:partial]), + :id => "tab-content-#{tab[:name]}", + :style => (tab[:name] != selected_tab ? 'display:none' : nil), + :class => 'tab-content') %> +<% end -%> + +<% html_title(l(:label_group), @group, l(:label_administration)) -%> diff --git a/app/views/groups/index.html.erb b/app/views/groups/index.html.erb new file mode 100644 index 00000000..48b9ab42 --- /dev/null +++ b/app/views/groups/index.html.erb @@ -0,0 +1,25 @@ +
+<%= link_to l(:label_group_new), new_group_path, :class => 'icon icon-add' %> +
+ +

<%= l(:label_group_plural) %>

+ +<% if @groups.any? %> + + + + + + + +<% @groups.each do |group| %> + + + + + +<% end %> +
<%=l(:label_group)%><%=l(:label_user_plural)%>
<%= link_to h(group), :action => 'edit', :id => group %><%= group.users.size %><%= link_to l(:button_delete), group, :confirm => l(:text_are_you_sure), :method => :delete, :class => 'icon icon-del' %>
+<% else %> +

<%= l(:label_no_data) %>

+<% end %> diff --git a/app/views/groups/new.html.erb b/app/views/groups/new.html.erb new file mode 100644 index 00000000..3c8d2ea3 --- /dev/null +++ b/app/views/groups/new.html.erb @@ -0,0 +1,8 @@ +

<%= link_to l(:label_group_plural), groups_path %> » <%= l(:label_group_new) %>

+ +<%= error_messages_for :group %> + +<% form_for(@group, :builder => TabularFormBuilder, :lang => current_language) do |f| %> +<%= render :partial => 'form', :locals => { :f => f } %> +

<%= f.submit l(:button_create) %>

+<% end %> diff --git a/app/views/groups/show.html.erb b/app/views/groups/show.html.erb new file mode 100644 index 00000000..02927ebd --- /dev/null +++ b/app/views/groups/show.html.erb @@ -0,0 +1,7 @@ +

<%= link_to l(:label_group_plural), groups_path %> » <%=h @group %>

+ + diff --git a/app/views/members/autocomplete_for_member.rhtml b/app/views/members/autocomplete_for_member.rhtml new file mode 100644 index 00000000..96b4c973 --- /dev/null +++ b/app/views/members/autocomplete_for_member.rhtml @@ -0,0 +1 @@ +<%= principals_check_box_tags 'member[user_ids][]', @principals %> \ No newline at end of file diff --git a/app/views/members/autocomplete_for_member_login.rhtml b/app/views/members/autocomplete_for_member_login.rhtml deleted file mode 100644 index 09a08bf9..00000000 --- a/app/views/members/autocomplete_for_member_login.rhtml +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/app/views/projects/settings/_members.rhtml b/app/views/projects/settings/_members.rhtml index a6bdd950..bfea8675 100644 --- a/app/views/projects/settings/_members.rhtml +++ b/app/views/projects/settings/_members.rhtml @@ -1,12 +1,12 @@ <%= error_messages_for 'member' %> <% roles = Role.find_all_givable - members = @project.members.find(:all, :include => [:roles, :user]).sort %> + members = @project.member_principals.find(:all, :include => [:roles, :principal]).sort %>
<% if members.any? %> - + <%= call_hook(:view_projects_settings_members_table_header, :project => @project) %> @@ -15,7 +15,7 @@ <% members.each do |member| %> <% next if member.new_record? %> - + <%= call_hook(:view_projects_settings_members_table_row, { :project => @project, :member => member}) %> @@ -48,27 +50,30 @@ -<% users_count = User.active.count - @project.users.count - users = (users_count < 300) ? User.active.find(:all, :limit => 200).sort - @project.users : [] %> +<% principals = Principal.active.find(:all, :limit => 100, :order => 'type, login, lastname ASC') - @project.principals %>
-<% if roles.any? && users_count > 0 %> +<% if roles.any? && principals.any? %> <% remote_form_for(:member, @member, :url => {:controller => 'members', :action => 'new', :id => @project}, :method => :post) do |f| %>
<%=l(:label_member_new)%> -

<%= text_field_tag 'member[user_login]', nil, :size => "40" %>

-
sqd
- <%= javascript_tag "new Ajax.Autocompleter('member_user_login', 'member_user_login_choices', '#{ url_for(:controller => 'members', :action => 'autocomplete_for_member_login', :id => @project) }', { minChars: 1, frequency: 0.5, paramName: 'user' });" %> - <% unless users.empty? %> -
- <% users.each do |user| -%> - - <% end -%> -
- <% end %> + +

<%= text_field_tag 'principal_search', nil, :size => "40" %>

+ <%= observe_field(:principal_search, + :frequency => 0.5, + :update => :principals, + :url => { :controller => 'members', :action => 'autocomplete_for_member', :id => @project }, + :with => 'q') + %> + +
+ <%= principals_check_box_tags 'member[user_ids][]', principals %> +
+

<%= l(:label_role_plural) %>: <% roles.each do |role| %> <% end %>

+

<%= submit_tag l(:button_add) %>

<% end %> diff --git a/app/views/users/_groups.rhtml b/app/views/users/_groups.rhtml new file mode 100644 index 00000000..6d0c2b3f --- /dev/null +++ b/app/views/users/_groups.rhtml @@ -0,0 +1,9 @@ +<% form_for(:user, :url => { :action => 'edit' }) do %> +
+<% Group.all.each do |group| %> +
+<% end %> +<%= hidden_field_tag 'user[group_ids][]', '' %> +
+<%= submit_tag l(:button_save) %> +<% end %> diff --git a/app/views/users/_memberships.rhtml b/app/views/users/_memberships.rhtml index cca982a3..7659f1cc 100644 --- a/app/views/users/_memberships.rhtml +++ b/app/views/users/_memberships.rhtml @@ -20,17 +20,19 @@ <% remote_form_for(:membership, :url => { :action => 'edit_membership', :id => @user, :membership_id => membership }, :html => { :id => "member-#{membership.id}-roles-form", :style => 'display:none;'}) do %>

<% roles.each do |role| %> -
+
<% end %>

+ <%= hidden_field_tag 'membership[role_ids][]', '' %>

<%= submit_tag l(:button_change) %> <%= link_to_function l(:button_cancel), "$('member-#{membership.id}-roles').show(); $('member-#{membership.id}-roles-form').hide(); return false;" %>

<% end %>
<%= call_hook(:view_users_memberships_table_row, :user => @user, :membership => membership, :roles => roles, :projects => projects )%> diff --git a/config/locales/en.yml b/config/locales/en.yml index f77079e3..93733e42 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -683,6 +683,9 @@ en: label_date_from_to: From {{start}} to {{end}} label_wiki_content_added: Wiki page added label_wiki_content_updated: Wiki page updated + label_group: Group + label_group_plural: Groups + label_group_new: New group button_login: Login button_submit: Submit diff --git a/config/routes.rb b/config/routes.rb index ded3435b..5357fc30 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -229,7 +229,8 @@ ActionController::Routing::Routes.draw do |map| map.connect 'attachments/:id/:filename', :controller => 'attachments', :action => 'show', :id => /\d+/, :filename => /.*/ map.connect 'attachments/download/:id/:filename', :controller => 'attachments', :action => 'download', :id => /\d+/, :filename => /.*/ - + map.resources :groups + #left old routes at the bottom for backwards compat map.connect 'projects/:project_id/issues/:action', :controller => 'issues' map.connect 'projects/:project_id/documents/:action', :controller => 'documents' diff --git a/db/migrate/20090704172350_populate_users_type.rb b/db/migrate/20090704172350_populate_users_type.rb new file mode 100644 index 00000000..1c31fece --- /dev/null +++ b/db/migrate/20090704172350_populate_users_type.rb @@ -0,0 +1,8 @@ +class PopulateUsersType < ActiveRecord::Migration + def self.up + Principal.update_all("type = 'User'", "type IS NULL") + end + + def self.down + end +end diff --git a/db/migrate/20090704172355_create_groups_users.rb b/db/migrate/20090704172355_create_groups_users.rb new file mode 100644 index 00000000..9ce03b95 --- /dev/null +++ b/db/migrate/20090704172355_create_groups_users.rb @@ -0,0 +1,13 @@ +class CreateGroupsUsers < ActiveRecord::Migration + def self.up + create_table :groups_users, :id => false do |t| + t.column :group_id, :integer, :null => false + t.column :user_id, :integer, :null => false + end + add_index :groups_users, [:group_id, :user_id], :unique => true, :name => :groups_users_ids + end + + def self.down + drop_table :groups_users + end +end diff --git a/db/migrate/20090704172358_add_member_roles_inherited_from.rb b/db/migrate/20090704172358_add_member_roles_inherited_from.rb new file mode 100644 index 00000000..4ffa5238 --- /dev/null +++ b/db/migrate/20090704172358_add_member_roles_inherited_from.rb @@ -0,0 +1,9 @@ +class AddMemberRolesInheritedFrom < ActiveRecord::Migration + def self.up + add_column :member_roles, :inherited_from, :integer + end + + def self.down + remove_column :member_roles, :inherited_from + end +end diff --git a/lib/redmine.rb b/lib/redmine.rb index 6188e7e5..0602691a 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -23,7 +23,7 @@ Redmine::AccessControl.map do |map| map.permission :add_project, {:projects => :add}, :require => :loggedin map.permission :edit_project, {:projects => [:settings, :edit]}, :require => :member map.permission :select_project_modules, {:projects => :modules}, :require => :member - map.permission :manage_members, {:projects => :settings, :members => [:new, :edit, :destroy, :autocomplete_for_member_login]}, :require => :member + map.permission :manage_members, {:projects => :settings, :members => [:new, :edit, :destroy, :autocomplete_for_member]}, :require => :member map.permission :manage_versions, {:projects => [:settings, :add_version], :versions => [:edit, :destroy]}, :require => :member map.project_module :issue_tracking do |map| diff --git a/public/images/22x22/groups.png b/public/images/22x22/groups.png new file mode 100644 index 0000000000000000000000000000000000000000..92f396207e63830c90639800f86b0a803cc5b306 GIT binary patch literal 1543 zcmd^<-%s6C9Ka7_47iFmZfw^jntP)QD`s%-R9J~~QX#;Rdtrs?>~Q0*V+sXEb_iQ+ zOb|+CL$>&LNR-2woEyZZ5HQ47d7I!&;AMfoX_`s zlW)G?hwphf?>8oE3PM{$0DywJ+A5CZ&6&u{CikBmT0Ti7z191BUq0TJ>TN&P9cgaw zY6C~wyGkPEk&aY%UrA(VELI+gF%{20Td|XgJQIt@V%0OuZ8DkLTbHQLUCPQ0g`O&_ zIZcsLeoyV|2!POPCbHP6=gyJh`Zp^Rl>mHo=AnO5IRIpX#`@-JQU`ey9gj0ol#PcW zLqV2-EDIqAISz_p-l7Gb6BJIcXc5v#WDr4$B7;LFkw+FpHiK*yIZ$GNBtpsLC5xAA zMsipgQZfw794$Lgfq4p~6fUgrQH7@!TU1dr4Y8U9HJjHQLGzG?kuFd=is}-l%bc!jI!1;VHBj6@j3Gfo<_%de6wXit6NOC_ zH6_NBIaB6M6`8hZV%?IWmPA`JYst`3MN37NCRqlwY|FB33mY~9woKa!V{36+S8PME zGdP)}(2l}5D($F@qX~|wIRuyJI@osQu&ab!6}lRcb1mJq4A&-_F2Qr%!0{ByQ=*>E zc{=YIg6CMCYk3*4=et-2Sczgaj8z6}G}alcvsmY_Az)L+u8lnxW9%!GuTs87`&!)B ziHxtazCq-CgZE9*_Z;7IeN6Ox((e=fz~BOtC&h7 z0FVWGxn$D=wfg^;1x-QjHu4r#bybzkC$9_l&lJWQ51qXG%Xiyn#LcA#$;8U+_W9#a z-~9f{yWSP?U;MUI{Ml>QhKh14c71wjTf@E8p2qQeE!i8B@vnOxJ@Hd8{blUOeT$d+ zL#2N-BeA7?&)nQg-%d?e6;Z|CM2aq_ew`iL*iyLo7@Ifuhs43bkyDpH9v<31y!Edg_s2$-2IX7R^_^2YjLy!niJWC4ef02&>tIv& zmRqBv>9ne*(|CEdwl#I+#<_KyN*2_!V_EC=rc!$kmexF(?7QZ!JuHqVCMTWwR~iz- zSpz>$54iLXW&QGaqFwAVHVh7|th{=0m4EMQW$WX4{oxzw4dBJXvR#k-o~Y0KuXWYQ Ks?XWhf&T!}l;L9l literal 0 HcmV?d00001 diff --git a/public/images/22x22/users.png b/public/images/22x22/users.png index 92f396207e63830c90639800f86b0a803cc5b306..b3bef89c28d36705bad5b52e308eed17f63b380a 100644 GIT binary patch literal 1052 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H3?x5i&EW)6>?NMQuIxA2Svh5_f;X110);pW zJR*x37`Q%wFr(8NlNmrkmUKs7M+SzC{oH>Nfr^+J0(?ST|1&VO>@AL3s~H4DOBJ2w zNNl~gH+!#U^)buhV}?#W5HXTEt<{F-rg)v3;`DrJ(D$v$bFwX`L}`BCTRbOOaqpa#ImJ#}Tb!P+ z&0bq=+2SRzv^r{Ob#~>!wZHwr$$5VS`g>c2d%!{H(ORyu?nOHTek%3vxMF zZn>Q}bj!+WS5?9S*Om=!YCH=#R`3w8v=#WC`T1nyNhuVyK-d VCjWEFC0>wkJzf1=);T3K0RTtq!UzBW literal 1543 zcmd^<-%s6C9Ka7_47iFmZfw^jntP)QD`s%-R9J~~QX#;Rdtrs?>~Q0*V+sXEb_iQ+ zOb|+CL$>&LNR-2woEyZZ5HQ47d7I!&;AMfoX_`s zlW)G?hwphf?>8oE3PM{$0DywJ+A5CZ&6&u{CikBmT0Ti7z191BUq0TJ>TN&P9cgaw zY6C~wyGkPEk&aY%UrA(VELI+gF%{20Td|XgJQIt@V%0OuZ8DkLTbHQLUCPQ0g`O&_ zIZcsLeoyV|2!POPCbHP6=gyJh`Zp^Rl>mHo=AnO5IRIpX#`@-JQU`ey9gj0ol#PcW zLqV2-EDIqAISz_p-l7Gb6BJIcXc5v#WDr4$B7;LFkw+FpHiK*yIZ$GNBtpsLC5xAA zMsipgQZfw794$Lgfq4p~6fUgrQH7@!TU1dr4Y8U9HJjHQLGzG?kuFd=is}-l%bc!jI!1;VHBj6@j3Gfo<_%de6wXit6NOC_ zH6_NBIaB6M6`8hZV%?IWmPA`JYst`3MN37NCRqlwY|FB33mY~9woKa!V{36+S8PME zGdP)}(2l}5D($F@qX~|wIRuyJI@osQu&ab!6}lRcb1mJq4A&-_F2Qr%!0{ByQ=*>E zc{=YIg6CMCYk3*4=et-2Sczgaj8z6}G}alcvsmY_Az)L+u8lnxW9%!GuTs87`&!)B ziHxtazCq-CgZE9*_Z;7IeN6Ox((e=fz~BOtC&h7 z0FVWGxn$D=wfg^;1x-QjHu4r#bybzkC$9_l&lJWQ51qXG%Xiyn#LcA#$;8U+_W9#a z-~9f{yWSP?U;MUI{Ml>QhKh14c71wjTf@E8p2qQeE!i8B@vnOxJ@Hd8{blUOeT$d+ zL#2N-BeA7?&)nQg-%d?e6;Z|CM2aq_ew`iL*iyLo7@Ifuhs43bkyDpH9v<31y!Edg_s2$-2IX7R^_^2YjLy!niJWC4ef02&>tIv& zmRqBv>9ne*(|CEdwl#I+#<_KyN*2_!V_EC=rc!$kmexF(?7QZ!JuHqVCMTWwR~iz- zSpz>$54iLXW&QGaqFwAVHVh7|th{=0m4EMQW$WX4{oxzw4dBJXvR#k-o~Y0KuXWYQ Ks?XWhf&T!}l;L9l diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index 21bebe56..405ac537 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -342,12 +342,14 @@ p.other-formats { text-align: right; font-size:0.9em; color: #666; } a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; } /* Project members tab */ -div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft { width: 64% } -div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright { width: 34% } -div#tab-content-members fieldset, div#tab-content-memberships fieldset { padding:1em; margin-bottom: 1em; } -div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend { font-weight: bold; } -div#tab-content-members fieldset label, div#tab-content-memberships fieldset label { display: block; } -div#tab-content-members fieldset div { max-height: 400px; overflow:auto; } +div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% } +div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% } +div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; } +div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; } +div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; } +div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; } + +table.members td.group { padding-left: 20px; background: url(../images/users.png) no-repeat 0% 0%; } * html div#tab-content-members fieldset div { height: 450px; } @@ -725,6 +727,7 @@ vertical-align: middle; .icon22-projects { background-image: url(../images/22x22/projects.png); } .icon22-users { background-image: url(../images/22x22/users.png); } +.icon22-groups { background-image: url(../images/22x22/groups.png); } .icon22-tracker { background-image: url(../images/22x22/tracker.png); } .icon22-role { background-image: url(../images/22x22/role.png); } .icon22-workflow { background-image: url(../images/22x22/workflow.png); } diff --git a/test/fixtures/groups_users.yml b/test/fixtures/groups_users.yml new file mode 100644 index 00000000..3702d6ef --- /dev/null +++ b/test/fixtures/groups_users.yml @@ -0,0 +1,5 @@ +--- +groups_users_001: + group_id: 10 + user_id: 8 + \ No newline at end of file diff --git a/test/fixtures/member_roles.yml b/test/fixtures/member_roles.yml index 6c91f15a..e0fc6d06 100644 --- a/test/fixtures/member_roles.yml +++ b/test/fixtures/member_roles.yml @@ -19,5 +19,21 @@ member_roles_005: id: 5 role_id: 1 member_id: 5 - - \ No newline at end of file +member_roles_006: + id: 6 + role_id: 1 + member_id: 6 +member_roles_007: + id: 7 + role_id: 2 + member_id: 6 +member_roles_008: + id: 8 + role_id: 1 + member_id: 7 + inherited_from: 6 +member_roles_009: + id: 9 + role_id: 2 + member_id: 7 + inherited_from: 7 diff --git a/test/fixtures/members.yml b/test/fixtures/members.yml index b1b19c3f..3fd785b9 100644 --- a/test/fixtures/members.yml +++ b/test/fixtures/members.yml @@ -30,4 +30,16 @@ members_005: project_id: 5 user_id: 2 mail_notification: true +members_006: + id: 6 + created_on: 2006-07-19 19:35:33 +02:00 + project_id: 5 + user_id: 10 + mail_notification: false +members_007: + id: 7 + created_on: 2006-07-19 19:35:33 +02:00 + project_id: 5 + user_id: 8 + mail_notification: false \ No newline at end of file diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 8be82284..442ddc15 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -144,5 +144,13 @@ users_009: mail_notification: false login: miscuser9 type: User +groups_010: + id: 10 + lastname: A Team + type: Group +groups_011: + id: 11 + lastname: B Team + type: Group \ No newline at end of file diff --git a/test/functional/groups_controller_test.rb b/test/functional/groups_controller_test.rb new file mode 100644 index 00000000..ac61db42 --- /dev/null +++ b/test/functional/groups_controller_test.rb @@ -0,0 +1,107 @@ +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require File.dirname(__FILE__) + '/../test_helper' +require 'groups_controller' + +# Re-raise errors caught by the controller. +class GroupsController; def rescue_action(e) raise e end; end + +class GroupsControllerTest < Test::Unit::TestCase + fixtures :projects, :users, :members, :member_roles + + def setup + @controller = GroupsController.new + @request = ActionController::TestRequest.new + @response = ActionController::TestResponse.new + User.current = nil + @request.session[:user_id] = 1 + end + + def test_index + get :index + assert_response :success + assert_template 'index' + end + + def test_show + get :show, :id => 10 + assert_response :success + assert_template 'show' + end + + def test_new + get :new + assert_response :success + assert_template 'new' + end + + def test_create + assert_difference 'Group.count' do + post :create, :group => {:lastname => 'New group'} + end + assert_redirected_to 'groups' + end + + def test_edit + get :edit, :id => 10 + assert_response :success + assert_template 'edit' + end + + def test_update + post :update, :id => 10 + assert_redirected_to 'groups' + end + + def test_destroy + assert_difference 'Group.count', -1 do + post :destroy, :id => 10 + end + assert_redirected_to 'groups' + end + + def test_add_users + assert_difference 'Group.find(10).users.count', 2 do + post :add_users, :id => 10, :user_ids => ['2', '3'] + end + end + + def test_remove_user + assert_difference 'Group.find(10).users.count', -1 do + post :remove_user, :id => 10, :user_id => '8' + end + end + + def test_new_membership + assert_difference 'Group.find(10).members.count' do + post :edit_membership, :id => 10, :membership => { :project_id => 2, :role_ids => ['1', '2']} + end + end + + def test_edit_membership + assert_no_difference 'Group.find(10).members.count' do + post :edit_membership, :id => 10, :membership_id => 6, :membership => { :role_ids => ['1', '3']} + end + end + + def test_destroy_membership + assert_difference 'Group.find(10).members.count', -1 do + post :destroy_membership, :id => 10, :membership_id => 6 + end + end +end diff --git a/test/functional/members_controller_test.rb b/test/functional/members_controller_test.rb index 2680c393..91f36abe 100644 --- a/test/functional/members_controller_test.rb +++ b/test/functional/members_controller_test.rb @@ -48,14 +48,6 @@ class MembersControllerTest < Test::Unit::TestCase assert User.find(7).member_of?(Project.find(1)) end - def test_create_by_user_login - assert_difference 'Member.count' do - post :new, :id => 1, :member => {:role_ids => [1], :user_login => 'someone'} - end - assert_redirected_to '/projects/ecookbook/settings/members' - assert User.find(7).member_of?(Project.find(1)) - end - def test_create_multiple assert_difference 'Member.count', 3 do post :new, :id => 1, :member => {:role_ids => [1], :user_ids => [7, 8, 9]} @@ -79,11 +71,12 @@ class MembersControllerTest < Test::Unit::TestCase assert !User.find(3).member_of?(Project.find(1)) end - def test_autocomplete_for_member_login - get :autocomplete_for_member_login, :id => 1, :user => 'mis' + def test_autocomplete_for_member + get :autocomplete_for_member, :id => 1, :q => 'mis' assert_response :success - assert_template 'autocomplete_for_member_login' + assert_template 'autocomplete_for_member' - assert_tag :ul, :child => {:tag => 'li', :content => /miscuser8/} + assert_tag :label, :content => /User Misc/, + :child => { :tag => 'input', :attributes => { :name => 'member[user_ids][]', :value => '8' } } end end diff --git a/test/integration/admin_test.rb b/test/integration/admin_test.rb index dd14e666..66c6b21c 100644 --- a/test/integration/admin_test.rb +++ b/test/integration/admin_test.rb @@ -18,7 +18,7 @@ require "#{File.dirname(__FILE__)}/../test_helper" class AdminTest < ActionController::IntegrationTest - fixtures :users + fixtures :all def test_add_user log_user("admin", "admin") @@ -26,16 +26,17 @@ class AdminTest < ActionController::IntegrationTest assert_response :success assert_template "users/add" post "/users/add", :user => { :login => "psmith", :firstname => "Paul", :lastname => "Smith", :mail => "psmith@somenet.foo", :language => "en" }, :password => "psmith09", :password_confirmation => "psmith09" - assert_redirected_to "/users" user = User.find_by_login("psmith") assert_kind_of User, user + assert_redirected_to "/users/#{ user.id }/edit" + logged_user = User.try_to_login("psmith", "psmith09") assert_kind_of User, logged_user assert_equal "Paul", logged_user.firstname post "users/edit", :id => user.id, :user => { :status => User::STATUS_LOCKED } - assert_redirected_to "/users" + assert_redirected_to "/users/#{ user.id }/edit" locked_user = User.try_to_login("psmith", "psmith09") assert_equal nil, locked_user end diff --git a/test/unit/group_test.rb b/test/unit/group_test.rb new file mode 100644 index 00000000..79b9d418 --- /dev/null +++ b/test/unit/group_test.rb @@ -0,0 +1,77 @@ +# Redmine - project management software +# Copyright (C) 2006-2009 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require File.dirname(__FILE__) + '/../test_helper' + +class GroupTest < Test::Unit::TestCase + fixtures :all + + def test_create + g = Group.new(:lastname => 'New group') + assert g.save + end + + def test_roles_given_to_new_user + group = Group.find(11) + user = User.find(9) + project = Project.first + + Member.create!(:principal => group, :project => project, :role_ids => [1, 2]) + group.users << user + assert user.member_of?(project) + end + + def test_roles_given_to_existing_user + group = Group.find(11) + user = User.find(9) + project = Project.first + + group.users << user + m = Member.create!(:principal => group, :project => project, :role_ids => [1, 2]) + assert user.member_of?(project) + end + + def test_roles_updated + group = Group.find(11) + user = User.find(9) + project = Project.first + group.users << user + m = Member.create!(:principal => group, :project => project, :role_ids => [1]) + assert_equal [1], user.reload.roles_for_project(project).collect(&:id).sort + + m.role_ids = [1, 2] + assert_equal [1, 2], user.reload.roles_for_project(project).collect(&:id).sort + + m.role_ids = [2] + assert_equal [2], user.reload.roles_for_project(project).collect(&:id).sort + + m.role_ids = [1] + assert_equal [1], user.reload.roles_for_project(project).collect(&:id).sort + end + + def test_roles_removed_when_removing_group_membership + assert User.find(8).member_of?(Project.find(5)) + Member.find_by_project_id_and_user_id(5, 10).destroy + assert !User.find(8).member_of?(Project.find(5)) + end + + def test_roles_removed_when_removing_user_from_group + assert User.find(8).member_of?(Project.find(5)) + User.find(8).groups.clear + assert !User.find(8).member_of?(Project.find(5)) + end +end diff --git a/test/unit/project_test.rb b/test/unit/project_test.rb index 60bc4f1c..13c63a05 100644 --- a/test/unit/project_test.rb +++ b/test/unit/project_test.rb @@ -63,6 +63,18 @@ class ProjectTest < Test::Unit::TestCase end end + def test_members_should_be_active_users + Project.all.each do |project| + assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) } + end + end + + def test_users_should_be_active_users + Project.all.each do |project| + assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) } + end + end + def test_archive user = @ecookbook.members.first.user @ecookbook.archive
<%= l(:label_user) %><%= l(:label_user) %> / <%= l(:label_group) %> <%= l(:label_role_plural) %>
<%= link_to_user member.user %><%= link_to_user member.principal %> <%=h member.roles.sort.collect(&:to_s).join(', ') %> <% if authorize_for('members', 'edit') %> @@ -23,8 +23,10 @@ :method => :post, :html => { :id => "member-#{member.id}-roles-form", :style => 'display:none;' }) do |f| %>

<% roles.each do |role| %> -
+
<% end %>

+ <%= hidden_field_tag 'member[role_ids][]', '' %>

<%= submit_tag l(:button_change), :class => "small" %> <%= link_to_function l(:button_cancel), "$('member-#{member.id}-roles').show(); $('member-#{member.id}-roles-form').hide(); return false;" %>

<% end %> @@ -32,10 +34,10 @@
<%= link_to_function l(:button_edit), "$('member-#{member.id}-roles').hide(); $('member-#{member.id}-roles-form').show(); return false;", :class => 'icon icon-edit' %> - <%= link_to_remote l(:button_delete), { :url => {:controller => 'members', :action => 'destroy', :id => member}, + <%= link_to_remote(l(:button_delete), { :url => {:controller => 'members', :action => 'destroy', :id => member}, :method => :post }, :title => l(:button_delete), - :class => 'icon icon-del' %> + :class => 'icon icon-del') if member.deletable? %>
<%= link_to_function l(:button_edit), "$('member-#{membership.id}-roles').hide(); $('member-#{membership.id}-roles-form').show(); return false;", :class => 'icon icon-edit' %> - <%= link_to_remote l(:button_delete), { :url => { :controller => 'users', :action => 'destroy_membership', :id => @user, :membership_id => membership }, + <%= link_to_remote(l(:button_delete), { :url => { :controller => 'users', :action => 'destroy_membership', :id => @user, :membership_id => membership }, :method => :post }, - :class => 'icon icon-del' %> + :class => 'icon icon-del') if membership.deletable? %>