##// END OF EJS Templates
Adds buit-in groups to give specific permissions to anonymous and non members users per project (#17976)....
Jean-Philippe Lang -
r13053:7e7ac5340a28
parent child
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -0,0 +1,30
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 class GroupAnonymous < GroupBuiltin
19 def name
20 l(:label_group_anonymous)
21 end
22
23 def builtin_type
24 "anonymous"
25 end
26
27 def self.instance_id
28 @@instance_id ||= load_instance.id
29 end
30 end
@@ -0,0 +1,56
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 class GroupBuiltin < Group
19 validate :validate_uniqueness, :on => :create
20
21 def validate_uniqueness
22 errors.add :base, 'The builtin group already exists.' if self.class.exists?
23 end
24
25 def builtin?
26 true
27 end
28
29 def destroy
30 false
31 end
32
33 def user_added(user)
34 raise 'Cannot add users to a builtin group'
35 end
36
37 class << self
38 def load_instance
39 return nil if self == GroupBuiltin
40 instance = first(:order => 'id') || create_instance
41 end
42
43 def create_instance
44 raise 'The builtin group already exists.' if exists?
45 instance = new
46 instance.lastname = name
47 instance.save :validate => false
48 raise 'Unable to create builtin group.' if instance.new_record?
49 instance
50 end
51 private :create_instance
52 end
53 end
54
55 require_dependency "group_anonymous"
56 require_dependency "group_non_member"
@@ -0,0 +1,30
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 class GroupNonMember < GroupBuiltin
19 def name
20 l(:label_group_non_member)
21 end
22
23 def builtin_type
24 "non_member"
25 end
26
27 def self.instance_id
28 @@instance_id ||= load_instance.id
29 end
30 end
@@ -0,0 +1,21
1 class InsertBuiltinGroups < ActiveRecord::Migration
2 def up
3 Group.reset_column_information
4
5 unless GroupAnonymous.any?
6 g = GroupAnonymous.new(:lastname => 'Anonymous users')
7 g.status = 1
8 g.save :validate => false
9 end
10 unless GroupNonMember.any?
11 g = GroupNonMember.new(:lastname => 'Non member users')
12 g.status = 1
13 g.save :validate => false
14 end
15 end
16
17 def down
18 GroupAnonymous.delete_all
19 GroupNonMember.delete_all
20 end
21 end
@@ -1,150 +1,154
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class GroupsController < ApplicationController
19 19 layout 'admin'
20 20
21 21 before_filter :require_admin
22 22 before_filter :find_group, :except => [:index, :new, :create]
23 23 accept_api_auth :index, :show, :create, :update, :destroy, :add_users, :remove_user
24 24
25 25 helper :custom_fields
26 26
27 27 def index
28 @groups = Group.sorted.all
29 28 respond_to do |format|
30 29 format.html {
30 @groups = Group.sorted.all
31 31 @user_count_by_group_id = user_count_by_group_id
32 32 }
33 format.api
33 format.api {
34 scope = Group.sorted
35 scope = scope.givable unless params[:builtin] == '1'
36 @groups = scope.all
37 }
34 38 end
35 39 end
36 40
37 41 def show
38 42 respond_to do |format|
39 43 format.html
40 44 format.api
41 45 end
42 46 end
43 47
44 48 def new
45 49 @group = Group.new
46 50 end
47 51
48 52 def create
49 53 @group = Group.new
50 54 @group.safe_attributes = params[:group]
51 55
52 56 respond_to do |format|
53 57 if @group.save
54 58 format.html {
55 59 flash[:notice] = l(:notice_successful_create)
56 60 redirect_to(params[:continue] ? new_group_path : groups_path)
57 61 }
58 62 format.api { render :action => 'show', :status => :created, :location => group_url(@group) }
59 63 else
60 64 format.html { render :action => "new" }
61 65 format.api { render_validation_errors(@group) }
62 66 end
63 67 end
64 68 end
65 69
66 70 def edit
67 71 end
68 72
69 73 def update
70 74 @group.safe_attributes = params[:group]
71 75
72 76 respond_to do |format|
73 77 if @group.save
74 78 flash[:notice] = l(:notice_successful_update)
75 79 format.html { redirect_to(groups_path) }
76 80 format.api { render_api_ok }
77 81 else
78 82 format.html { render :action => "edit" }
79 83 format.api { render_validation_errors(@group) }
80 84 end
81 85 end
82 86 end
83 87
84 88 def destroy
85 89 @group.destroy
86 90
87 91 respond_to do |format|
88 92 format.html { redirect_to(groups_path) }
89 93 format.api { render_api_ok }
90 94 end
91 95 end
92 96
93 97 def add_users
94 98 @users = User.where(:id => (params[:user_id] || params[:user_ids])).all
95 99 @group.users << @users if request.post?
96 100 respond_to do |format|
97 101 format.html { redirect_to edit_group_path(@group, :tab => 'users') }
98 102 format.js
99 103 format.api { render_api_ok }
100 104 end
101 105 end
102 106
103 107 def remove_user
104 108 @group.users.delete(User.find(params[:user_id])) if request.delete?
105 109 respond_to do |format|
106 110 format.html { redirect_to edit_group_path(@group, :tab => 'users') }
107 111 format.js
108 112 format.api { render_api_ok }
109 113 end
110 114 end
111 115
112 116 def autocomplete_for_user
113 117 respond_to do |format|
114 118 format.js
115 119 end
116 120 end
117 121
118 122 def edit_membership
119 123 @membership = Member.edit_membership(params[:membership_id], params[:membership], @group)
120 124 @membership.save if request.post?
121 125 respond_to do |format|
122 126 format.html { redirect_to edit_group_path(@group, :tab => 'memberships') }
123 127 format.js
124 128 end
125 129 end
126 130
127 131 def destroy_membership
128 132 Member.find(params[:membership_id]).destroy if request.post?
129 133 respond_to do |format|
130 134 format.html { redirect_to edit_group_path(@group, :tab => 'memberships') }
131 135 format.js
132 136 end
133 137 end
134 138
135 139 private
136 140
137 141 def find_group
138 142 @group = Group.find(params[:id])
139 143 rescue ActiveRecord::RecordNotFound
140 144 render_404
141 145 end
142 146
143 147 def user_count_by_group_id
144 148 h = User.joins(:groups).group('group_id').count
145 149 h.keys.each do |key|
146 150 h[key.to_i] = h.delete(key)
147 151 end
148 152 h
149 153 end
150 154 end
@@ -1,42 +1,43
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2014 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module GroupsHelper
21 def group_settings_tabs
22 tabs = [{:name => 'general', :partial => 'groups/general', :label => :label_general},
23 {:name => 'users', :partial => 'groups/users', :label => :label_user_plural},
24 {:name => 'memberships', :partial => 'groups/memberships', :label => :label_project_plural}
25 ]
21 def group_settings_tabs(group)
22 tabs = []
23 tabs << {:name => 'general', :partial => 'groups/general', :label => :label_general}
24 tabs << {:name => 'users', :partial => 'groups/users', :label => :label_user_plural} if group.givable?
25 tabs << {:name => 'memberships', :partial => 'groups/memberships', :label => :label_project_plural}
26 tabs
26 27 end
27 28
28 29 def render_principals_for_new_group_users(group)
29 30 scope = User.active.sorted.not_in_group(group).like(params[:q])
30 31 principal_count = scope.count
31 32 principal_pages = Redmine::Pagination::Paginator.new principal_count, 100, params['page']
32 33 principals = scope.offset(principal_pages.offset).limit(principal_pages.per_page).all
33 34
34 35 s = content_tag('div', principals_check_box_tags('user_ids[]', principals), :id => 'principals')
35 36
36 37 links = pagination_links_full(principal_pages, principal_count, :per_page_links => false) {|text, parameters, options|
37 38 link_to text, autocomplete_for_user_group_path(group, parameters.merge(:q => params[:q], :format => 'js')), :remote => true
38 39 }
39 40
40 41 s + content_tag('p', links, :class => 'pagination')
41 42 end
42 43 end
@@ -1,54 +1,54
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2014 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module UsersHelper
21 21 def users_status_options_for_select(selected)
22 22 user_count_by_status = User.group('status').count.to_hash
23 23 options_for_select([[l(:label_all), ''],
24 24 ["#{l(:status_active)} (#{user_count_by_status[1].to_i})", '1'],
25 25 ["#{l(:status_registered)} (#{user_count_by_status[2].to_i})", '2'],
26 26 ["#{l(:status_locked)} (#{user_count_by_status[3].to_i})", '3']], selected.to_s)
27 27 end
28 28
29 29 def user_mail_notification_options(user)
30 30 user.valid_notification_options.collect {|o| [l(o.last), o.first]}
31 31 end
32 32
33 33 def change_status_link(user)
34 34 url = {:controller => 'users', :action => 'update', :id => user, :page => params[:page], :status => params[:status], :tab => nil}
35 35
36 36 if user.locked?
37 37 link_to l(:button_unlock), url.merge(:user => {:status => User::STATUS_ACTIVE}), :method => :put, :class => 'icon icon-unlock'
38 38 elsif user.registered?
39 39 link_to l(:button_activate), url.merge(:user => {:status => User::STATUS_ACTIVE}), :method => :put, :class => 'icon icon-unlock'
40 40 elsif user != User.current
41 41 link_to l(:button_lock), url.merge(:user => {:status => User::STATUS_LOCKED}), :method => :put, :class => 'icon icon-lock'
42 42 end
43 43 end
44 44
45 45 def user_settings_tabs
46 46 tabs = [{:name => 'general', :partial => 'users/general', :label => :label_general},
47 47 {:name => 'memberships', :partial => 'users/memberships', :label => :label_project_plural}
48 48 ]
49 if Group.all.any?
49 if Group.givable.any?
50 50 tabs.insert 1, {:name => 'groups', :partial => 'users/groups', :label => :label_group_plural}
51 51 end
52 52 tabs
53 53 end
54 54 end
@@ -1,91 +1,120
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Group < Principal
19 19 include Redmine::SafeAttributes
20 20
21 21 has_and_belongs_to_many :users,
22 22 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
23 23 :after_add => :user_added,
24 24 :after_remove => :user_removed
25 25
26 26 acts_as_customizable
27 27
28 28 validates_presence_of :lastname
29 29 validates_uniqueness_of :lastname, :case_sensitive => false
30 30 validates_length_of :lastname, :maximum => 255
31 31
32 32 before_destroy :remove_references_before_destroy
33 33
34 scope :sorted, lambda { order("#{table_name}.lastname ASC") }
34 scope :sorted, lambda { order("#{table_name}.type, #{table_name}.lastname ASC") }
35 35 scope :named, lambda {|arg| where("LOWER(#{table_name}.lastname) = LOWER(?)", arg.to_s.strip)}
36 scope :givable, lambda {where(:type => 'Group')}
36 37
37 38 safe_attributes 'name',
38 39 'user_ids',
39 40 'custom_field_values',
40 41 'custom_fields',
41 :if => lambda {|group, user| user.admin?}
42 :if => lambda {|group, user| user.admin? && !group.builtin?}
42 43
43 44 def to_s
44 lastname.to_s
45 name.to_s
45 46 end
46 47
47 48 def name
48 49 lastname
49 50 end
50 51
51 52 def name=(arg)
52 53 self.lastname = arg
53 54 end
54 55
56 def builtin_type
57 nil
58 end
59
60 # Return true if the group is a builtin group
61 def builtin?
62 false
63 end
64
65 # Returns true if the group can be given to a user
66 def givable?
67 !builtin?
68 end
69
55 70 def user_added(user)
56 71 members.each do |member|
57 72 next if member.project.nil?
58 73 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)
59 74 member.member_roles.each do |member_role|
60 75 user_member.member_roles << MemberRole.new(:role => member_role.role, :inherited_from => member_role.id)
61 76 end
62 77 user_member.save!
63 78 end
64 79 end
65 80
66 81 def user_removed(user)
67 82 members.each do |member|
68 83 MemberRole.
69 84 includes(:member).
70 85 where("#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids).
71 86 each(&:destroy)
72 87 end
73 88 end
74 89
75 90 def self.human_attribute_name(attribute_key_name, *args)
76 91 attr_name = attribute_key_name.to_s
77 92 if attr_name == 'lastname'
78 93 attr_name = "name"
79 94 end
80 95 super(attr_name, *args)
81 96 end
82 97
98 def self.builtin_id(arg)
99 (arg.anonymous? ? GroupAnonymous : GroupNonMember).instance_id
100 end
101
102 def self.anonymous
103 GroupAnonymous.load_instance
104 end
105
106 def self.non_member
107 GroupNonMember.load_instance
108 end
109
83 110 private
84 111
85 112 # Removes references that are not handled by associations
86 113 def remove_references_before_destroy
87 114 return if self.id.nil?
88 115
89 116 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
90 117 end
91 118 end
119
120 require_dependency "group_builtin"
@@ -1,484 +1,485
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueQuery < Query
19 19
20 20 self.queried_class = Issue
21 21
22 22 self.available_columns = [
23 23 QueryColumn.new(:id, :sortable => "#{Issue.table_name}.id", :default_order => 'desc', :caption => '#', :frozen => true),
24 24 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
25 25 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
26 26 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
27 27 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
28 28 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
29 29 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
30 30 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
31 31 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
32 32 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
33 33 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
34 34 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
35 35 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
36 36 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
37 37 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
38 38 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
39 39 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
40 40 QueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc'),
41 41 QueryColumn.new(:relations, :caption => :label_related_issues),
42 42 QueryColumn.new(:description, :inline => false)
43 43 ]
44 44
45 45 scope :visible, lambda {|*args|
46 46 user = args.shift || User.current
47 47 base = Project.allowed_to_condition(user, :view_issues, *args)
48 48 scope = includes(:project).where("#{table_name}.project_id IS NULL OR (#{base})")
49 49
50 50 if user.admin?
51 51 scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", VISIBILITY_PRIVATE, user.id)
52 52 elsif user.memberships.any?
53 53 scope.where("#{table_name}.visibility = ?" +
54 54 " OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
55 55 "SELECT DISTINCT q.id FROM #{table_name} q" +
56 56 " INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
57 57 " INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
58 58 " INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
59 59 " WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
60 60 " OR #{table_name}.user_id = ?",
61 61 VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, user.id)
62 62 elsif user.logged?
63 63 scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
64 64 else
65 65 scope.where("#{table_name}.visibility = ?", VISIBILITY_PUBLIC)
66 66 end
67 67 }
68 68
69 69 def initialize(attributes=nil, *args)
70 70 super attributes
71 71 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
72 72 end
73 73
74 74 # Returns true if the query is visible to +user+ or the current user.
75 75 def visible?(user=User.current)
76 76 return true if user.admin?
77 77 return false unless project.nil? || user.allowed_to?(:view_issues, project)
78 78 case visibility
79 79 when VISIBILITY_PUBLIC
80 80 true
81 81 when VISIBILITY_ROLES
82 82 if project
83 83 (user.roles_for_project(project) & roles).any?
84 84 else
85 85 Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
86 86 end
87 87 else
88 88 user == self.user
89 89 end
90 90 end
91 91
92 92 def is_private?
93 93 visibility == VISIBILITY_PRIVATE
94 94 end
95 95
96 96 def is_public?
97 97 !is_private?
98 98 end
99 99
100 100 def draw_relations
101 101 r = options[:draw_relations]
102 102 r.nil? || r == '1'
103 103 end
104 104
105 105 def draw_relations=(arg)
106 106 options[:draw_relations] = (arg == '0' ? '0' : nil)
107 107 end
108 108
109 109 def draw_progress_line
110 110 r = options[:draw_progress_line]
111 111 r == '1'
112 112 end
113 113
114 114 def draw_progress_line=(arg)
115 115 options[:draw_progress_line] = (arg == '1' ? '1' : nil)
116 116 end
117 117
118 118 def build_from_params(params)
119 119 super
120 120 self.draw_relations = params[:draw_relations] || (params[:query] && params[:query][:draw_relations])
121 121 self.draw_progress_line = params[:draw_progress_line] || (params[:query] && params[:query][:draw_progress_line])
122 122 self
123 123 end
124 124
125 125 def initialize_available_filters
126 126 principals = []
127 127 subprojects = []
128 128 versions = []
129 129 categories = []
130 130 issue_custom_fields = []
131 131
132 132 if project
133 133 principals += project.principals.sort
134 134 unless project.leaf?
135 135 subprojects = project.descendants.visible.all
136 136 principals += Principal.member_of(subprojects)
137 137 end
138 138 versions = project.shared_versions.all
139 139 categories = project.issue_categories.all
140 140 issue_custom_fields = project.all_issue_custom_fields
141 141 else
142 142 if all_projects.any?
143 143 principals += Principal.member_of(all_projects)
144 144 end
145 145 versions = Version.visible.where(:sharing => 'system').all
146 146 issue_custom_fields = IssueCustomField.where(:is_for_all => true)
147 147 end
148 148 principals.uniq!
149 149 principals.sort!
150 principals.reject! {|p| p.is_a?(GroupBuiltin)}
150 151 users = principals.select {|p| p.is_a?(User)}
151 152
152 153 add_available_filter "status_id",
153 154 :type => :list_status, :values => IssueStatus.sorted.collect{|s| [s.name, s.id.to_s] }
154 155
155 156 if project.nil?
156 157 project_values = []
157 158 if User.current.logged? && User.current.memberships.any?
158 159 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
159 160 end
160 161 project_values += all_projects_values
161 162 add_available_filter("project_id",
162 163 :type => :list, :values => project_values
163 164 ) unless project_values.empty?
164 165 end
165 166
166 167 add_available_filter "tracker_id",
167 168 :type => :list, :values => trackers.collect{|s| [s.name, s.id.to_s] }
168 169 add_available_filter "priority_id",
169 170 :type => :list, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
170 171
171 172 author_values = []
172 173 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
173 174 author_values += users.collect{|s| [s.name, s.id.to_s] }
174 175 add_available_filter("author_id",
175 176 :type => :list, :values => author_values
176 177 ) unless author_values.empty?
177 178
178 179 assigned_to_values = []
179 180 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
180 181 assigned_to_values += (Setting.issue_group_assignment? ?
181 182 principals : users).collect{|s| [s.name, s.id.to_s] }
182 183 add_available_filter("assigned_to_id",
183 184 :type => :list_optional, :values => assigned_to_values
184 185 ) unless assigned_to_values.empty?
185 186
186 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
187 group_values = Group.givable.collect {|g| [g.name, g.id.to_s] }
187 188 add_available_filter("member_of_group",
188 189 :type => :list_optional, :values => group_values
189 190 ) unless group_values.empty?
190 191
191 192 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
192 193 add_available_filter("assigned_to_role",
193 194 :type => :list_optional, :values => role_values
194 195 ) unless role_values.empty?
195 196
196 197 if versions.any?
197 198 add_available_filter "fixed_version_id",
198 199 :type => :list_optional,
199 200 :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
200 201 end
201 202
202 203 if categories.any?
203 204 add_available_filter "category_id",
204 205 :type => :list_optional,
205 206 :values => categories.collect{|s| [s.name, s.id.to_s] }
206 207 end
207 208
208 209 add_available_filter "subject", :type => :text
209 210 add_available_filter "created_on", :type => :date_past
210 211 add_available_filter "updated_on", :type => :date_past
211 212 add_available_filter "closed_on", :type => :date_past
212 213 add_available_filter "start_date", :type => :date
213 214 add_available_filter "due_date", :type => :date
214 215 add_available_filter "estimated_hours", :type => :float
215 216 add_available_filter "done_ratio", :type => :integer
216 217
217 218 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
218 219 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
219 220 add_available_filter "is_private",
220 221 :type => :list,
221 222 :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
222 223 end
223 224
224 225 if User.current.logged?
225 226 add_available_filter "watcher_id",
226 227 :type => :list, :values => [["<< #{l(:label_me)} >>", "me"]]
227 228 end
228 229
229 230 if subprojects.any?
230 231 add_available_filter "subproject_id",
231 232 :type => :list_subprojects,
232 233 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
233 234 end
234 235
235 236 add_custom_fields_filters(issue_custom_fields)
236 237
237 238 add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
238 239
239 240 IssueRelation::TYPES.each do |relation_type, options|
240 241 add_available_filter relation_type, :type => :relation, :label => options[:name]
241 242 end
242 243
243 244 Tracker.disabled_core_fields(trackers).each {|field|
244 245 delete_available_filter field
245 246 }
246 247 end
247 248
248 249 def available_columns
249 250 return @available_columns if @available_columns
250 251 @available_columns = self.class.available_columns.dup
251 252 @available_columns += (project ?
252 253 project.all_issue_custom_fields :
253 254 IssueCustomField
254 255 ).visible.collect {|cf| QueryCustomFieldColumn.new(cf) }
255 256
256 257 if User.current.allowed_to?(:view_time_entries, project, :global => true)
257 258 index = nil
258 259 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
259 260 index = (index ? index + 1 : -1)
260 261 # insert the column after estimated_hours or at the end
261 262 @available_columns.insert index, QueryColumn.new(:spent_hours,
262 263 :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)",
263 264 :default_order => 'desc',
264 265 :caption => :label_spent_time
265 266 )
266 267 end
267 268
268 269 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
269 270 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
270 271 @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
271 272 end
272 273
273 274 disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
274 275 @available_columns.reject! {|column|
275 276 disabled_fields.include?(column.name.to_s)
276 277 }
277 278
278 279 @available_columns
279 280 end
280 281
281 282 def default_columns_names
282 283 @default_columns_names ||= begin
283 284 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
284 285
285 286 project.present? ? default_columns : [:project] | default_columns
286 287 end
287 288 end
288 289
289 290 # Returns the issue count
290 291 def issue_count
291 292 Issue.visible.joins(:status, :project).where(statement).count
292 293 rescue ::ActiveRecord::StatementInvalid => e
293 294 raise StatementInvalid.new(e.message)
294 295 end
295 296
296 297 # Returns the issue count by group or nil if query is not grouped
297 298 def issue_count_by_group
298 299 r = nil
299 300 if grouped?
300 301 begin
301 302 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
302 303 r = Issue.visible.
303 304 joins(:status, :project).
304 305 where(statement).
305 306 joins(joins_for_order_statement(group_by_statement)).
306 307 group(group_by_statement).
307 308 count
308 309 rescue ActiveRecord::RecordNotFound
309 310 r = {nil => issue_count}
310 311 end
311 312 c = group_by_column
312 313 if c.is_a?(QueryCustomFieldColumn)
313 314 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
314 315 end
315 316 end
316 317 r
317 318 rescue ::ActiveRecord::StatementInvalid => e
318 319 raise StatementInvalid.new(e.message)
319 320 end
320 321
321 322 # Returns the issues
322 323 # Valid options are :order, :offset, :limit, :include, :conditions
323 324 def issues(options={})
324 325 order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
325 326
326 327 scope = Issue.visible.
327 328 joins(:status, :project).
328 329 where(statement).
329 330 includes(([:status, :project] + (options[:include] || [])).uniq).
330 331 where(options[:conditions]).
331 332 order(order_option).
332 333 joins(joins_for_order_statement(order_option.join(','))).
333 334 limit(options[:limit]).
334 335 offset(options[:offset])
335 336
336 337 scope = scope.preload(:custom_values)
337 338 if has_column?(:author)
338 339 scope = scope.preload(:author)
339 340 end
340 341
341 342 issues = scope.all
342 343
343 344 if has_column?(:spent_hours)
344 345 Issue.load_visible_spent_hours(issues)
345 346 end
346 347 if has_column?(:relations)
347 348 Issue.load_visible_relations(issues)
348 349 end
349 350 issues
350 351 rescue ::ActiveRecord::StatementInvalid => e
351 352 raise StatementInvalid.new(e.message)
352 353 end
353 354
354 355 # Returns the issues ids
355 356 def issue_ids(options={})
356 357 order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
357 358
358 359 Issue.visible.
359 360 joins(:status, :project).
360 361 where(statement).
361 362 includes(([:status, :project] + (options[:include] || [])).uniq).
362 363 where(options[:conditions]).
363 364 order(order_option).
364 365 joins(joins_for_order_statement(order_option.join(','))).
365 366 limit(options[:limit]).
366 367 offset(options[:offset]).
367 368 find_ids
368 369 rescue ::ActiveRecord::StatementInvalid => e
369 370 raise StatementInvalid.new(e.message)
370 371 end
371 372
372 373 # Returns the journals
373 374 # Valid options are :order, :offset, :limit
374 375 def journals(options={})
375 376 Journal.visible.
376 377 joins(:issue => [:project, :status]).
377 378 where(statement).
378 379 order(options[:order]).
379 380 limit(options[:limit]).
380 381 offset(options[:offset]).
381 382 preload(:details, :user, {:issue => [:project, :author, :tracker, :status]}).
382 383 all
383 384 rescue ::ActiveRecord::StatementInvalid => e
384 385 raise StatementInvalid.new(e.message)
385 386 end
386 387
387 388 # Returns the versions
388 389 # Valid options are :conditions
389 390 def versions(options={})
390 391 Version.visible.
391 392 where(project_statement).
392 393 where(options[:conditions]).
393 394 includes(:project).
394 395 all
395 396 rescue ::ActiveRecord::StatementInvalid => e
396 397 raise StatementInvalid.new(e.message)
397 398 end
398 399
399 400 def sql_for_watcher_id_field(field, operator, value)
400 401 db_table = Watcher.table_name
401 402 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
402 403 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
403 404 end
404 405
405 406 def sql_for_member_of_group_field(field, operator, value)
406 407 if operator == '*' # Any group
407 groups = Group.all
408 groups = Group.givable
408 409 operator = '=' # Override the operator since we want to find by assigned_to
409 410 elsif operator == "!*"
410 groups = Group.all
411 groups = Group.givable
411 412 operator = '!' # Override the operator since we want to find by assigned_to
412 413 else
413 414 groups = Group.where(:id => value).all
414 415 end
415 416 groups ||= []
416 417
417 418 members_of_groups = groups.inject([]) {|user_ids, group|
418 419 user_ids + group.user_ids + [group.id]
419 420 }.uniq.compact.sort.collect(&:to_s)
420 421
421 422 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
422 423 end
423 424
424 425 def sql_for_assigned_to_role_field(field, operator, value)
425 426 case operator
426 427 when "*", "!*" # Member / Not member
427 428 sw = operator == "!*" ? 'NOT' : ''
428 429 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
429 430 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
430 431 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
431 432 when "=", "!"
432 433 role_cond = value.any? ?
433 434 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
434 435 "1=0"
435 436
436 437 sw = operator == "!" ? 'NOT' : ''
437 438 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
438 439 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
439 440 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
440 441 end
441 442 end
442 443
443 444 def sql_for_is_private_field(field, operator, value)
444 445 op = (operator == "=" ? 'IN' : 'NOT IN')
445 446 va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
446 447
447 448 "#{Issue.table_name}.is_private #{op} (#{va})"
448 449 end
449 450
450 451 def sql_for_relations(field, operator, value, options={})
451 452 relation_options = IssueRelation::TYPES[field]
452 453 return relation_options unless relation_options
453 454
454 455 relation_type = field
455 456 join_column, target_join_column = "issue_from_id", "issue_to_id"
456 457 if relation_options[:reverse] || options[:reverse]
457 458 relation_type = relation_options[:reverse] || relation_type
458 459 join_column, target_join_column = target_join_column, join_column
459 460 end
460 461
461 462 sql = case operator
462 463 when "*", "!*"
463 464 op = (operator == "*" ? 'IN' : 'NOT IN')
464 465 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
465 466 when "=", "!"
466 467 op = (operator == "=" ? 'IN' : 'NOT IN')
467 468 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
468 469 when "=p", "=!p", "!p"
469 470 op = (operator == "!p" ? 'NOT IN' : 'IN')
470 471 comp = (operator == "=!p" ? '<>' : '=')
471 472 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
472 473 end
473 474
474 475 if relation_options[:sym] == field && !options[:reverse]
475 476 sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
476 477 sql = sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
477 478 end
478 479 "(#{sql})"
479 480 end
480 481
481 482 IssueRelation::TYPES.keys.each do |relation_type|
482 483 alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
483 484 end
484 485 end
@@ -1,116 +1,119
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Principal < ActiveRecord::Base
19 19 self.table_name = "#{table_name_prefix}users#{table_name_suffix}"
20 20
21 21 # Account statuses
22 22 STATUS_ANONYMOUS = 0
23 23 STATUS_ACTIVE = 1
24 24 STATUS_REGISTERED = 2
25 25 STATUS_LOCKED = 3
26 26
27 27 has_many :members, :foreign_key => 'user_id', :dependent => :destroy
28 28 has_many :memberships, :class_name => 'Member',
29 29 :foreign_key => 'user_id',
30 30 :include => [:project, :roles],
31 31 :conditions => "#{Project.table_name}.status<>#{Project::STATUS_ARCHIVED}",
32 32 :order => "#{Project.table_name}.name"
33 33 has_many :projects, :through => :memberships
34 34 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
35 35
36 36 # Groups and active users
37 37 scope :active, lambda { where(:status => STATUS_ACTIVE) }
38 38
39 39 scope :like, lambda {|q|
40 40 q = q.to_s
41 41 if q.blank?
42 42 where({})
43 43 else
44 44 pattern = "%#{q}%"
45 45 sql = %w(login firstname lastname mail).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
46 46 params = {:p => pattern}
47 47 if q =~ /^(.+)\s+(.+)$/
48 48 a, b = "#{$1}%", "#{$2}%"
49 49 sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:a) AND LOWER(#{table_name}.lastname) LIKE LOWER(:b))"
50 50 sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:b) AND LOWER(#{table_name}.lastname) LIKE LOWER(:a))"
51 51 params.merge!(:a => a, :b => b)
52 52 end
53 53 where(sql, params)
54 54 end
55 55 }
56 56
57 57 # Principals that are members of a collection of projects
58 58 scope :member_of, lambda {|projects|
59 59 projects = [projects] unless projects.is_a?(Array)
60 60 if projects.empty?
61 61 where("1=0")
62 62 else
63 63 ids = projects.map(&:id)
64 64 active.where("#{Principal.table_name}.id IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
65 65 end
66 66 }
67 67 # Principals that are not members of projects
68 68 scope :not_member_of, lambda {|projects|
69 69 projects = [projects] unless projects.is_a?(Array)
70 70 if projects.empty?
71 71 where("1=0")
72 72 else
73 73 ids = projects.map(&:id)
74 74 where("#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
75 75 end
76 76 }
77 77 scope :sorted, lambda { order(*Principal.fields_for_order_statement)}
78 78
79 79 before_create :set_default_empty_values
80 80
81 81 def name(formatter = nil)
82 82 to_s
83 83 end
84 84
85 85 def <=>(principal)
86 86 if principal.nil?
87 87 -1
88 88 elsif self.class.name == principal.class.name
89 89 self.to_s.downcase <=> principal.to_s.downcase
90 90 else
91 91 # groups after users
92 92 principal.class.name <=> self.class.name
93 93 end
94 94 end
95 95
96 96 # Returns an array of fields names than can be used to make an order statement for principals.
97 97 # Users are sorted before Groups.
98 98 # Examples:
99 99 def self.fields_for_order_statement(table=nil)
100 100 table ||= table_name
101 101 columns = ['type DESC'] + (User.name_formatter[:order] - ['id']) + ['lastname', 'id']
102 102 columns.uniq.map {|field| "#{table}.#{field}"}
103 103 end
104 104
105 105 protected
106 106
107 107 # Make sure we don't try to insert NULL values (see #4632)
108 108 def set_default_empty_values
109 109 self.login ||= ''
110 110 self.hashed_password ||= ''
111 111 self.firstname ||= ''
112 112 self.lastname ||= ''
113 113 self.mail ||= ''
114 114 true
115 115 end
116 116 end
117
118 require_dependency "user"
119 require_dependency "group"
@@ -1,1054 +1,1064
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_CLOSED = 5
24 24 STATUS_ARCHIVED = 9
25 25
26 26 # Maximum length for project identifiers
27 27 IDENTIFIER_MAX_LENGTH = 100
28 28
29 29 # Specific overridden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members, :include => [:principal, :roles], :conditions => "#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}"
32 32 has_many :memberships, :class_name => 'Member'
33 33 has_many :member_principals, :class_name => 'Member',
34 34 :include => :principal,
35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE})"
35 :conditions => "#{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}"
36 36
37 37 has_many :enabled_modules, :dependent => :delete_all
38 38 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
39 39 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
40 40 has_many :issue_changes, :through => :issues, :source => :journals
41 41 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
42 42 has_many :time_entries, :dependent => :destroy
43 43 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
44 44 has_many :documents, :dependent => :destroy
45 45 has_many :news, :dependent => :destroy, :include => :author
46 46 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
47 47 has_many :boards, :dependent => :destroy, :order => "position ASC"
48 48 has_one :repository, :conditions => ["is_default = ?", true]
49 49 has_many :repositories, :dependent => :destroy
50 50 has_many :changesets, :through => :repository
51 51 has_one :wiki, :dependent => :destroy
52 52 # Custom field for the project issues
53 53 has_and_belongs_to_many :issue_custom_fields,
54 54 :class_name => 'IssueCustomField',
55 55 :order => "#{CustomField.table_name}.position",
56 56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 57 :association_foreign_key => 'custom_field_id'
58 58
59 59 acts_as_nested_set :dependent => :destroy
60 60 acts_as_attachable :view_permission => :view_files,
61 61 :delete_permission => :manage_files
62 62
63 63 acts_as_customizable
64 64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
65 65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 67 :author => nil
68 68
69 69 attr_protected :status
70 70
71 71 validates_presence_of :name, :identifier
72 72 validates_uniqueness_of :identifier
73 73 validates_associated :repository, :wiki
74 74 validates_length_of :name, :maximum => 255
75 75 validates_length_of :homepage, :maximum => 255
76 76 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
77 77 # downcase letters, digits, dashes but not digits only
78 78 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
79 79 # reserved words
80 80 validates_exclusion_of :identifier, :in => %w( new )
81 81
82 82 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
83 83 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
84 84 before_destroy :delete_all_members
85 85
86 86 scope :has_module, lambda {|mod|
87 87 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
88 88 }
89 89 scope :active, lambda { where(:status => STATUS_ACTIVE) }
90 90 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
91 91 scope :all_public, lambda { where(:is_public => true) }
92 92 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
93 93 scope :allowed_to, lambda {|*args|
94 94 user = User.current
95 95 permission = nil
96 96 if args.first.is_a?(Symbol)
97 97 permission = args.shift
98 98 else
99 99 user = args.shift
100 100 permission = args.shift
101 101 end
102 102 where(Project.allowed_to_condition(user, permission, *args))
103 103 }
104 104 scope :like, lambda {|arg|
105 105 if arg.blank?
106 106 where(nil)
107 107 else
108 108 pattern = "%#{arg.to_s.strip.downcase}%"
109 109 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
110 110 end
111 111 }
112 112
113 113 def initialize(attributes=nil, *args)
114 114 super
115 115
116 116 initialized = (attributes || {}).stringify_keys
117 117 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
118 118 self.identifier = Project.next_identifier
119 119 end
120 120 if !initialized.key?('is_public')
121 121 self.is_public = Setting.default_projects_public?
122 122 end
123 123 if !initialized.key?('enabled_module_names')
124 124 self.enabled_module_names = Setting.default_projects_modules
125 125 end
126 126 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
127 127 default = Setting.default_projects_tracker_ids
128 128 if default.is_a?(Array)
129 129 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.all
130 130 else
131 131 self.trackers = Tracker.sorted.all
132 132 end
133 133 end
134 134 end
135 135
136 136 def identifier=(identifier)
137 137 super unless identifier_frozen?
138 138 end
139 139
140 140 def identifier_frozen?
141 141 errors[:identifier].blank? && !(new_record? || identifier.blank?)
142 142 end
143 143
144 144 # returns latest created projects
145 145 # non public projects will be returned only if user is a member of those
146 146 def self.latest(user=nil, count=5)
147 147 visible(user).limit(count).order("created_on DESC").all
148 148 end
149 149
150 150 # Returns true if the project is visible to +user+ or to the current user.
151 151 def visible?(user=User.current)
152 152 user.allowed_to?(:view_project, self)
153 153 end
154 154
155 155 # Returns a SQL conditions string used to find all projects visible by the specified user.
156 156 #
157 157 # Examples:
158 158 # Project.visible_condition(admin) => "projects.status = 1"
159 159 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
160 160 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
161 161 def self.visible_condition(user, options={})
162 162 allowed_to_condition(user, :view_project, options)
163 163 end
164 164
165 165 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
166 166 #
167 167 # Valid options:
168 168 # * :project => limit the condition to project
169 169 # * :with_subprojects => limit the condition to project and its subprojects
170 170 # * :member => limit the condition to the user projects
171 171 def self.allowed_to_condition(user, permission, options={})
172 172 perm = Redmine::AccessControl.permission(permission)
173 173 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
174 174 if perm && perm.project_module
175 175 # If the permission belongs to a project module, make sure the module is enabled
176 176 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
177 177 end
178 178 if options[:project]
179 179 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
180 180 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
181 181 base_statement = "(#{project_statement}) AND (#{base_statement})"
182 182 end
183 183
184 184 if user.admin?
185 185 base_statement
186 186 else
187 187 statement_by_role = {}
188 188 unless options[:member]
189 189 role = user.builtin_role
190 190 if role.allowed_to?(permission)
191 191 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
192 192 end
193 193 end
194 if user.logged?
195 user.projects_by_role.each do |role, projects|
196 if role.allowed_to?(permission) && projects.any?
197 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
198 end
194 user.projects_by_role.each do |role, projects|
195 if role.allowed_to?(permission) && projects.any?
196 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
199 197 end
200 198 end
201 199 if statement_by_role.empty?
202 200 "1=0"
203 201 else
204 202 if block_given?
205 203 statement_by_role.each do |role, statement|
206 204 if s = yield(role, user)
207 205 statement_by_role[role] = "(#{statement} AND (#{s}))"
208 206 end
209 207 end
210 208 end
211 209 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
212 210 end
213 211 end
214 212 end
215 213
214 def override_roles(role)
215 @override_members ||= memberships.where(:user_id => [GroupAnonymous.instance_id, GroupNonMember.instance_id]).all
216 member = @override_members.detect {|m| role.anonymous? ^ (m.user_id == GroupNonMember.instance_id)}
217 member ? member.roles : [role]
218 end
219
216 220 def principals
217 221 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
218 222 end
219 223
220 224 def users
221 225 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
222 226 end
223 227
224 228 # Returns the Systemwide and project specific activities
225 229 def activities(include_inactive=false)
226 230 if include_inactive
227 231 return all_activities
228 232 else
229 233 return active_activities
230 234 end
231 235 end
232 236
233 237 # Will create a new Project specific Activity or update an existing one
234 238 #
235 239 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
236 240 # does not successfully save.
237 241 def update_or_create_time_entry_activity(id, activity_hash)
238 242 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
239 243 self.create_time_entry_activity_if_needed(activity_hash)
240 244 else
241 245 activity = project.time_entry_activities.find_by_id(id.to_i)
242 246 activity.update_attributes(activity_hash) if activity
243 247 end
244 248 end
245 249
246 250 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
247 251 #
248 252 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
249 253 # does not successfully save.
250 254 def create_time_entry_activity_if_needed(activity)
251 255 if activity['parent_id']
252 256 parent_activity = TimeEntryActivity.find(activity['parent_id'])
253 257 activity['name'] = parent_activity.name
254 258 activity['position'] = parent_activity.position
255 259 if Enumeration.overriding_change?(activity, parent_activity)
256 260 project_activity = self.time_entry_activities.create(activity)
257 261 if project_activity.new_record?
258 262 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
259 263 else
260 264 self.time_entries.
261 265 where(["activity_id = ?", parent_activity.id]).
262 266 update_all("activity_id = #{project_activity.id}")
263 267 end
264 268 end
265 269 end
266 270 end
267 271
268 272 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
269 273 #
270 274 # Examples:
271 275 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
272 276 # project.project_condition(false) => "projects.id = 1"
273 277 def project_condition(with_subprojects)
274 278 cond = "#{Project.table_name}.id = #{id}"
275 279 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
276 280 cond
277 281 end
278 282
279 283 def self.find(*args)
280 284 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
281 285 project = find_by_identifier(*args)
282 286 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
283 287 project
284 288 else
285 289 super
286 290 end
287 291 end
288 292
289 293 def self.find_by_param(*args)
290 294 self.find(*args)
291 295 end
292 296
293 297 alias :base_reload :reload
294 298 def reload(*args)
295 299 @principals = nil
296 300 @users = nil
297 301 @shared_versions = nil
298 302 @rolled_up_versions = nil
299 303 @rolled_up_trackers = nil
300 304 @all_issue_custom_fields = nil
301 305 @all_time_entry_custom_fields = nil
302 306 @to_param = nil
303 307 @allowed_parents = nil
304 308 @allowed_permissions = nil
305 309 @actions_allowed = nil
306 310 @start_date = nil
307 311 @due_date = nil
312 @override_members = nil
308 313 base_reload(*args)
309 314 end
310 315
311 316 def to_param
312 317 # id is used for projects with a numeric identifier (compatibility)
313 318 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
314 319 end
315 320
316 321 def active?
317 322 self.status == STATUS_ACTIVE
318 323 end
319 324
320 325 def archived?
321 326 self.status == STATUS_ARCHIVED
322 327 end
323 328
324 329 # Archives the project and its descendants
325 330 def archive
326 331 # Check that there is no issue of a non descendant project that is assigned
327 332 # to one of the project or descendant versions
328 333 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
329 334 if v_ids.any? &&
330 335 Issue.
331 336 includes(:project).
332 337 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
333 338 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
334 339 exists?
335 340 return false
336 341 end
337 342 Project.transaction do
338 343 archive!
339 344 end
340 345 true
341 346 end
342 347
343 348 # Unarchives the project
344 349 # All its ancestors must be active
345 350 def unarchive
346 351 return false if ancestors.detect {|a| !a.active?}
347 352 update_attribute :status, STATUS_ACTIVE
348 353 end
349 354
350 355 def close
351 356 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
352 357 end
353 358
354 359 def reopen
355 360 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
356 361 end
357 362
358 363 # Returns an array of projects the project can be moved to
359 364 # by the current user
360 365 def allowed_parents
361 366 return @allowed_parents if @allowed_parents
362 367 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
363 368 @allowed_parents = @allowed_parents - self_and_descendants
364 369 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
365 370 @allowed_parents << nil
366 371 end
367 372 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
368 373 @allowed_parents << parent
369 374 end
370 375 @allowed_parents
371 376 end
372 377
373 378 # Sets the parent of the project with authorization check
374 379 def set_allowed_parent!(p)
375 380 unless p.nil? || p.is_a?(Project)
376 381 if p.to_s.blank?
377 382 p = nil
378 383 else
379 384 p = Project.find_by_id(p)
380 385 return false unless p
381 386 end
382 387 end
383 388 if p.nil?
384 389 if !new_record? && allowed_parents.empty?
385 390 return false
386 391 end
387 392 elsif !allowed_parents.include?(p)
388 393 return false
389 394 end
390 395 set_parent!(p)
391 396 end
392 397
393 398 # Sets the parent of the project
394 399 # Argument can be either a Project, a String, a Fixnum or nil
395 400 def set_parent!(p)
396 401 unless p.nil? || p.is_a?(Project)
397 402 if p.to_s.blank?
398 403 p = nil
399 404 else
400 405 p = Project.find_by_id(p)
401 406 return false unless p
402 407 end
403 408 end
404 409 if p == parent && !p.nil?
405 410 # Nothing to do
406 411 true
407 412 elsif p.nil? || (p.active? && move_possible?(p))
408 413 set_or_update_position_under(p)
409 414 Issue.update_versions_from_hierarchy_change(self)
410 415 true
411 416 else
412 417 # Can not move to the given target
413 418 false
414 419 end
415 420 end
416 421
417 422 # Recalculates all lft and rgt values based on project names
418 423 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
419 424 # Used in BuildProjectsTree migration
420 425 def self.rebuild_tree!
421 426 transaction do
422 427 update_all "lft = NULL, rgt = NULL"
423 428 rebuild!(false)
424 429 all.each { |p| p.set_or_update_position_under(p.parent) }
425 430 end
426 431 end
427 432
428 433 # Returns an array of the trackers used by the project and its active sub projects
429 434 def rolled_up_trackers
430 435 @rolled_up_trackers ||=
431 436 Tracker.
432 437 joins(:projects).
433 438 joins("JOIN #{EnabledModule.table_name} ON #{EnabledModule.table_name}.project_id = #{Project.table_name}.id AND #{EnabledModule.table_name}.name = 'issue_tracking'").
434 439 select("DISTINCT #{Tracker.table_name}.*").
435 440 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
436 441 sorted.
437 442 all
438 443 end
439 444
440 445 # Closes open and locked project versions that are completed
441 446 def close_completed_versions
442 447 Version.transaction do
443 448 versions.where(:status => %w(open locked)).each do |version|
444 449 if version.completed?
445 450 version.update_attribute(:status, 'closed')
446 451 end
447 452 end
448 453 end
449 454 end
450 455
451 456 # Returns a scope of the Versions on subprojects
452 457 def rolled_up_versions
453 458 @rolled_up_versions ||=
454 459 Version.
455 460 includes(:project).
456 461 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
457 462 end
458 463
459 464 # Returns a scope of the Versions used by the project
460 465 def shared_versions
461 466 if new_record?
462 467 Version.
463 468 includes(:project).
464 469 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
465 470 else
466 471 @shared_versions ||= begin
467 472 r = root? ? self : root
468 473 Version.
469 474 includes(:project).
470 475 where("#{Project.table_name}.id = #{id}" +
471 476 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
472 477 " #{Version.table_name}.sharing = 'system'" +
473 478 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
474 479 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
475 480 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
476 481 "))")
477 482 end
478 483 end
479 484 end
480 485
481 486 # Returns a hash of project users grouped by role
482 487 def users_by_role
483 488 members.includes(:user, :roles).inject({}) do |h, m|
484 489 m.roles.each do |r|
485 490 h[r] ||= []
486 491 h[r] << m.user
487 492 end
488 493 h
489 494 end
490 495 end
491 496
492 497 # Deletes all project's members
493 498 def delete_all_members
494 499 me, mr = Member.table_name, MemberRole.table_name
495 500 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
496 501 Member.delete_all(['project_id = ?', id])
497 502 end
498 503
499 504 # Users/groups issues can be assigned to
500 505 def assignable_users
501 assignable = Setting.issue_group_assignment? ? member_principals : members
502 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
506 types = ['User']
507 types << 'Group' if Setting.issue_group_assignment?
508
509 member_principals.
510 select {|m| types.include?(m.principal.type) && m.roles.detect(&:assignable?)}.
511 map(&:principal).
512 sort
503 513 end
504 514
505 515 # Returns the mail addresses of users that should be always notified on project events
506 516 def recipients
507 517 notified_users.collect {|user| user.mail}
508 518 end
509 519
510 520 # Returns the users that should be notified on project events
511 521 def notified_users
512 522 # TODO: User part should be extracted to User#notify_about?
513 523 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
514 524 end
515 525
516 526 # Returns a scope of all custom fields enabled for project issues
517 527 # (explicitly associated custom fields and custom fields enabled for all projects)
518 528 def all_issue_custom_fields
519 529 @all_issue_custom_fields ||= IssueCustomField.
520 530 sorted.
521 531 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
522 532 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
523 533 " WHERE cfp.project_id = ?)", true, id)
524 534 end
525 535
526 536 # Returns an array of all custom fields enabled for project time entries
527 537 # (explictly associated custom fields and custom fields enabled for all projects)
528 538 def all_time_entry_custom_fields
529 539 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
530 540 end
531 541
532 542 def project
533 543 self
534 544 end
535 545
536 546 def <=>(project)
537 547 name.downcase <=> project.name.downcase
538 548 end
539 549
540 550 def to_s
541 551 name
542 552 end
543 553
544 554 # Returns a short description of the projects (first lines)
545 555 def short_description(length = 255)
546 556 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
547 557 end
548 558
549 559 def css_classes
550 560 s = 'project'
551 561 s << ' root' if root?
552 562 s << ' child' if child?
553 563 s << (leaf? ? ' leaf' : ' parent')
554 564 unless active?
555 565 if archived?
556 566 s << ' archived'
557 567 else
558 568 s << ' closed'
559 569 end
560 570 end
561 571 s
562 572 end
563 573
564 574 # The earliest start date of a project, based on it's issues and versions
565 575 def start_date
566 576 @start_date ||= [
567 577 issues.minimum('start_date'),
568 578 shared_versions.minimum('effective_date'),
569 579 Issue.fixed_version(shared_versions).minimum('start_date')
570 580 ].compact.min
571 581 end
572 582
573 583 # The latest due date of an issue or version
574 584 def due_date
575 585 @due_date ||= [
576 586 issues.maximum('due_date'),
577 587 shared_versions.maximum('effective_date'),
578 588 Issue.fixed_version(shared_versions).maximum('due_date')
579 589 ].compact.max
580 590 end
581 591
582 592 def overdue?
583 593 active? && !due_date.nil? && (due_date < Date.today)
584 594 end
585 595
586 596 # Returns the percent completed for this project, based on the
587 597 # progress on it's versions.
588 598 def completed_percent(options={:include_subprojects => false})
589 599 if options.delete(:include_subprojects)
590 600 total = self_and_descendants.collect(&:completed_percent).sum
591 601
592 602 total / self_and_descendants.count
593 603 else
594 604 if versions.count > 0
595 605 total = versions.collect(&:completed_percent).sum
596 606
597 607 total / versions.count
598 608 else
599 609 100
600 610 end
601 611 end
602 612 end
603 613
604 614 # Return true if this project allows to do the specified action.
605 615 # action can be:
606 616 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
607 617 # * a permission Symbol (eg. :edit_project)
608 618 def allows_to?(action)
609 619 if archived?
610 620 # No action allowed on archived projects
611 621 return false
612 622 end
613 623 unless active? || Redmine::AccessControl.read_action?(action)
614 624 # No write action allowed on closed projects
615 625 return false
616 626 end
617 627 # No action allowed on disabled modules
618 628 if action.is_a? Hash
619 629 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
620 630 else
621 631 allowed_permissions.include? action
622 632 end
623 633 end
624 634
625 635 # Return the enabled module with the given name
626 636 # or nil if the module is not enabled for the project
627 637 def enabled_module(name)
628 638 name = name.to_s
629 639 enabled_modules.detect {|m| m.name == name}
630 640 end
631 641
632 642 # Return true if the module with the given name is enabled
633 643 def module_enabled?(name)
634 644 enabled_module(name).present?
635 645 end
636 646
637 647 def enabled_module_names=(module_names)
638 648 if module_names && module_names.is_a?(Array)
639 649 module_names = module_names.collect(&:to_s).reject(&:blank?)
640 650 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
641 651 else
642 652 enabled_modules.clear
643 653 end
644 654 end
645 655
646 656 # Returns an array of the enabled modules names
647 657 def enabled_module_names
648 658 enabled_modules.collect(&:name)
649 659 end
650 660
651 661 # Enable a specific module
652 662 #
653 663 # Examples:
654 664 # project.enable_module!(:issue_tracking)
655 665 # project.enable_module!("issue_tracking")
656 666 def enable_module!(name)
657 667 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
658 668 end
659 669
660 670 # Disable a module if it exists
661 671 #
662 672 # Examples:
663 673 # project.disable_module!(:issue_tracking)
664 674 # project.disable_module!("issue_tracking")
665 675 # project.disable_module!(project.enabled_modules.first)
666 676 def disable_module!(target)
667 677 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
668 678 target.destroy unless target.blank?
669 679 end
670 680
671 681 safe_attributes 'name',
672 682 'description',
673 683 'homepage',
674 684 'is_public',
675 685 'identifier',
676 686 'custom_field_values',
677 687 'custom_fields',
678 688 'tracker_ids',
679 689 'issue_custom_field_ids'
680 690
681 691 safe_attributes 'enabled_module_names',
682 692 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
683 693
684 694 safe_attributes 'inherit_members',
685 695 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
686 696
687 697 # Returns an array of projects that are in this project's hierarchy
688 698 #
689 699 # Example: parents, children, siblings
690 700 def hierarchy
691 701 parents = project.self_and_ancestors || []
692 702 descendants = project.descendants || []
693 703 project_hierarchy = parents | descendants # Set union
694 704 end
695 705
696 706 # Returns an auto-generated project identifier based on the last identifier used
697 707 def self.next_identifier
698 708 p = Project.order('id DESC').first
699 709 p.nil? ? nil : p.identifier.to_s.succ
700 710 end
701 711
702 712 # Copies and saves the Project instance based on the +project+.
703 713 # Duplicates the source project's:
704 714 # * Wiki
705 715 # * Versions
706 716 # * Categories
707 717 # * Issues
708 718 # * Members
709 719 # * Queries
710 720 #
711 721 # Accepts an +options+ argument to specify what to copy
712 722 #
713 723 # Examples:
714 724 # project.copy(1) # => copies everything
715 725 # project.copy(1, :only => 'members') # => copies members only
716 726 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
717 727 def copy(project, options={})
718 728 project = project.is_a?(Project) ? project : Project.find(project)
719 729
720 730 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
721 731 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
722 732
723 733 Project.transaction do
724 734 if save
725 735 reload
726 736 to_be_copied.each do |name|
727 737 send "copy_#{name}", project
728 738 end
729 739 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
730 740 save
731 741 end
732 742 end
733 743 end
734 744
735 745 # Returns a new unsaved Project instance with attributes copied from +project+
736 746 def self.copy_from(project)
737 747 project = project.is_a?(Project) ? project : Project.find(project)
738 748 # clear unique attributes
739 749 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
740 750 copy = Project.new(attributes)
741 751 copy.enabled_modules = project.enabled_modules
742 752 copy.trackers = project.trackers
743 753 copy.custom_values = project.custom_values.collect {|v| v.clone}
744 754 copy.issue_custom_fields = project.issue_custom_fields
745 755 copy
746 756 end
747 757
748 758 # Yields the given block for each project with its level in the tree
749 759 def self.project_tree(projects, &block)
750 760 ancestors = []
751 761 projects.sort_by(&:lft).each do |project|
752 762 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
753 763 ancestors.pop
754 764 end
755 765 yield project, ancestors.size
756 766 ancestors << project
757 767 end
758 768 end
759 769
760 770 private
761 771
762 772 def after_parent_changed(parent_was)
763 773 remove_inherited_member_roles
764 774 add_inherited_member_roles
765 775 end
766 776
767 777 def update_inherited_members
768 778 if parent
769 779 if inherit_members? && !inherit_members_was
770 780 remove_inherited_member_roles
771 781 add_inherited_member_roles
772 782 elsif !inherit_members? && inherit_members_was
773 783 remove_inherited_member_roles
774 784 end
775 785 end
776 786 end
777 787
778 788 def remove_inherited_member_roles
779 789 member_roles = memberships.map(&:member_roles).flatten
780 790 member_role_ids = member_roles.map(&:id)
781 791 member_roles.each do |member_role|
782 792 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
783 793 member_role.destroy
784 794 end
785 795 end
786 796 end
787 797
788 798 def add_inherited_member_roles
789 799 if inherit_members? && parent
790 800 parent.memberships.each do |parent_member|
791 801 member = Member.find_or_new(self.id, parent_member.user_id)
792 802 parent_member.member_roles.each do |parent_member_role|
793 803 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
794 804 end
795 805 member.save!
796 806 end
797 807 end
798 808 end
799 809
800 810 # Copies wiki from +project+
801 811 def copy_wiki(project)
802 812 # Check that the source project has a wiki first
803 813 unless project.wiki.nil?
804 814 wiki = self.wiki || Wiki.new
805 815 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
806 816 wiki_pages_map = {}
807 817 project.wiki.pages.each do |page|
808 818 # Skip pages without content
809 819 next if page.content.nil?
810 820 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
811 821 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
812 822 new_wiki_page.content = new_wiki_content
813 823 wiki.pages << new_wiki_page
814 824 wiki_pages_map[page.id] = new_wiki_page
815 825 end
816 826
817 827 self.wiki = wiki
818 828 wiki.save
819 829 # Reproduce page hierarchy
820 830 project.wiki.pages.each do |page|
821 831 if page.parent_id && wiki_pages_map[page.id]
822 832 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
823 833 wiki_pages_map[page.id].save
824 834 end
825 835 end
826 836 end
827 837 end
828 838
829 839 # Copies versions from +project+
830 840 def copy_versions(project)
831 841 project.versions.each do |version|
832 842 new_version = Version.new
833 843 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
834 844 self.versions << new_version
835 845 end
836 846 end
837 847
838 848 # Copies issue categories from +project+
839 849 def copy_issue_categories(project)
840 850 project.issue_categories.each do |issue_category|
841 851 new_issue_category = IssueCategory.new
842 852 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
843 853 self.issue_categories << new_issue_category
844 854 end
845 855 end
846 856
847 857 # Copies issues from +project+
848 858 def copy_issues(project)
849 859 # Stores the source issue id as a key and the copied issues as the
850 860 # value. Used to map the two together for issue relations.
851 861 issues_map = {}
852 862
853 863 # Store status and reopen locked/closed versions
854 864 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
855 865 version_statuses.each do |version, status|
856 866 version.update_attribute :status, 'open'
857 867 end
858 868
859 869 # Get issues sorted by root_id, lft so that parent issues
860 870 # get copied before their children
861 871 project.issues.reorder('root_id, lft').each do |issue|
862 872 new_issue = Issue.new
863 873 new_issue.copy_from(issue, :subtasks => false, :link => false)
864 874 new_issue.project = self
865 875 # Changing project resets the custom field values
866 876 # TODO: handle this in Issue#project=
867 877 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
868 878 # Reassign fixed_versions by name, since names are unique per project
869 879 if issue.fixed_version && issue.fixed_version.project == project
870 880 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
871 881 end
872 882 # Reassign the category by name, since names are unique per project
873 883 if issue.category
874 884 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
875 885 end
876 886 # Parent issue
877 887 if issue.parent_id
878 888 if copied_parent = issues_map[issue.parent_id]
879 889 new_issue.parent_issue_id = copied_parent.id
880 890 end
881 891 end
882 892
883 893 self.issues << new_issue
884 894 if new_issue.new_record?
885 895 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
886 896 else
887 897 issues_map[issue.id] = new_issue unless new_issue.new_record?
888 898 end
889 899 end
890 900
891 901 # Restore locked/closed version statuses
892 902 version_statuses.each do |version, status|
893 903 version.update_attribute :status, status
894 904 end
895 905
896 906 # Relations after in case issues related each other
897 907 project.issues.each do |issue|
898 908 new_issue = issues_map[issue.id]
899 909 unless new_issue
900 910 # Issue was not copied
901 911 next
902 912 end
903 913
904 914 # Relations
905 915 issue.relations_from.each do |source_relation|
906 916 new_issue_relation = IssueRelation.new
907 917 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
908 918 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
909 919 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
910 920 new_issue_relation.issue_to = source_relation.issue_to
911 921 end
912 922 new_issue.relations_from << new_issue_relation
913 923 end
914 924
915 925 issue.relations_to.each do |source_relation|
916 926 new_issue_relation = IssueRelation.new
917 927 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
918 928 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
919 929 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
920 930 new_issue_relation.issue_from = source_relation.issue_from
921 931 end
922 932 new_issue.relations_to << new_issue_relation
923 933 end
924 934 end
925 935 end
926 936
927 937 # Copies members from +project+
928 938 def copy_members(project)
929 939 # Copy users first, then groups to handle members with inherited and given roles
930 940 members_to_copy = []
931 941 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
932 942 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
933 943
934 944 members_to_copy.each do |member|
935 945 new_member = Member.new
936 946 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
937 947 # only copy non inherited roles
938 948 # inherited roles will be added when copying the group membership
939 949 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
940 950 next if role_ids.empty?
941 951 new_member.role_ids = role_ids
942 952 new_member.project = self
943 953 self.members << new_member
944 954 end
945 955 end
946 956
947 957 # Copies queries from +project+
948 958 def copy_queries(project)
949 959 project.queries.each do |query|
950 960 new_query = IssueQuery.new
951 961 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
952 962 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
953 963 new_query.project = self
954 964 new_query.user_id = query.user_id
955 965 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
956 966 self.queries << new_query
957 967 end
958 968 end
959 969
960 970 # Copies boards from +project+
961 971 def copy_boards(project)
962 972 project.boards.each do |board|
963 973 new_board = Board.new
964 974 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
965 975 new_board.project = self
966 976 self.boards << new_board
967 977 end
968 978 end
969 979
970 980 def allowed_permissions
971 981 @allowed_permissions ||= begin
972 982 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
973 983 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
974 984 end
975 985 end
976 986
977 987 def allowed_actions
978 988 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
979 989 end
980 990
981 991 # Returns all the active Systemwide and project specific activities
982 992 def active_activities
983 993 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
984 994
985 995 if overridden_activity_ids.empty?
986 996 return TimeEntryActivity.shared.active
987 997 else
988 998 return system_activities_and_project_overrides
989 999 end
990 1000 end
991 1001
992 1002 # Returns all the Systemwide and project specific activities
993 1003 # (inactive and active)
994 1004 def all_activities
995 1005 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
996 1006
997 1007 if overridden_activity_ids.empty?
998 1008 return TimeEntryActivity.shared
999 1009 else
1000 1010 return system_activities_and_project_overrides(true)
1001 1011 end
1002 1012 end
1003 1013
1004 1014 # Returns the systemwide active activities merged with the project specific overrides
1005 1015 def system_activities_and_project_overrides(include_inactive=false)
1006 1016 t = TimeEntryActivity.table_name
1007 1017 scope = TimeEntryActivity.where(
1008 1018 "(#{t}.project_id IS NULL AND #{t}.id NOT IN (?)) OR (#{t}.project_id = ?)",
1009 1019 time_entry_activities.map(&:parent_id), id
1010 1020 )
1011 1021 unless include_inactive
1012 1022 scope = scope.active
1013 1023 end
1014 1024 scope
1015 1025 end
1016 1026
1017 1027 # Archives subprojects recursively
1018 1028 def archive!
1019 1029 children.each do |subproject|
1020 1030 subproject.send :archive!
1021 1031 end
1022 1032 update_attribute :status, STATUS_ARCHIVED
1023 1033 end
1024 1034
1025 1035 def update_position_under_parent
1026 1036 set_or_update_position_under(parent)
1027 1037 end
1028 1038
1029 1039 public
1030 1040
1031 1041 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
1032 1042 def set_or_update_position_under(target_parent)
1033 1043 parent_was = parent
1034 1044 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
1035 1045 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
1036 1046
1037 1047 if to_be_inserted_before
1038 1048 move_to_left_of(to_be_inserted_before)
1039 1049 elsif target_parent.nil?
1040 1050 if sibs.empty?
1041 1051 # move_to_root adds the project in first (ie. left) position
1042 1052 move_to_root
1043 1053 else
1044 1054 move_to_right_of(sibs.last) unless self == sibs.last
1045 1055 end
1046 1056 else
1047 1057 # move_to_child_of adds the project in last (ie.right) position
1048 1058 move_to_child_of(target_parent)
1049 1059 end
1050 1060 if parent_was != target_parent
1051 1061 after_parent_changed(parent_was)
1052 1062 end
1053 1063 end
1054 1064 end
@@ -1,781 +1,789
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Different ways of displaying/sorting users
24 24 USER_FORMATS = {
25 25 :firstname_lastname => {
26 26 :string => '#{firstname} #{lastname}',
27 27 :order => %w(firstname lastname id),
28 28 :setting_order => 1
29 29 },
30 30 :firstname_lastinitial => {
31 31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
32 32 :order => %w(firstname lastname id),
33 33 :setting_order => 2
34 34 },
35 35 :firstinitial_lastname => {
36 36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
37 37 :order => %w(firstname lastname id),
38 38 :setting_order => 2
39 39 },
40 40 :firstname => {
41 41 :string => '#{firstname}',
42 42 :order => %w(firstname id),
43 43 :setting_order => 3
44 44 },
45 45 :lastname_firstname => {
46 46 :string => '#{lastname} #{firstname}',
47 47 :order => %w(lastname firstname id),
48 48 :setting_order => 4
49 49 },
50 50 :lastname_coma_firstname => {
51 51 :string => '#{lastname}, #{firstname}',
52 52 :order => %w(lastname firstname id),
53 53 :setting_order => 5
54 54 },
55 55 :lastname => {
56 56 :string => '#{lastname}',
57 57 :order => %w(lastname id),
58 58 :setting_order => 6
59 59 },
60 60 :username => {
61 61 :string => '#{login}',
62 62 :order => %w(login id),
63 63 :setting_order => 7
64 64 },
65 65 }
66 66
67 67 MAIL_NOTIFICATION_OPTIONS = [
68 68 ['all', :label_user_mail_option_all],
69 69 ['selected', :label_user_mail_option_selected],
70 70 ['only_my_events', :label_user_mail_option_only_my_events],
71 71 ['only_assigned', :label_user_mail_option_only_assigned],
72 72 ['only_owner', :label_user_mail_option_only_owner],
73 73 ['none', :label_user_mail_option_none]
74 74 ]
75 75
76 76 has_and_belongs_to_many :groups,
77 77 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
78 78 :after_add => Proc.new {|user, group| group.user_added(user)},
79 79 :after_remove => Proc.new {|user, group| group.user_removed(user)}
80 80 has_many :changesets, :dependent => :nullify
81 81 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
82 82 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
83 83 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
84 84 belongs_to :auth_source
85 85
86 86 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
87 87 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
88 88
89 89 acts_as_customizable
90 90
91 91 attr_accessor :password, :password_confirmation, :generate_password
92 92 attr_accessor :last_before_login_on
93 93 # Prevents unauthorized assignments
94 94 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
95 95
96 96 LOGIN_LENGTH_LIMIT = 60
97 97 MAIL_LENGTH_LIMIT = 60
98 98
99 99 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
100 100 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
101 101 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
102 102 # Login must contain letters, numbers, underscores only
103 103 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
104 104 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
105 105 validates_length_of :firstname, :lastname, :maximum => 30
106 106 validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
107 107 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
108 108 validates_confirmation_of :password, :allow_nil => true
109 109 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
110 110 validate :validate_password_length
111 111
112 112 before_create :set_mail_notification
113 113 before_save :generate_password_if_needed, :update_hashed_password
114 114 before_destroy :remove_references_before_destroy
115 115 after_save :update_notified_project_ids, :destroy_tokens
116 116
117 117 scope :in_group, lambda {|group|
118 118 group_id = group.is_a?(Group) ? group.id : group.to_i
119 119 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
120 120 }
121 121 scope :not_in_group, lambda {|group|
122 122 group_id = group.is_a?(Group) ? group.id : group.to_i
123 123 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
124 124 }
125 125 scope :sorted, lambda { order(*User.fields_for_order_statement)}
126 126
127 127 def set_mail_notification
128 128 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
129 129 true
130 130 end
131 131
132 132 def update_hashed_password
133 133 # update hashed_password if password was set
134 134 if self.password && self.auth_source_id.blank?
135 135 salt_password(password)
136 136 end
137 137 end
138 138
139 139 alias :base_reload :reload
140 140 def reload(*args)
141 141 @name = nil
142 142 @projects_by_role = nil
143 143 @membership_by_project_id = nil
144 144 @notified_projects_ids = nil
145 145 @notified_projects_ids_changed = false
146 146 @builtin_role = nil
147 147 base_reload(*args)
148 148 end
149 149
150 150 def mail=(arg)
151 151 write_attribute(:mail, arg.to_s.strip)
152 152 end
153 153
154 154 def identity_url=(url)
155 155 if url.blank?
156 156 write_attribute(:identity_url, '')
157 157 else
158 158 begin
159 159 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
160 160 rescue OpenIdAuthentication::InvalidOpenId
161 161 # Invalid url, don't save
162 162 end
163 163 end
164 164 self.read_attribute(:identity_url)
165 165 end
166 166
167 167 # Returns the user that matches provided login and password, or nil
168 168 def self.try_to_login(login, password, active_only=true)
169 169 login = login.to_s
170 170 password = password.to_s
171 171
172 172 # Make sure no one can sign in with an empty login or password
173 173 return nil if login.empty? || password.empty?
174 174 user = find_by_login(login)
175 175 if user
176 176 # user is already in local database
177 177 return nil unless user.check_password?(password)
178 178 return nil if !user.active? && active_only
179 179 else
180 180 # user is not yet registered, try to authenticate with available sources
181 181 attrs = AuthSource.authenticate(login, password)
182 182 if attrs
183 183 user = new(attrs)
184 184 user.login = login
185 185 user.language = Setting.default_language
186 186 if user.save
187 187 user.reload
188 188 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
189 189 end
190 190 end
191 191 end
192 192 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
193 193 user
194 194 rescue => text
195 195 raise text
196 196 end
197 197
198 198 # Returns the user who matches the given autologin +key+ or nil
199 199 def self.try_to_autologin(key)
200 200 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
201 201 if user
202 202 user.update_column(:last_login_on, Time.now)
203 203 user
204 204 end
205 205 end
206 206
207 207 def self.name_formatter(formatter = nil)
208 208 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
209 209 end
210 210
211 211 # Returns an array of fields names than can be used to make an order statement for users
212 212 # according to how user names are displayed
213 213 # Examples:
214 214 #
215 215 # User.fields_for_order_statement => ['users.login', 'users.id']
216 216 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
217 217 def self.fields_for_order_statement(table=nil)
218 218 table ||= table_name
219 219 name_formatter[:order].map {|field| "#{table}.#{field}"}
220 220 end
221 221
222 222 # Return user's full name for display
223 223 def name(formatter = nil)
224 224 f = self.class.name_formatter(formatter)
225 225 if formatter
226 226 eval('"' + f[:string] + '"')
227 227 else
228 228 @name ||= eval('"' + f[:string] + '"')
229 229 end
230 230 end
231 231
232 232 def active?
233 233 self.status == STATUS_ACTIVE
234 234 end
235 235
236 236 def registered?
237 237 self.status == STATUS_REGISTERED
238 238 end
239 239
240 240 def locked?
241 241 self.status == STATUS_LOCKED
242 242 end
243 243
244 244 def activate
245 245 self.status = STATUS_ACTIVE
246 246 end
247 247
248 248 def register
249 249 self.status = STATUS_REGISTERED
250 250 end
251 251
252 252 def lock
253 253 self.status = STATUS_LOCKED
254 254 end
255 255
256 256 def activate!
257 257 update_attribute(:status, STATUS_ACTIVE)
258 258 end
259 259
260 260 def register!
261 261 update_attribute(:status, STATUS_REGISTERED)
262 262 end
263 263
264 264 def lock!
265 265 update_attribute(:status, STATUS_LOCKED)
266 266 end
267 267
268 268 # Returns true if +clear_password+ is the correct user's password, otherwise false
269 269 def check_password?(clear_password)
270 270 if auth_source_id.present?
271 271 auth_source.authenticate(self.login, clear_password)
272 272 else
273 273 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
274 274 end
275 275 end
276 276
277 277 # Generates a random salt and computes hashed_password for +clear_password+
278 278 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
279 279 def salt_password(clear_password)
280 280 self.salt = User.generate_salt
281 281 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
282 282 self.passwd_changed_on = Time.now
283 283 end
284 284
285 285 # Does the backend storage allow this user to change their password?
286 286 def change_password_allowed?
287 287 return true if auth_source.nil?
288 288 return auth_source.allow_password_changes?
289 289 end
290 290
291 291 def must_change_password?
292 292 must_change_passwd? && change_password_allowed?
293 293 end
294 294
295 295 def generate_password?
296 296 generate_password == '1' || generate_password == true
297 297 end
298 298
299 299 # Generate and set a random password on given length
300 300 def random_password(length=40)
301 301 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
302 302 chars -= %w(0 O 1 l)
303 303 password = ''
304 304 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
305 305 self.password = password
306 306 self.password_confirmation = password
307 307 self
308 308 end
309 309
310 310 def pref
311 311 self.preference ||= UserPreference.new(:user => self)
312 312 end
313 313
314 314 def time_zone
315 315 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
316 316 end
317 317
318 318 def force_default_language?
319 319 Setting.force_default_language_for_loggedin?
320 320 end
321 321
322 322 def language
323 323 if force_default_language?
324 324 Setting.default_language
325 325 else
326 326 super
327 327 end
328 328 end
329 329
330 330 def wants_comments_in_reverse_order?
331 331 self.pref[:comments_sorting] == 'desc'
332 332 end
333 333
334 334 # Return user's RSS key (a 40 chars long string), used to access feeds
335 335 def rss_key
336 336 if rss_token.nil?
337 337 create_rss_token(:action => 'feeds')
338 338 end
339 339 rss_token.value
340 340 end
341 341
342 342 # Return user's API key (a 40 chars long string), used to access the API
343 343 def api_key
344 344 if api_token.nil?
345 345 create_api_token(:action => 'api')
346 346 end
347 347 api_token.value
348 348 end
349 349
350 350 # Return an array of project ids for which the user has explicitly turned mail notifications on
351 351 def notified_projects_ids
352 352 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
353 353 end
354 354
355 355 def notified_project_ids=(ids)
356 356 @notified_projects_ids_changed = true
357 357 @notified_projects_ids = ids
358 358 end
359 359
360 360 # Updates per project notifications (after_save callback)
361 361 def update_notified_project_ids
362 362 if @notified_projects_ids_changed
363 363 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
364 364 members.update_all(:mail_notification => false)
365 365 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
366 366 end
367 367 end
368 368 private :update_notified_project_ids
369 369
370 370 def valid_notification_options
371 371 self.class.valid_notification_options(self)
372 372 end
373 373
374 374 # Only users that belong to more than 1 project can select projects for which they are notified
375 375 def self.valid_notification_options(user=nil)
376 376 # Note that @user.membership.size would fail since AR ignores
377 377 # :include association option when doing a count
378 378 if user.nil? || user.memberships.length < 1
379 379 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
380 380 else
381 381 MAIL_NOTIFICATION_OPTIONS
382 382 end
383 383 end
384 384
385 385 # Find a user account by matching the exact login and then a case-insensitive
386 386 # version. Exact matches will be given priority.
387 387 def self.find_by_login(login)
388 388 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
389 389 if login.present?
390 390 # First look for an exact match
391 391 user = where(:login => login).detect {|u| u.login == login}
392 392 unless user
393 393 # Fail over to case-insensitive if none was found
394 394 user = where("LOWER(login) = ?", login.downcase).first
395 395 end
396 396 user
397 397 end
398 398 end
399 399
400 400 def self.find_by_rss_key(key)
401 401 Token.find_active_user('feeds', key)
402 402 end
403 403
404 404 def self.find_by_api_key(key)
405 405 Token.find_active_user('api', key)
406 406 end
407 407
408 408 # Makes find_by_mail case-insensitive
409 409 def self.find_by_mail(mail)
410 410 where("LOWER(mail) = ?", mail.to_s.downcase).first
411 411 end
412 412
413 413 # Returns true if the default admin account can no longer be used
414 414 def self.default_admin_account_changed?
415 415 !User.active.find_by_login("admin").try(:check_password?, "admin")
416 416 end
417 417
418 418 def to_s
419 419 name
420 420 end
421 421
422 422 CSS_CLASS_BY_STATUS = {
423 423 STATUS_ANONYMOUS => 'anon',
424 424 STATUS_ACTIVE => 'active',
425 425 STATUS_REGISTERED => 'registered',
426 426 STATUS_LOCKED => 'locked'
427 427 }
428 428
429 429 def css_classes
430 430 "user #{CSS_CLASS_BY_STATUS[status]}"
431 431 end
432 432
433 433 # Returns the current day according to user's time zone
434 434 def today
435 435 if time_zone.nil?
436 436 Date.today
437 437 else
438 438 Time.now.in_time_zone(time_zone).to_date
439 439 end
440 440 end
441 441
442 442 # Returns the day of +time+ according to user's time zone
443 443 def time_to_date(time)
444 444 if time_zone.nil?
445 445 time.to_date
446 446 else
447 447 time.in_time_zone(time_zone).to_date
448 448 end
449 449 end
450 450
451 451 def logged?
452 452 true
453 453 end
454 454
455 455 def anonymous?
456 456 !logged?
457 457 end
458 458
459 459 # Returns user's membership for the given project
460 460 # or nil if the user is not a member of project
461 461 def membership(project)
462 462 project_id = project.is_a?(Project) ? project.id : project
463 463
464 464 @membership_by_project_id ||= Hash.new {|h, project_id|
465 465 h[project_id] = memberships.where(:project_id => project_id).first
466 466 }
467 467 @membership_by_project_id[project_id]
468 468 end
469 469
470 470 # Returns the user's bult-in role
471 471 def builtin_role
472 472 @builtin_role ||= Role.non_member
473 473 end
474 474
475 475 # Return user's roles for project
476 476 def roles_for_project(project)
477 roles = []
478 477 # No role on archived projects
479 return roles if project.nil? || project.archived?
478 return [] if project.nil? || project.archived?
480 479 if membership = membership(project)
481 roles = membership.roles
480 membership.roles.dup
481 elsif project.is_public?
482 project.override_roles(builtin_role)
482 483 else
483 roles << builtin_role
484 []
484 485 end
485 roles
486 486 end
487 487
488 488 # Return true if the user is a member of project
489 489 def member_of?(project)
490 490 projects.to_a.include?(project)
491 491 end
492 492
493 493 # Returns a hash of user's projects grouped by roles
494 494 def projects_by_role
495 495 return @projects_by_role if @projects_by_role
496 496
497 @projects_by_role = Hash.new([])
498 memberships.each do |membership|
499 if membership.project
500 membership.roles.each do |role|
501 @projects_by_role[role] = [] unless @projects_by_role.key?(role)
502 @projects_by_role[role] << membership.project
497 hash = Hash.new([])
498
499 members = Member.joins(:project).
500 where("#{Project.table_name}.status <> 9").
501 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Member.table_name}.user_id = ?)", self.id, true, Group.builtin_id(self)).
502 preload(:project, :roles)
503
504 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
505 members.each do |member|
506 if member.project
507 member.roles.each do |role|
508 hash[role] = [] unless hash.key?(role)
509 hash[role] << member.project
503 510 end
504 511 end
505 512 end
506 @projects_by_role.each do |role, projects|
513
514 hash.each do |role, projects|
507 515 projects.uniq!
508 516 end
509 517
510 @projects_by_role
518 @projects_by_role = hash
511 519 end
512 520
513 521 # Returns true if user is arg or belongs to arg
514 522 def is_or_belongs_to?(arg)
515 523 if arg.is_a?(User)
516 524 self == arg
517 525 elsif arg.is_a?(Group)
518 526 arg.users.include?(self)
519 527 else
520 528 false
521 529 end
522 530 end
523 531
524 532 # Return true if the user is allowed to do the specified action on a specific context
525 533 # Action can be:
526 534 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
527 535 # * a permission Symbol (eg. :edit_project)
528 536 # Context can be:
529 537 # * a project : returns true if user is allowed to do the specified action on this project
530 538 # * an array of projects : returns true if user is allowed on every project
531 539 # * nil with options[:global] set : check if user has at least one role allowed for this action,
532 540 # or falls back to Non Member / Anonymous permissions depending if the user is logged
533 541 def allowed_to?(action, context, options={}, &block)
534 542 if context && context.is_a?(Project)
535 543 return false unless context.allows_to?(action)
536 544 # Admin users are authorized for anything else
537 545 return true if admin?
538 546
539 547 roles = roles_for_project(context)
540 548 return false unless roles
541 549 roles.any? {|role|
542 550 (context.is_public? || role.member?) &&
543 551 role.allowed_to?(action) &&
544 552 (block_given? ? yield(role, self) : true)
545 553 }
546 554 elsif context && context.is_a?(Array)
547 555 if context.empty?
548 556 false
549 557 else
550 558 # Authorize if user is authorized on every element of the array
551 559 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
552 560 end
553 561 elsif options[:global]
554 562 # Admin users are always authorized
555 563 return true if admin?
556 564
557 565 # authorize if user has at least one role that has this permission
558 566 roles = memberships.collect {|m| m.roles}.flatten.uniq
559 567 roles << (self.logged? ? Role.non_member : Role.anonymous)
560 568 roles.any? {|role|
561 569 role.allowed_to?(action) &&
562 570 (block_given? ? yield(role, self) : true)
563 571 }
564 572 else
565 573 false
566 574 end
567 575 end
568 576
569 577 # Is the user allowed to do the specified action on any project?
570 578 # See allowed_to? for the actions and valid options.
571 579 #
572 580 # NB: this method is not used anywhere in the core codebase as of
573 581 # 2.5.2, but it's used by many plugins so if we ever want to remove
574 582 # it it has to be carefully deprecated for a version or two.
575 583 def allowed_to_globally?(action, options={}, &block)
576 584 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
577 585 end
578 586
579 587 # Returns true if the user is allowed to delete the user's own account
580 588 def own_account_deletable?
581 589 Setting.unsubscribe? &&
582 590 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
583 591 end
584 592
585 593 safe_attributes 'login',
586 594 'firstname',
587 595 'lastname',
588 596 'mail',
589 597 'mail_notification',
590 598 'notified_project_ids',
591 599 'language',
592 600 'custom_field_values',
593 601 'custom_fields',
594 602 'identity_url'
595 603
596 604 safe_attributes 'status',
597 605 'auth_source_id',
598 606 'generate_password',
599 607 'must_change_passwd',
600 608 :if => lambda {|user, current_user| current_user.admin?}
601 609
602 610 safe_attributes 'group_ids',
603 611 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
604 612
605 613 # Utility method to help check if a user should be notified about an
606 614 # event.
607 615 #
608 616 # TODO: only supports Issue events currently
609 617 def notify_about?(object)
610 618 if mail_notification == 'all'
611 619 true
612 620 elsif mail_notification.blank? || mail_notification == 'none'
613 621 false
614 622 else
615 623 case object
616 624 when Issue
617 625 case mail_notification
618 626 when 'selected', 'only_my_events'
619 627 # user receives notifications for created/assigned issues on unselected projects
620 628 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
621 629 when 'only_assigned'
622 630 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
623 631 when 'only_owner'
624 632 object.author == self
625 633 end
626 634 when News
627 635 # always send to project members except when mail_notification is set to 'none'
628 636 true
629 637 end
630 638 end
631 639 end
632 640
633 641 def self.current=(user)
634 642 RequestStore.store[:current_user] = user
635 643 end
636 644
637 645 def self.current
638 646 RequestStore.store[:current_user] ||= User.anonymous
639 647 end
640 648
641 649 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
642 650 # one anonymous user per database.
643 651 def self.anonymous
644 652 anonymous_user = AnonymousUser.first
645 653 if anonymous_user.nil?
646 654 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
647 655 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
648 656 end
649 657 anonymous_user
650 658 end
651 659
652 660 # Salts all existing unsalted passwords
653 661 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
654 662 # This method is used in the SaltPasswords migration and is to be kept as is
655 663 def self.salt_unsalted_passwords!
656 664 transaction do
657 665 User.where("salt IS NULL OR salt = ''").find_each do |user|
658 666 next if user.hashed_password.blank?
659 667 salt = User.generate_salt
660 668 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
661 669 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
662 670 end
663 671 end
664 672 end
665 673
666 674 protected
667 675
668 676 def validate_password_length
669 677 return if password.blank? && generate_password?
670 678 # Password length validation based on setting
671 679 if !password.nil? && password.size < Setting.password_min_length.to_i
672 680 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
673 681 end
674 682 end
675 683
676 684 private
677 685
678 686 def generate_password_if_needed
679 687 if generate_password? && auth_source.nil?
680 688 length = [Setting.password_min_length.to_i + 2, 10].max
681 689 random_password(length)
682 690 end
683 691 end
684 692
685 693 # Delete all outstanding password reset tokens on password or email change.
686 694 # Delete the autologin tokens on password change to prohibit session leakage.
687 695 # This helps to keep the account secure in case the associated email account
688 696 # was compromised.
689 697 def destroy_tokens
690 698 tokens = []
691 699 tokens |= ['recovery', 'autologin'] if hashed_password_changed?
692 700 tokens |= ['recovery'] if mail_changed?
693 701
694 702 if tokens.any?
695 703 Token.where(:user_id => id, :action => tokens).delete_all
696 704 end
697 705 end
698 706
699 707 # Removes references that are not handled by associations
700 708 # Things that are not deleted are reassociated with the anonymous user
701 709 def remove_references_before_destroy
702 710 return if self.id.nil?
703 711
704 712 substitute = User.anonymous
705 713 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
706 714 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
707 715 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
708 716 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
709 717 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
710 718 JournalDetail.
711 719 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
712 720 update_all(['old_value = ?', substitute.id.to_s])
713 721 JournalDetail.
714 722 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
715 723 update_all(['value = ?', substitute.id.to_s])
716 724 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
717 725 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
718 726 # Remove private queries and keep public ones
719 727 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
720 728 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
721 729 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
722 730 Token.delete_all ['user_id = ?', id]
723 731 Watcher.delete_all ['user_id = ?', id]
724 732 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
725 733 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
726 734 end
727 735
728 736 # Return password digest
729 737 def self.hash_password(clear_password)
730 738 Digest::SHA1.hexdigest(clear_password || "")
731 739 end
732 740
733 741 # Returns a 128bits random salt as a hex string (32 chars long)
734 742 def self.generate_salt
735 743 Redmine::Utils.random_hex(16)
736 744 end
737 745
738 746 end
739 747
740 748 class AnonymousUser < User
741 749 validate :validate_anonymous_uniqueness, :on => :create
742 750
743 751 def validate_anonymous_uniqueness
744 752 # There should be only one AnonymousUser in the database
745 753 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
746 754 end
747 755
748 756 def available_custom_fields
749 757 []
750 758 end
751 759
752 760 # Overrides a few properties
753 761 def logged?; false end
754 762 def admin; false end
755 763 def name(*args); I18n.t(:label_user_anonymous) end
756 764 def mail; nil end
757 765 def time_zone; nil end
758 766 def rss_key; nil end
759 767
760 768 def pref
761 769 UserPreference.new(:user => self)
762 770 end
763 771
764 772 # Returns the user's bult-in role
765 773 def builtin_role
766 774 @builtin_role ||= Role.anonymous
767 775 end
768 776
769 777 def membership(*args)
770 778 nil
771 779 end
772 780
773 781 def member_of?(*args)
774 782 false
775 783 end
776 784
777 785 # Anonymous user can not be destroyed
778 786 def destroy
779 787 false
780 788 end
781 789 end
@@ -1,8 +1,10
1 1 <%= error_messages_for @group %>
2 2
3 3 <div class="box tabular">
4 <p><%= f.text_field :name, :required => true, :size => 60 %></p>
4 <p><%= f.text_field :name, :required => true, :size => 60,
5 :disabled => !@group.safe_attribute?('name') %></p>
6
5 7 <% @group.custom_field_values.each do |value| %>
6 8 <p><%= custom_field_tag_with_label :group, value %></p>
7 9 <% end %>
8 10 </div>
@@ -1,4 +1,4
1 <%= labelled_form_for @group do |f| %>
1 <%= labelled_form_for @group, :url => group_path(@group) do |f| %>
2 2 <%= render :partial => 'form', :locals => { :f => f } %>
3 3 <%= submit_tag l(:button_save) %>
4 4 <% end %>
@@ -1,3 +1,3
1 1 <%= title [l(:label_group_plural), groups_path], @group.name %>
2 2
3 <%= render_tabs group_settings_tabs %>
3 <%= render_tabs group_settings_tabs(@group) %>
@@ -1,10 +1,11
1 1 api.array :groups do
2 2 @groups.each do |group|
3 3 api.group do
4 4 api.id group.id
5 5 api.name group.lastname
6 api.builtin group.builtin_type if group.builtin_type
6 7
7 8 render_api_custom_values group.visible_custom_field_values, api
8 9 end
9 10 end
10 11 end
@@ -1,25 +1,25
1 1 <div class="contextual">
2 2 <%= link_to l(:label_group_new), new_group_path, :class => 'icon icon-add' %>
3 3 </div>
4 4
5 5 <%= title l(:label_group_plural) %>
6 6 <% if @groups.any? %>
7 7 <table class="list groups">
8 8 <thead><tr>
9 9 <th><%=l(:label_group)%></th>
10 10 <th><%=l(:label_user_plural)%></th>
11 11 <th></th>
12 12 </tr></thead>
13 13 <tbody>
14 14 <% @groups.each do |group| %>
15 <tr id="group-<%= group.id %>" class="<%= cycle 'odd', 'even' %>">
15 <tr id="group-<%= group.id %>" class="<%= cycle 'odd', 'even' %> <%= "builtin" if group.builtin? %>">
16 16 <td class="name"><%= link_to h(group), edit_group_path(group) %></td>
17 <td class="user_count"><%= @user_count_by_group_id[group.id] || 0 %></td>
18 <td class="buttons"><%= delete_link group %></td>
17 <td class="user_count"><%= (@user_count_by_group_id[group.id] || 0) unless group.builtin? %></td>
18 <td class="buttons"><%= delete_link group unless group.builtin? %></td>
19 19 </tr>
20 20 <% end %>
21 21 </tbody>
22 22 </table>
23 23 <% else %>
24 24 <p class="nodata"><%= l(:label_no_data) %></p>
25 25 <% end %>
@@ -1,30 +1,31
1 1 api.group do
2 2 api.id @group.id
3 3 api.name @group.lastname
4 api.builtin @group.builtin_type if @group.builtin_type
4 5
5 6 render_api_custom_values @group.visible_custom_field_values, api
6 7
7 8 api.array :users do
8 9 @group.users.each do |user|
9 10 api.user :id => user.id, :name => user.name
10 11 end
11 end if include_in_api_response?('users')
12 end if include_in_api_response?('users') && !@group.builtin?
12 13
13 14 api.array :memberships do
14 15 @group.memberships.each do |membership|
15 16 api.membership do
16 17 api.id membership.id
17 18 api.project :id => membership.project.id, :name => membership.project.name
18 19 api.array :roles do
19 20 membership.member_roles.each do |member_role|
20 21 if member_role.role
21 22 attrs = {:id => member_role.role.id, :name => member_role.role.name}
22 23 attrs.merge!(:inherited => true) if member_role.inherited_from.present?
23 24 api.role attrs
24 25 end
25 26 end
26 27 end
27 28 end if membership.project
28 29 end
29 30 end if include_in_api_response?('memberships')
30 31 end
@@ -1,9 +1,9
1 1 <%= form_for(:user, :url => { :action => 'update' }, :html => {:method => :put}) do %>
2 2 <div class="box">
3 <% Group.all.sort.each do |group| %>
3 <% Group.givable.sort.each do |group| %>
4 4 <label><%= check_box_tag 'user[group_ids][]', group.id, @user.groups.include?(group), :id => nil %> <%=h group %></label><br />
5 5 <% end %>
6 6 <%= hidden_field_tag 'user[group_ids][]', '' %>
7 7 </div>
8 8 <%= submit_tag l(:button_save) %>
9 9 <% end %>
@@ -1,1108 +1,1110
1 1 en:
2 2 # Text direction: Left-to-Right (ltr) or Right-to-Left (rtl)
3 3 direction: ltr
4 4 date:
5 5 formats:
6 6 # Use the strftime parameters for formats.
7 7 # When no format has been given, it uses default.
8 8 # You can provide other formats here if you like!
9 9 default: "%m/%d/%Y"
10 10 short: "%b %d"
11 11 long: "%B %d, %Y"
12 12
13 13 day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
14 14 abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
15 15
16 16 # Don't forget the nil at the beginning; there's no such thing as a 0th month
17 17 month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December]
18 18 abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
19 19 # Used in date_select and datime_select.
20 20 order:
21 21 - :year
22 22 - :month
23 23 - :day
24 24
25 25 time:
26 26 formats:
27 27 default: "%m/%d/%Y %I:%M %p"
28 28 time: "%I:%M %p"
29 29 short: "%d %b %H:%M"
30 30 long: "%B %d, %Y %H:%M"
31 31 am: "am"
32 32 pm: "pm"
33 33
34 34 datetime:
35 35 distance_in_words:
36 36 half_a_minute: "half a minute"
37 37 less_than_x_seconds:
38 38 one: "less than 1 second"
39 39 other: "less than %{count} seconds"
40 40 x_seconds:
41 41 one: "1 second"
42 42 other: "%{count} seconds"
43 43 less_than_x_minutes:
44 44 one: "less than a minute"
45 45 other: "less than %{count} minutes"
46 46 x_minutes:
47 47 one: "1 minute"
48 48 other: "%{count} minutes"
49 49 about_x_hours:
50 50 one: "about 1 hour"
51 51 other: "about %{count} hours"
52 52 x_hours:
53 53 one: "1 hour"
54 54 other: "%{count} hours"
55 55 x_days:
56 56 one: "1 day"
57 57 other: "%{count} days"
58 58 about_x_months:
59 59 one: "about 1 month"
60 60 other: "about %{count} months"
61 61 x_months:
62 62 one: "1 month"
63 63 other: "%{count} months"
64 64 about_x_years:
65 65 one: "about 1 year"
66 66 other: "about %{count} years"
67 67 over_x_years:
68 68 one: "over 1 year"
69 69 other: "over %{count} years"
70 70 almost_x_years:
71 71 one: "almost 1 year"
72 72 other: "almost %{count} years"
73 73
74 74 number:
75 75 format:
76 76 separator: "."
77 77 delimiter: ""
78 78 precision: 3
79 79
80 80 human:
81 81 format:
82 82 delimiter: ""
83 83 precision: 3
84 84 storage_units:
85 85 format: "%n %u"
86 86 units:
87 87 byte:
88 88 one: "Byte"
89 89 other: "Bytes"
90 90 kb: "KB"
91 91 mb: "MB"
92 92 gb: "GB"
93 93 tb: "TB"
94 94
95 95 # Used in array.to_sentence.
96 96 support:
97 97 array:
98 98 sentence_connector: "and"
99 99 skip_last_comma: false
100 100
101 101 activerecord:
102 102 errors:
103 103 template:
104 104 header:
105 105 one: "1 error prohibited this %{model} from being saved"
106 106 other: "%{count} errors prohibited this %{model} from being saved"
107 107 messages:
108 108 inclusion: "is not included in the list"
109 109 exclusion: "is reserved"
110 110 invalid: "is invalid"
111 111 confirmation: "doesn't match confirmation"
112 112 accepted: "must be accepted"
113 113 empty: "can't be empty"
114 114 blank: "can't be blank"
115 115 too_long: "is too long (maximum is %{count} characters)"
116 116 too_short: "is too short (minimum is %{count} characters)"
117 117 wrong_length: "is the wrong length (should be %{count} characters)"
118 118 taken: "has already been taken"
119 119 not_a_number: "is not a number"
120 120 not_a_date: "is not a valid date"
121 121 greater_than: "must be greater than %{count}"
122 122 greater_than_or_equal_to: "must be greater than or equal to %{count}"
123 123 equal_to: "must be equal to %{count}"
124 124 less_than: "must be less than %{count}"
125 125 less_than_or_equal_to: "must be less than or equal to %{count}"
126 126 odd: "must be odd"
127 127 even: "must be even"
128 128 greater_than_start_date: "must be greater than start date"
129 129 not_same_project: "doesn't belong to the same project"
130 130 circular_dependency: "This relation would create a circular dependency"
131 131 cant_link_an_issue_with_a_descendant: "An issue cannot be linked to one of its subtasks"
132 132 earlier_than_minimum_start_date: "cannot be earlier than %{date} because of preceding issues"
133 133
134 134 actionview_instancetag_blank_option: Please select
135 135
136 136 general_text_No: 'No'
137 137 general_text_Yes: 'Yes'
138 138 general_text_no: 'no'
139 139 general_text_yes: 'yes'
140 140 general_lang_name: 'English'
141 141 general_csv_separator: ','
142 142 general_csv_decimal_separator: '.'
143 143 general_csv_encoding: ISO-8859-1
144 144 general_first_day_of_week: '7'
145 145
146 146 notice_account_updated: Account was successfully updated.
147 147 notice_account_invalid_creditentials: Invalid user or password
148 148 notice_account_password_updated: Password was successfully updated.
149 149 notice_account_wrong_password: Wrong password
150 150 notice_account_register_done: Account was successfully created. An email containing the instructions to activate your account was sent to %{email}.
151 151 notice_account_unknown_email: Unknown user.
152 152 notice_account_not_activated_yet: You haven't activated your account yet. If you want to receive a new activation email, please <a href="%{url}">click this link</a>.
153 153 notice_account_locked: Your account is locked.
154 154 notice_can_t_change_password: This account uses an external authentication source. Impossible to change the password.
155 155 notice_account_lost_email_sent: An email with instructions to choose a new password has been sent to you.
156 156 notice_account_activated: Your account has been activated. You can now log in.
157 157 notice_successful_create: Successful creation.
158 158 notice_successful_update: Successful update.
159 159 notice_successful_delete: Successful deletion.
160 160 notice_successful_connection: Successful connection.
161 161 notice_file_not_found: The page you were trying to access doesn't exist or has been removed.
162 162 notice_locking_conflict: Data has been updated by another user.
163 163 notice_not_authorized: You are not authorized to access this page.
164 164 notice_not_authorized_archived_project: The project you're trying to access has been archived.
165 165 notice_email_sent: "An email was sent to %{value}"
166 166 notice_email_error: "An error occurred while sending mail (%{value})"
167 167 notice_feeds_access_key_reseted: Your Atom access key was reset.
168 168 notice_api_access_key_reseted: Your API access key was reset.
169 169 notice_failed_to_save_issues: "Failed to save %{count} issue(s) on %{total} selected: %{ids}."
170 170 notice_failed_to_save_time_entries: "Failed to save %{count} time entrie(s) on %{total} selected: %{ids}."
171 171 notice_failed_to_save_members: "Failed to save member(s): %{errors}."
172 172 notice_no_issue_selected: "No issue is selected! Please, check the issues you want to edit."
173 173 notice_account_pending: "Your account was created and is now pending administrator approval."
174 174 notice_default_data_loaded: Default configuration successfully loaded.
175 175 notice_unable_delete_version: Unable to delete version.
176 176 notice_unable_delete_time_entry: Unable to delete time log entry.
177 177 notice_issue_done_ratios_updated: Issue done ratios updated.
178 178 notice_gantt_chart_truncated: "The chart was truncated because it exceeds the maximum number of items that can be displayed (%{max})"
179 179 notice_issue_successful_create: "Issue %{id} created."
180 180 notice_issue_update_conflict: "The issue has been updated by an other user while you were editing it."
181 181 notice_account_deleted: "Your account has been permanently deleted."
182 182 notice_user_successful_create: "User %{id} created."
183 183 notice_new_password_must_be_different: The new password must be different from the current password
184 184
185 185 error_can_t_load_default_data: "Default configuration could not be loaded: %{value}"
186 186 error_scm_not_found: "The entry or revision was not found in the repository."
187 187 error_scm_command_failed: "An error occurred when trying to access the repository: %{value}"
188 188 error_scm_annotate: "The entry does not exist or cannot be annotated."
189 189 error_scm_annotate_big_text_file: "The entry cannot be annotated, as it exceeds the maximum text file size."
190 190 error_issue_not_found_in_project: 'The issue was not found or does not belong to this project'
191 191 error_no_tracker_in_project: 'No tracker is associated to this project. Please check the Project settings.'
192 192 error_no_default_issue_status: 'No default issue status is defined. Please check your configuration (Go to "Administration -> Issue statuses").'
193 193 error_can_not_delete_custom_field: Unable to delete custom field
194 194 error_can_not_delete_tracker: "This tracker contains issues and cannot be deleted."
195 195 error_can_not_remove_role: "This role is in use and cannot be deleted."
196 196 error_can_not_reopen_issue_on_closed_version: 'An issue assigned to a closed version cannot be reopened'
197 197 error_can_not_archive_project: This project cannot be archived
198 198 error_issue_done_ratios_not_updated: "Issue done ratios not updated."
199 199 error_workflow_copy_source: 'Please select a source tracker or role'
200 200 error_workflow_copy_target: 'Please select target tracker(s) and role(s)'
201 201 error_unable_delete_issue_status: 'Unable to delete issue status'
202 202 error_unable_to_connect: "Unable to connect (%{value})"
203 203 error_attachment_too_big: "This file cannot be uploaded because it exceeds the maximum allowed file size (%{max_size})"
204 204 error_session_expired: "Your session has expired. Please login again."
205 205 warning_attachments_not_saved: "%{count} file(s) could not be saved."
206 206
207 207 mail_subject_lost_password: "Your %{value} password"
208 208 mail_body_lost_password: 'To change your password, click on the following link:'
209 209 mail_subject_register: "Your %{value} account activation"
210 210 mail_body_register: 'To activate your account, click on the following link:'
211 211 mail_body_account_information_external: "You can use your %{value} account to log in."
212 212 mail_body_account_information: Your account information
213 213 mail_subject_account_activation_request: "%{value} account activation request"
214 214 mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:"
215 215 mail_subject_reminder: "%{count} issue(s) due in the next %{days} days"
216 216 mail_body_reminder: "%{count} issue(s) that are assigned to you are due in the next %{days} days:"
217 217 mail_subject_wiki_content_added: "'%{id}' wiki page has been added"
218 218 mail_body_wiki_content_added: "The '%{id}' wiki page has been added by %{author}."
219 219 mail_subject_wiki_content_updated: "'%{id}' wiki page has been updated"
220 220 mail_body_wiki_content_updated: "The '%{id}' wiki page has been updated by %{author}."
221 221
222 222 field_name: Name
223 223 field_description: Description
224 224 field_summary: Summary
225 225 field_is_required: Required
226 226 field_firstname: First name
227 227 field_lastname: Last name
228 228 field_mail: Email
229 229 field_filename: File
230 230 field_filesize: Size
231 231 field_downloads: Downloads
232 232 field_author: Author
233 233 field_created_on: Created
234 234 field_updated_on: Updated
235 235 field_closed_on: Closed
236 236 field_field_format: Format
237 237 field_is_for_all: For all projects
238 238 field_possible_values: Possible values
239 239 field_regexp: Regular expression
240 240 field_min_length: Minimum length
241 241 field_max_length: Maximum length
242 242 field_value: Value
243 243 field_category: Category
244 244 field_title: Title
245 245 field_project: Project
246 246 field_issue: Issue
247 247 field_status: Status
248 248 field_notes: Notes
249 249 field_is_closed: Issue closed
250 250 field_is_default: Default value
251 251 field_tracker: Tracker
252 252 field_subject: Subject
253 253 field_due_date: Due date
254 254 field_assigned_to: Assignee
255 255 field_priority: Priority
256 256 field_fixed_version: Target version
257 257 field_user: User
258 258 field_principal: Principal
259 259 field_role: Role
260 260 field_homepage: Homepage
261 261 field_is_public: Public
262 262 field_parent: Subproject of
263 263 field_is_in_roadmap: Issues displayed in roadmap
264 264 field_login: Login
265 265 field_mail_notification: Email notifications
266 266 field_admin: Administrator
267 267 field_last_login_on: Last connection
268 268 field_language: Language
269 269 field_effective_date: Date
270 270 field_password: Password
271 271 field_new_password: New password
272 272 field_password_confirmation: Confirmation
273 273 field_version: Version
274 274 field_type: Type
275 275 field_host: Host
276 276 field_port: Port
277 277 field_account: Account
278 278 field_base_dn: Base DN
279 279 field_attr_login: Login attribute
280 280 field_attr_firstname: Firstname attribute
281 281 field_attr_lastname: Lastname attribute
282 282 field_attr_mail: Email attribute
283 283 field_onthefly: On-the-fly user creation
284 284 field_start_date: Start date
285 285 field_done_ratio: "% Done"
286 286 field_auth_source: Authentication mode
287 287 field_hide_mail: Hide my email address
288 288 field_comments: Comment
289 289 field_url: URL
290 290 field_start_page: Start page
291 291 field_subproject: Subproject
292 292 field_hours: Hours
293 293 field_activity: Activity
294 294 field_spent_on: Date
295 295 field_identifier: Identifier
296 296 field_is_filter: Used as a filter
297 297 field_issue_to: Related issue
298 298 field_delay: Delay
299 299 field_assignable: Issues can be assigned to this role
300 300 field_redirect_existing_links: Redirect existing links
301 301 field_estimated_hours: Estimated time
302 302 field_column_names: Columns
303 303 field_time_entries: Log time
304 304 field_time_zone: Time zone
305 305 field_searchable: Searchable
306 306 field_default_value: Default value
307 307 field_comments_sorting: Display comments
308 308 field_parent_title: Parent page
309 309 field_editable: Editable
310 310 field_watcher: Watcher
311 311 field_identity_url: OpenID URL
312 312 field_content: Content
313 313 field_group_by: Group results by
314 314 field_sharing: Sharing
315 315 field_parent_issue: Parent task
316 316 field_member_of_group: "Assignee's group"
317 317 field_assigned_to_role: "Assignee's role"
318 318 field_text: Text field
319 319 field_visible: Visible
320 320 field_warn_on_leaving_unsaved: "Warn me when leaving a page with unsaved text"
321 321 field_issues_visibility: Issues visibility
322 322 field_is_private: Private
323 323 field_commit_logs_encoding: Commit messages encoding
324 324 field_scm_path_encoding: Path encoding
325 325 field_path_to_repository: Path to repository
326 326 field_root_directory: Root directory
327 327 field_cvsroot: CVSROOT
328 328 field_cvs_module: Module
329 329 field_repository_is_default: Main repository
330 330 field_multiple: Multiple values
331 331 field_auth_source_ldap_filter: LDAP filter
332 332 field_core_fields: Standard fields
333 333 field_timeout: "Timeout (in seconds)"
334 334 field_board_parent: Parent forum
335 335 field_private_notes: Private notes
336 336 field_inherit_members: Inherit members
337 337 field_generate_password: Generate password
338 338 field_must_change_passwd: Must change password at next logon
339 339
340 340 setting_app_title: Application title
341 341 setting_app_subtitle: Application subtitle
342 342 setting_welcome_text: Welcome text
343 343 setting_default_language: Default language
344 344 setting_login_required: Authentication required
345 345 setting_self_registration: Self-registration
346 346 setting_attachment_max_size: Maximum attachment size
347 347 setting_issues_export_limit: Issues export limit
348 348 setting_mail_from: Emission email address
349 349 setting_bcc_recipients: Blind carbon copy recipients (bcc)
350 350 setting_plain_text_mail: Plain text mail (no HTML)
351 351 setting_host_name: Host name and path
352 352 setting_text_formatting: Text formatting
353 353 setting_wiki_compression: Wiki history compression
354 354 setting_feeds_limit: Maximum number of items in Atom feeds
355 355 setting_default_projects_public: New projects are public by default
356 356 setting_autofetch_changesets: Fetch commits automatically
357 357 setting_sys_api_enabled: Enable WS for repository management
358 358 setting_commit_ref_keywords: Referencing keywords
359 359 setting_commit_fix_keywords: Fixing keywords
360 360 setting_autologin: Autologin
361 361 setting_date_format: Date format
362 362 setting_time_format: Time format
363 363 setting_cross_project_issue_relations: Allow cross-project issue relations
364 364 setting_cross_project_subtasks: Allow cross-project subtasks
365 365 setting_issue_list_default_columns: Default columns displayed on the issue list
366 366 setting_repositories_encodings: Attachments and repositories encodings
367 367 setting_emails_header: Email header
368 368 setting_emails_footer: Email footer
369 369 setting_protocol: Protocol
370 370 setting_per_page_options: Objects per page options
371 371 setting_user_format: Users display format
372 372 setting_activity_days_default: Days displayed on project activity
373 373 setting_display_subprojects_issues: Display subprojects issues on main projects by default
374 374 setting_enabled_scm: Enabled SCM
375 375 setting_mail_handler_body_delimiters: "Truncate emails after one of these lines"
376 376 setting_mail_handler_api_enabled: Enable WS for incoming emails
377 377 setting_mail_handler_api_key: API key
378 378 setting_sequential_project_identifiers: Generate sequential project identifiers
379 379 setting_gravatar_enabled: Use Gravatar user icons
380 380 setting_gravatar_default: Default Gravatar image
381 381 setting_diff_max_lines_displayed: Maximum number of diff lines displayed
382 382 setting_file_max_size_displayed: Maximum size of text files displayed inline
383 383 setting_repository_log_display_limit: Maximum number of revisions displayed on file log
384 384 setting_openid: Allow OpenID login and registration
385 385 setting_password_min_length: Minimum password length
386 386 setting_new_project_user_role_id: Role given to a non-admin user who creates a project
387 387 setting_default_projects_modules: Default enabled modules for new projects
388 388 setting_issue_done_ratio: Calculate the issue done ratio with
389 389 setting_issue_done_ratio_issue_field: Use the issue field
390 390 setting_issue_done_ratio_issue_status: Use the issue status
391 391 setting_start_of_week: Start calendars on
392 392 setting_rest_api_enabled: Enable REST web service
393 393 setting_cache_formatted_text: Cache formatted text
394 394 setting_default_notification_option: Default notification option
395 395 setting_commit_logtime_enabled: Enable time logging
396 396 setting_commit_logtime_activity_id: Activity for logged time
397 397 setting_gantt_items_limit: Maximum number of items displayed on the gantt chart
398 398 setting_issue_group_assignment: Allow issue assignment to groups
399 399 setting_default_issue_start_date_to_creation_date: Use current date as start date for new issues
400 400 setting_commit_cross_project_ref: Allow issues of all the other projects to be referenced and fixed
401 401 setting_unsubscribe: Allow users to delete their own account
402 402 setting_session_lifetime: Session maximum lifetime
403 403 setting_session_timeout: Session inactivity timeout
404 404 setting_thumbnails_enabled: Display attachment thumbnails
405 405 setting_thumbnails_size: Thumbnails size (in pixels)
406 406 setting_non_working_week_days: Non-working days
407 407 setting_jsonp_enabled: Enable JSONP support
408 408 setting_default_projects_tracker_ids: Default trackers for new projects
409 409 setting_mail_handler_excluded_filenames: Exclude attachments by name
410 410 setting_force_default_language_for_anonymous: Force default language for anonymous users
411 411 setting_force_default_language_for_loggedin: Force default language for logged-in users
412 412
413 413 permission_add_project: Create project
414 414 permission_add_subprojects: Create subprojects
415 415 permission_edit_project: Edit project
416 416 permission_close_project: Close / reopen the project
417 417 permission_select_project_modules: Select project modules
418 418 permission_manage_members: Manage members
419 419 permission_manage_project_activities: Manage project activities
420 420 permission_manage_versions: Manage versions
421 421 permission_manage_categories: Manage issue categories
422 422 permission_view_issues: View Issues
423 423 permission_add_issues: Add issues
424 424 permission_edit_issues: Edit issues
425 425 permission_manage_issue_relations: Manage issue relations
426 426 permission_set_issues_private: Set issues public or private
427 427 permission_set_own_issues_private: Set own issues public or private
428 428 permission_add_issue_notes: Add notes
429 429 permission_edit_issue_notes: Edit notes
430 430 permission_edit_own_issue_notes: Edit own notes
431 431 permission_view_private_notes: View private notes
432 432 permission_set_notes_private: Set notes as private
433 433 permission_move_issues: Move issues
434 434 permission_delete_issues: Delete issues
435 435 permission_manage_public_queries: Manage public queries
436 436 permission_save_queries: Save queries
437 437 permission_view_gantt: View gantt chart
438 438 permission_view_calendar: View calendar
439 439 permission_view_issue_watchers: View watchers list
440 440 permission_add_issue_watchers: Add watchers
441 441 permission_delete_issue_watchers: Delete watchers
442 442 permission_log_time: Log spent time
443 443 permission_view_time_entries: View spent time
444 444 permission_edit_time_entries: Edit time logs
445 445 permission_edit_own_time_entries: Edit own time logs
446 446 permission_manage_news: Manage news
447 447 permission_comment_news: Comment news
448 448 permission_view_documents: View documents
449 449 permission_add_documents: Add documents
450 450 permission_edit_documents: Edit documents
451 451 permission_delete_documents: Delete documents
452 452 permission_manage_files: Manage files
453 453 permission_view_files: View files
454 454 permission_manage_wiki: Manage wiki
455 455 permission_rename_wiki_pages: Rename wiki pages
456 456 permission_delete_wiki_pages: Delete wiki pages
457 457 permission_view_wiki_pages: View wiki
458 458 permission_view_wiki_edits: View wiki history
459 459 permission_edit_wiki_pages: Edit wiki pages
460 460 permission_delete_wiki_pages_attachments: Delete attachments
461 461 permission_protect_wiki_pages: Protect wiki pages
462 462 permission_manage_repository: Manage repository
463 463 permission_browse_repository: Browse repository
464 464 permission_view_changesets: View changesets
465 465 permission_commit_access: Commit access
466 466 permission_manage_boards: Manage forums
467 467 permission_view_messages: View messages
468 468 permission_add_messages: Post messages
469 469 permission_edit_messages: Edit messages
470 470 permission_edit_own_messages: Edit own messages
471 471 permission_delete_messages: Delete messages
472 472 permission_delete_own_messages: Delete own messages
473 473 permission_export_wiki_pages: Export wiki pages
474 474 permission_manage_subtasks: Manage subtasks
475 475 permission_manage_related_issues: Manage related issues
476 476
477 477 project_module_issue_tracking: Issue tracking
478 478 project_module_time_tracking: Time tracking
479 479 project_module_news: News
480 480 project_module_documents: Documents
481 481 project_module_files: Files
482 482 project_module_wiki: Wiki
483 483 project_module_repository: Repository
484 484 project_module_boards: Forums
485 485 project_module_calendar: Calendar
486 486 project_module_gantt: Gantt
487 487
488 488 label_user: User
489 489 label_user_plural: Users
490 490 label_user_new: New user
491 491 label_user_anonymous: Anonymous
492 492 label_project: Project
493 493 label_project_new: New project
494 494 label_project_plural: Projects
495 495 label_x_projects:
496 496 zero: no projects
497 497 one: 1 project
498 498 other: "%{count} projects"
499 499 label_project_all: All Projects
500 500 label_project_latest: Latest projects
501 501 label_issue: Issue
502 502 label_issue_new: New issue
503 503 label_issue_plural: Issues
504 504 label_issue_view_all: View all issues
505 505 label_issues_by: "Issues by %{value}"
506 506 label_issue_added: Issue added
507 507 label_issue_updated: Issue updated
508 508 label_issue_note_added: Note added
509 509 label_issue_status_updated: Status updated
510 510 label_issue_assigned_to_updated: Assignee updated
511 511 label_issue_priority_updated: Priority updated
512 512 label_document: Document
513 513 label_document_new: New document
514 514 label_document_plural: Documents
515 515 label_document_added: Document added
516 516 label_role: Role
517 517 label_role_plural: Roles
518 518 label_role_new: New role
519 519 label_role_and_permissions: Roles and permissions
520 520 label_role_anonymous: Anonymous
521 521 label_role_non_member: Non member
522 522 label_member: Member
523 523 label_member_new: New member
524 524 label_member_plural: Members
525 525 label_tracker: Tracker
526 526 label_tracker_plural: Trackers
527 527 label_tracker_new: New tracker
528 528 label_workflow: Workflow
529 529 label_issue_status: Issue status
530 530 label_issue_status_plural: Issue statuses
531 531 label_issue_status_new: New status
532 532 label_issue_category: Issue category
533 533 label_issue_category_plural: Issue categories
534 534 label_issue_category_new: New category
535 535 label_custom_field: Custom field
536 536 label_custom_field_plural: Custom fields
537 537 label_custom_field_new: New custom field
538 538 label_enumerations: Enumerations
539 539 label_enumeration_new: New value
540 540 label_information: Information
541 541 label_information_plural: Information
542 542 label_please_login: Please log in
543 543 label_register: Register
544 544 label_login_with_open_id_option: or login with OpenID
545 545 label_password_lost: Lost password
546 546 label_home: Home
547 547 label_my_page: My page
548 548 label_my_account: My account
549 549 label_my_projects: My projects
550 550 label_my_page_block: My page block
551 551 label_administration: Administration
552 552 label_login: Sign in
553 553 label_logout: Sign out
554 554 label_help: Help
555 555 label_reported_issues: Reported issues
556 556 label_assigned_to_me_issues: Issues assigned to me
557 557 label_last_login: Last connection
558 558 label_registered_on: Registered on
559 559 label_activity: Activity
560 560 label_overall_activity: Overall activity
561 561 label_user_activity: "%{value}'s activity"
562 562 label_new: New
563 563 label_logged_as: Logged in as
564 564 label_environment: Environment
565 565 label_authentication: Authentication
566 566 label_auth_source: Authentication mode
567 567 label_auth_source_new: New authentication mode
568 568 label_auth_source_plural: Authentication modes
569 569 label_subproject_plural: Subprojects
570 570 label_subproject_new: New subproject
571 571 label_and_its_subprojects: "%{value} and its subprojects"
572 572 label_min_max_length: Min - Max length
573 573 label_list: List
574 574 label_date: Date
575 575 label_integer: Integer
576 576 label_float: Float
577 577 label_boolean: Boolean
578 578 label_string: Text
579 579 label_text: Long text
580 580 label_attribute: Attribute
581 581 label_attribute_plural: Attributes
582 582 label_no_data: No data to display
583 583 label_change_status: Change status
584 584 label_history: History
585 585 label_attachment: File
586 586 label_attachment_new: New file
587 587 label_attachment_delete: Delete file
588 588 label_attachment_plural: Files
589 589 label_file_added: File added
590 590 label_report: Report
591 591 label_report_plural: Reports
592 592 label_news: News
593 593 label_news_new: Add news
594 594 label_news_plural: News
595 595 label_news_latest: Latest news
596 596 label_news_view_all: View all news
597 597 label_news_added: News added
598 598 label_news_comment_added: Comment added to a news
599 599 label_settings: Settings
600 600 label_overview: Overview
601 601 label_version: Version
602 602 label_version_new: New version
603 603 label_version_plural: Versions
604 604 label_close_versions: Close completed versions
605 605 label_confirmation: Confirmation
606 606 label_export_to: 'Also available in:'
607 607 label_read: Read...
608 608 label_public_projects: Public projects
609 609 label_open_issues: open
610 610 label_open_issues_plural: open
611 611 label_closed_issues: closed
612 612 label_closed_issues_plural: closed
613 613 label_x_open_issues_abbr_on_total:
614 614 zero: 0 open / %{total}
615 615 one: 1 open / %{total}
616 616 other: "%{count} open / %{total}"
617 617 label_x_open_issues_abbr:
618 618 zero: 0 open
619 619 one: 1 open
620 620 other: "%{count} open"
621 621 label_x_closed_issues_abbr:
622 622 zero: 0 closed
623 623 one: 1 closed
624 624 other: "%{count} closed"
625 625 label_x_issues:
626 626 zero: 0 issues
627 627 one: 1 issue
628 628 other: "%{count} issues"
629 629 label_total: Total
630 630 label_total_time: Total time
631 631 label_permissions: Permissions
632 632 label_current_status: Current status
633 633 label_new_statuses_allowed: New statuses allowed
634 634 label_all: all
635 635 label_any: any
636 636 label_none: none
637 637 label_nobody: nobody
638 638 label_next: Next
639 639 label_previous: Previous
640 640 label_used_by: Used by
641 641 label_details: Details
642 642 label_add_note: Add a note
643 643 label_per_page: Per page
644 644 label_calendar: Calendar
645 645 label_months_from: months from
646 646 label_gantt: Gantt
647 647 label_internal: Internal
648 648 label_last_changes: "last %{count} changes"
649 649 label_change_view_all: View all changes
650 650 label_personalize_page: Personalize this page
651 651 label_comment: Comment
652 652 label_comment_plural: Comments
653 653 label_x_comments:
654 654 zero: no comments
655 655 one: 1 comment
656 656 other: "%{count} comments"
657 657 label_comment_add: Add a comment
658 658 label_comment_added: Comment added
659 659 label_comment_delete: Delete comments
660 660 label_query: Custom query
661 661 label_query_plural: Custom queries
662 662 label_query_new: New query
663 663 label_my_queries: My custom queries
664 664 label_filter_add: Add filter
665 665 label_filter_plural: Filters
666 666 label_equals: is
667 667 label_not_equals: is not
668 668 label_in_less_than: in less than
669 669 label_in_more_than: in more than
670 670 label_in_the_next_days: in the next
671 671 label_in_the_past_days: in the past
672 672 label_greater_or_equal: '>='
673 673 label_less_or_equal: '<='
674 674 label_between: between
675 675 label_in: in
676 676 label_today: today
677 677 label_all_time: all time
678 678 label_yesterday: yesterday
679 679 label_this_week: this week
680 680 label_last_week: last week
681 681 label_last_n_weeks: "last %{count} weeks"
682 682 label_last_n_days: "last %{count} days"
683 683 label_this_month: this month
684 684 label_last_month: last month
685 685 label_this_year: this year
686 686 label_date_range: Date range
687 687 label_less_than_ago: less than days ago
688 688 label_more_than_ago: more than days ago
689 689 label_ago: days ago
690 690 label_contains: contains
691 691 label_not_contains: doesn't contain
692 692 label_any_issues_in_project: any issues in project
693 693 label_any_issues_not_in_project: any issues not in project
694 694 label_no_issues_in_project: no issues in project
695 695 label_day_plural: days
696 696 label_repository: Repository
697 697 label_repository_new: New repository
698 698 label_repository_plural: Repositories
699 699 label_browse: Browse
700 700 label_branch: Branch
701 701 label_tag: Tag
702 702 label_revision: Revision
703 703 label_revision_plural: Revisions
704 704 label_revision_id: "Revision %{value}"
705 705 label_associated_revisions: Associated revisions
706 706 label_added: added
707 707 label_modified: modified
708 708 label_copied: copied
709 709 label_renamed: renamed
710 710 label_deleted: deleted
711 711 label_latest_revision: Latest revision
712 712 label_latest_revision_plural: Latest revisions
713 713 label_view_revisions: View revisions
714 714 label_view_all_revisions: View all revisions
715 715 label_max_size: Maximum size
716 716 label_sort_highest: Move to top
717 717 label_sort_higher: Move up
718 718 label_sort_lower: Move down
719 719 label_sort_lowest: Move to bottom
720 720 label_roadmap: Roadmap
721 721 label_roadmap_due_in: "Due in %{value}"
722 722 label_roadmap_overdue: "%{value} late"
723 723 label_roadmap_no_issues: No issues for this version
724 724 label_search: Search
725 725 label_result_plural: Results
726 726 label_all_words: All words
727 727 label_wiki: Wiki
728 728 label_wiki_edit: Wiki edit
729 729 label_wiki_edit_plural: Wiki edits
730 730 label_wiki_page: Wiki page
731 731 label_wiki_page_plural: Wiki pages
732 732 label_index_by_title: Index by title
733 733 label_index_by_date: Index by date
734 734 label_current_version: Current version
735 735 label_preview: Preview
736 736 label_feed_plural: Feeds
737 737 label_changes_details: Details of all changes
738 738 label_issue_tracking: Issue tracking
739 739 label_spent_time: Spent time
740 740 label_overall_spent_time: Overall spent time
741 741 label_f_hour: "%{value} hour"
742 742 label_f_hour_plural: "%{value} hours"
743 743 label_time_tracking: Time tracking
744 744 label_change_plural: Changes
745 745 label_statistics: Statistics
746 746 label_commits_per_month: Commits per month
747 747 label_commits_per_author: Commits per author
748 748 label_diff: diff
749 749 label_view_diff: View differences
750 750 label_diff_inline: inline
751 751 label_diff_side_by_side: side by side
752 752 label_options: Options
753 753 label_copy_workflow_from: Copy workflow from
754 754 label_permissions_report: Permissions report
755 755 label_watched_issues: Watched issues
756 756 label_related_issues: Related issues
757 757 label_applied_status: Applied status
758 758 label_loading: Loading...
759 759 label_relation_new: New relation
760 760 label_relation_delete: Delete relation
761 761 label_relates_to: Related to
762 762 label_duplicates: Duplicates
763 763 label_duplicated_by: Duplicated by
764 764 label_blocks: Blocks
765 765 label_blocked_by: Blocked by
766 766 label_precedes: Precedes
767 767 label_follows: Follows
768 768 label_copied_to: Copied to
769 769 label_copied_from: Copied from
770 770 label_end_to_start: end to start
771 771 label_end_to_end: end to end
772 772 label_start_to_start: start to start
773 773 label_start_to_end: start to end
774 774 label_stay_logged_in: Stay logged in
775 775 label_disabled: disabled
776 776 label_show_completed_versions: Show completed versions
777 777 label_me: me
778 778 label_board: Forum
779 779 label_board_new: New forum
780 780 label_board_plural: Forums
781 781 label_board_locked: Locked
782 782 label_board_sticky: Sticky
783 783 label_topic_plural: Topics
784 784 label_message_plural: Messages
785 785 label_message_last: Last message
786 786 label_message_new: New message
787 787 label_message_posted: Message added
788 788 label_reply_plural: Replies
789 789 label_send_information: Send account information to the user
790 790 label_year: Year
791 791 label_month: Month
792 792 label_week: Week
793 793 label_date_from: From
794 794 label_date_to: To
795 795 label_language_based: Based on user's language
796 796 label_sort_by: "Sort by %{value}"
797 797 label_send_test_email: Send a test email
798 798 label_feeds_access_key: Atom access key
799 799 label_missing_feeds_access_key: Missing a Atom access key
800 800 label_feeds_access_key_created_on: "Atom access key created %{value} ago"
801 801 label_module_plural: Modules
802 802 label_added_time_by: "Added by %{author} %{age} ago"
803 803 label_updated_time_by: "Updated by %{author} %{age} ago"
804 804 label_updated_time: "Updated %{value} ago"
805 805 label_jump_to_a_project: Jump to a project...
806 806 label_file_plural: Files
807 807 label_changeset_plural: Changesets
808 808 label_default_columns: Default columns
809 809 label_no_change_option: (No change)
810 810 label_bulk_edit_selected_issues: Bulk edit selected issues
811 811 label_bulk_edit_selected_time_entries: Bulk edit selected time entries
812 812 label_theme: Theme
813 813 label_default: Default
814 814 label_search_titles_only: Search titles only
815 815 label_user_mail_option_all: "For any event on all my projects"
816 816 label_user_mail_option_selected: "For any event on the selected projects only..."
817 817 label_user_mail_option_none: "No events"
818 818 label_user_mail_option_only_my_events: "Only for things I watch or I'm involved in"
819 819 label_user_mail_option_only_assigned: "Only for things I am assigned to"
820 820 label_user_mail_option_only_owner: "Only for things I am the owner of"
821 821 label_user_mail_no_self_notified: "I don't want to be notified of changes that I make myself"
822 822 label_registration_activation_by_email: account activation by email
823 823 label_registration_manual_activation: manual account activation
824 824 label_registration_automatic_activation: automatic account activation
825 825 label_display_per_page: "Per page: %{value}"
826 826 label_age: Age
827 827 label_change_properties: Change properties
828 828 label_general: General
829 829 label_more: More
830 830 label_scm: SCM
831 831 label_plugins: Plugins
832 832 label_ldap_authentication: LDAP authentication
833 833 label_downloads_abbr: D/L
834 834 label_optional_description: Optional description
835 835 label_add_another_file: Add another file
836 836 label_preferences: Preferences
837 837 label_chronological_order: In chronological order
838 838 label_reverse_chronological_order: In reverse chronological order
839 839 label_planning: Planning
840 840 label_incoming_emails: Incoming emails
841 841 label_generate_key: Generate a key
842 842 label_issue_watchers: Watchers
843 843 label_example: Example
844 844 label_display: Display
845 845 label_sort: Sort
846 846 label_ascending: Ascending
847 847 label_descending: Descending
848 848 label_date_from_to: From %{start} to %{end}
849 849 label_wiki_content_added: Wiki page added
850 850 label_wiki_content_updated: Wiki page updated
851 851 label_group: Group
852 852 label_group_plural: Groups
853 853 label_group_new: New group
854 label_group_anonymous: Anonymous users
855 label_group_non_member: Non member users
854 856 label_time_entry_plural: Spent time
855 857 label_version_sharing_none: Not shared
856 858 label_version_sharing_descendants: With subprojects
857 859 label_version_sharing_hierarchy: With project hierarchy
858 860 label_version_sharing_tree: With project tree
859 861 label_version_sharing_system: With all projects
860 862 label_update_issue_done_ratios: Update issue done ratios
861 863 label_copy_source: Source
862 864 label_copy_target: Target
863 865 label_copy_same_as_target: Same as target
864 866 label_display_used_statuses_only: Only display statuses that are used by this tracker
865 867 label_api_access_key: API access key
866 868 label_missing_api_access_key: Missing an API access key
867 869 label_api_access_key_created_on: "API access key created %{value} ago"
868 870 label_profile: Profile
869 871 label_subtask_plural: Subtasks
870 872 label_project_copy_notifications: Send email notifications during the project copy
871 873 label_principal_search: "Search for user or group:"
872 874 label_user_search: "Search for user:"
873 875 label_additional_workflow_transitions_for_author: Additional transitions allowed when the user is the author
874 876 label_additional_workflow_transitions_for_assignee: Additional transitions allowed when the user is the assignee
875 877 label_issues_visibility_all: All issues
876 878 label_issues_visibility_public: All non private issues
877 879 label_issues_visibility_own: Issues created by or assigned to the user
878 880 label_git_report_last_commit: Report last commit for files and directories
879 881 label_parent_revision: Parent
880 882 label_child_revision: Child
881 883 label_export_options: "%{export_format} export options"
882 884 label_copy_attachments: Copy attachments
883 885 label_copy_subtasks: Copy subtasks
884 886 label_item_position: "%{position} of %{count}"
885 887 label_completed_versions: Completed versions
886 888 label_search_for_watchers: Search for watchers to add
887 889 label_session_expiration: Session expiration
888 890 label_show_closed_projects: View closed projects
889 891 label_status_transitions: Status transitions
890 892 label_fields_permissions: Fields permissions
891 893 label_readonly: Read-only
892 894 label_required: Required
893 895 label_hidden: Hidden
894 896 label_attribute_of_project: "Project's %{name}"
895 897 label_attribute_of_issue: "Issue's %{name}"
896 898 label_attribute_of_author: "Author's %{name}"
897 899 label_attribute_of_assigned_to: "Assignee's %{name}"
898 900 label_attribute_of_user: "User's %{name}"
899 901 label_attribute_of_fixed_version: "Target version's %{name}"
900 902 label_cross_project_descendants: With subprojects
901 903 label_cross_project_tree: With project tree
902 904 label_cross_project_hierarchy: With project hierarchy
903 905 label_cross_project_system: With all projects
904 906 label_gantt_progress_line: Progress line
905 907 label_visibility_private: to me only
906 908 label_visibility_roles: to these roles only
907 909 label_visibility_public: to any users
908 910 label_link: Link
909 911 label_only: only
910 912 label_drop_down_list: drop-down list
911 913 label_checkboxes: checkboxes
912 914 label_radio_buttons: radio buttons
913 915 label_link_values_to: Link values to URL
914 916 label_custom_field_select_type: Select the type of object to which the custom field is to be attached
915 917 label_check_for_updates: Check for updates
916 918 label_latest_compatible_version: Latest compatible version
917 919 label_unknown_plugin: Unknown plugin
918 920
919 921 button_login: Login
920 922 button_submit: Submit
921 923 button_save: Save
922 924 button_check_all: Check all
923 925 button_uncheck_all: Uncheck all
924 926 button_collapse_all: Collapse all
925 927 button_expand_all: Expand all
926 928 button_delete: Delete
927 929 button_create: Create
928 930 button_create_and_continue: Create and continue
929 931 button_test: Test
930 932 button_edit: Edit
931 933 button_edit_associated_wikipage: "Edit associated Wiki page: %{page_title}"
932 934 button_add: Add
933 935 button_change: Change
934 936 button_apply: Apply
935 937 button_clear: Clear
936 938 button_lock: Lock
937 939 button_unlock: Unlock
938 940 button_download: Download
939 941 button_list: List
940 942 button_view: View
941 943 button_move: Move
942 944 button_move_and_follow: Move and follow
943 945 button_back: Back
944 946 button_cancel: Cancel
945 947 button_activate: Activate
946 948 button_sort: Sort
947 949 button_log_time: Log time
948 950 button_rollback: Rollback to this version
949 951 button_watch: Watch
950 952 button_unwatch: Unwatch
951 953 button_reply: Reply
952 954 button_archive: Archive
953 955 button_unarchive: Unarchive
954 956 button_reset: Reset
955 957 button_rename: Rename
956 958 button_change_password: Change password
957 959 button_copy: Copy
958 960 button_copy_and_follow: Copy and follow
959 961 button_annotate: Annotate
960 962 button_update: Update
961 963 button_configure: Configure
962 964 button_quote: Quote
963 965 button_duplicate: Duplicate
964 966 button_show: Show
965 967 button_hide: Hide
966 968 button_edit_section: Edit this section
967 969 button_export: Export
968 970 button_delete_my_account: Delete my account
969 971 button_close: Close
970 972 button_reopen: Reopen
971 973
972 974 status_active: active
973 975 status_registered: registered
974 976 status_locked: locked
975 977
976 978 project_status_active: active
977 979 project_status_closed: closed
978 980 project_status_archived: archived
979 981
980 982 version_status_open: open
981 983 version_status_locked: locked
982 984 version_status_closed: closed
983 985
984 986 field_active: Active
985 987
986 988 text_select_mail_notifications: Select actions for which email notifications should be sent.
987 989 text_regexp_info: eg. ^[A-Z0-9]+$
988 990 text_min_max_length_info: 0 means no restriction
989 991 text_project_destroy_confirmation: Are you sure you want to delete this project and related data?
990 992 text_subprojects_destroy_warning: "Its subproject(s): %{value} will be also deleted."
991 993 text_workflow_edit: Select a role and a tracker to edit the workflow
992 994 text_are_you_sure: Are you sure?
993 995 text_journal_changed: "%{label} changed from %{old} to %{new}"
994 996 text_journal_changed_no_detail: "%{label} updated"
995 997 text_journal_set_to: "%{label} set to %{value}"
996 998 text_journal_deleted: "%{label} deleted (%{old})"
997 999 text_journal_added: "%{label} %{value} added"
998 1000 text_tip_issue_begin_day: issue beginning this day
999 1001 text_tip_issue_end_day: issue ending this day
1000 1002 text_tip_issue_begin_end_day: issue beginning and ending this day
1001 1003 text_project_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed, must start with a lower case letter.<br />Once saved, the identifier cannot be changed.'
1002 1004 text_caracters_maximum: "%{count} characters maximum."
1003 1005 text_caracters_minimum: "Must be at least %{count} characters long."
1004 1006 text_length_between: "Length between %{min} and %{max} characters."
1005 1007 text_tracker_no_workflow: No workflow defined for this tracker
1006 1008 text_unallowed_characters: Unallowed characters
1007 1009 text_comma_separated: Multiple values allowed (comma separated).
1008 1010 text_line_separated: Multiple values allowed (one line for each value).
1009 1011 text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
1010 1012 text_issue_added: "Issue %{id} has been reported by %{author}."
1011 1013 text_issue_updated: "Issue %{id} has been updated by %{author}."
1012 1014 text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content?
1013 1015 text_issue_category_destroy_question: "Some issues (%{count}) are assigned to this category. What do you want to do?"
1014 1016 text_issue_category_destroy_assignments: Remove category assignments
1015 1017 text_issue_category_reassign_to: Reassign issues to this category
1016 1018 text_user_mail_option: "For unselected projects, you will only receive notifications about things you watch or you're involved in (eg. issues you're the author or assignee)."
1017 1019 text_no_configuration_data: "Roles, trackers, issue statuses and workflow have not been configured yet.\nIt is highly recommended to load the default configuration. You will be able to modify it once loaded."
1018 1020 text_load_default_configuration: Load the default configuration
1019 1021 text_status_changed_by_changeset: "Applied in changeset %{value}."
1020 1022 text_time_logged_by_changeset: "Applied in changeset %{value}."
1021 1023 text_issues_destroy_confirmation: 'Are you sure you want to delete the selected issue(s)?'
1022 1024 text_issues_destroy_descendants_confirmation: "This will also delete %{count} subtask(s)."
1023 1025 text_time_entries_destroy_confirmation: 'Are you sure you want to delete the selected time entr(y/ies)?'
1024 1026 text_select_project_modules: 'Select modules to enable for this project:'
1025 1027 text_default_administrator_account_changed: Default administrator account changed
1026 1028 text_file_repository_writable: Attachments directory writable
1027 1029 text_plugin_assets_writable: Plugin assets directory writable
1028 1030 text_rmagick_available: RMagick available (optional)
1029 1031 text_convert_available: ImageMagick convert available (optional)
1030 1032 text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?"
1031 1033 text_destroy_time_entries: Delete reported hours
1032 1034 text_assign_time_entries_to_project: Assign reported hours to the project
1033 1035 text_reassign_time_entries: 'Reassign reported hours to this issue:'
1034 1036 text_user_wrote: "%{value} wrote:"
1035 1037 text_enumeration_destroy_question: "%{count} objects are assigned to this value."
1036 1038 text_enumeration_category_reassign_to: 'Reassign them to this value:'
1037 1039 text_email_delivery_not_configured: "Email delivery is not configured, and notifications are disabled.\nConfigure your SMTP server in config/configuration.yml and restart the application to enable them."
1038 1040 text_repository_usernames_mapping: "Select or update the Redmine user mapped to each username found in the repository log.\nUsers with the same Redmine and repository username or email are automatically mapped."
1039 1041 text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.'
1040 1042 text_custom_field_possible_values_info: 'One line for each value'
1041 1043 text_wiki_page_destroy_question: "This page has %{descendants} child page(s) and descendant(s). What do you want to do?"
1042 1044 text_wiki_page_nullify_children: "Keep child pages as root pages"
1043 1045 text_wiki_page_destroy_children: "Delete child pages and all their descendants"
1044 1046 text_wiki_page_reassign_children: "Reassign child pages to this parent page"
1045 1047 text_own_membership_delete_confirmation: "You are about to remove some or all of your permissions and may no longer be able to edit this project after that.\nAre you sure you want to continue?"
1046 1048 text_zoom_in: Zoom in
1047 1049 text_zoom_out: Zoom out
1048 1050 text_warn_on_leaving_unsaved: "The current page contains unsaved text that will be lost if you leave this page."
1049 1051 text_scm_path_encoding_note: "Default: UTF-8"
1050 1052 text_git_repository_note: Repository is bare and local (e.g. /gitrepo, c:\gitrepo)
1051 1053 text_mercurial_repository_note: Local repository (e.g. /hgrepo, c:\hgrepo)
1052 1054 text_scm_command: Command
1053 1055 text_scm_command_version: Version
1054 1056 text_scm_config: You can configure your SCM commands in config/configuration.yml. Please restart the application after editing it.
1055 1057 text_scm_command_not_available: SCM command is not available. Please check settings on the administration panel.
1056 1058 text_issue_conflict_resolution_overwrite: "Apply my changes anyway (previous notes will be kept but some changes may be overwritten)"
1057 1059 text_issue_conflict_resolution_add_notes: "Add my notes and discard my other changes"
1058 1060 text_issue_conflict_resolution_cancel: "Discard all my changes and redisplay %{link}"
1059 1061 text_account_destroy_confirmation: "Are you sure you want to proceed?\nYour account will be permanently deleted, with no way to reactivate it."
1060 1062 text_session_expiration_settings: "Warning: changing these settings may expire the current sessions including yours."
1061 1063 text_project_closed: This project is closed and read-only.
1062 1064 text_turning_multiple_off: "If you disable multiple values, multiple values will be removed in order to preserve only one value per item."
1063 1065
1064 1066 default_role_manager: Manager
1065 1067 default_role_developer: Developer
1066 1068 default_role_reporter: Reporter
1067 1069 default_tracker_bug: Bug
1068 1070 default_tracker_feature: Feature
1069 1071 default_tracker_support: Support
1070 1072 default_issue_status_new: New
1071 1073 default_issue_status_in_progress: In Progress
1072 1074 default_issue_status_resolved: Resolved
1073 1075 default_issue_status_feedback: Feedback
1074 1076 default_issue_status_closed: Closed
1075 1077 default_issue_status_rejected: Rejected
1076 1078 default_doc_category_user: User documentation
1077 1079 default_doc_category_tech: Technical documentation
1078 1080 default_priority_low: Low
1079 1081 default_priority_normal: Normal
1080 1082 default_priority_high: High
1081 1083 default_priority_urgent: Urgent
1082 1084 default_priority_immediate: Immediate
1083 1085 default_activity_design: Design
1084 1086 default_activity_development: Development
1085 1087
1086 1088 enumeration_issue_priorities: Issue priorities
1087 1089 enumeration_doc_categories: Document categories
1088 1090 enumeration_activities: Activities (time tracking)
1089 1091 enumeration_system_activity: System Activity
1090 1092 description_filter: Filter
1091 1093 description_search: Searchfield
1092 1094 description_choose_project: Projects
1093 1095 description_project_scope: Search scope
1094 1096 description_notes: Notes
1095 1097 description_message_content: Message content
1096 1098 description_query_sort_criteria_attribute: Sort attribute
1097 1099 description_query_sort_criteria_direction: Sort direction
1098 1100 description_user_mail_notification: Mail notification settings
1099 1101 description_available_columns: Available Columns
1100 1102 description_selected_columns: Selected Columns
1101 1103 description_all_columns: All Columns
1102 1104 description_issue_category_reassign: Choose issue category
1103 1105 description_wiki_subpages_reassign: Choose new parent page
1104 1106 description_date_range_list: Choose range from list
1105 1107 description_date_range_interval: Choose range by selecting start and end date
1106 1108 description_date_from: Enter start date
1107 1109 description_date_to: Enter end date
1108 1110 text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
@@ -1,1128 +1,1130
1 1 # French translations for Ruby on Rails
2 2 # by Christian Lescuyer (christian@flyingcoders.com)
3 3 # contributor: Sebastien Grosjean - ZenCocoon.com
4 4 # contributor: Thibaut Cuvelier - Developpez.com
5 5
6 6 fr:
7 7 direction: ltr
8 8 date:
9 9 formats:
10 10 default: "%d/%m/%Y"
11 11 short: "%e %b"
12 12 long: "%e %B %Y"
13 13 long_ordinal: "%e %B %Y"
14 14 only_day: "%e"
15 15
16 16 day_names: [dimanche, lundi, mardi, mercredi, jeudi, vendredi, samedi]
17 17 abbr_day_names: [dim, lun, mar, mer, jeu, ven, sam]
18 18
19 19 # Don't forget the nil at the beginning; there's no such thing as a 0th month
20 20 month_names: [~, janvier, février, mars, avril, mai, juin, juillet, août, septembre, octobre, novembre, décembre]
21 21 abbr_month_names: [~, jan., fév., mar., avr., mai, juin, juil., août, sept., oct., nov., déc.]
22 22 # Used in date_select and datime_select.
23 23 order:
24 24 - :day
25 25 - :month
26 26 - :year
27 27
28 28 time:
29 29 formats:
30 30 default: "%d/%m/%Y %H:%M"
31 31 time: "%H:%M"
32 32 short: "%d %b %H:%M"
33 33 long: "%A %d %B %Y %H:%M:%S %Z"
34 34 long_ordinal: "%A %d %B %Y %H:%M:%S %Z"
35 35 only_second: "%S"
36 36 am: 'am'
37 37 pm: 'pm'
38 38
39 39 datetime:
40 40 distance_in_words:
41 41 half_a_minute: "30 secondes"
42 42 less_than_x_seconds:
43 43 zero: "moins d'une seconde"
44 44 one: "moins d'une seconde"
45 45 other: "moins de %{count} secondes"
46 46 x_seconds:
47 47 one: "1 seconde"
48 48 other: "%{count} secondes"
49 49 less_than_x_minutes:
50 50 zero: "moins d'une minute"
51 51 one: "moins d'une minute"
52 52 other: "moins de %{count} minutes"
53 53 x_minutes:
54 54 one: "1 minute"
55 55 other: "%{count} minutes"
56 56 about_x_hours:
57 57 one: "environ une heure"
58 58 other: "environ %{count} heures"
59 59 x_hours:
60 60 one: "une heure"
61 61 other: "%{count} heures"
62 62 x_days:
63 63 one: "un jour"
64 64 other: "%{count} jours"
65 65 about_x_months:
66 66 one: "environ un mois"
67 67 other: "environ %{count} mois"
68 68 x_months:
69 69 one: "un mois"
70 70 other: "%{count} mois"
71 71 about_x_years:
72 72 one: "environ un an"
73 73 other: "environ %{count} ans"
74 74 over_x_years:
75 75 one: "plus d'un an"
76 76 other: "plus de %{count} ans"
77 77 almost_x_years:
78 78 one: "presqu'un an"
79 79 other: "presque %{count} ans"
80 80 prompts:
81 81 year: "Année"
82 82 month: "Mois"
83 83 day: "Jour"
84 84 hour: "Heure"
85 85 minute: "Minute"
86 86 second: "Seconde"
87 87
88 88 number:
89 89 format:
90 90 precision: 3
91 91 separator: ','
92 92 delimiter: ' '
93 93 currency:
94 94 format:
95 95 unit: '€'
96 96 precision: 2
97 97 format: '%n %u'
98 98 human:
99 99 format:
100 100 precision: 3
101 101 storage_units:
102 102 format: "%n %u"
103 103 units:
104 104 byte:
105 105 one: "octet"
106 106 other: "octets"
107 107 kb: "ko"
108 108 mb: "Mo"
109 109 gb: "Go"
110 110 tb: "To"
111 111
112 112 support:
113 113 array:
114 114 sentence_connector: 'et'
115 115 skip_last_comma: true
116 116 word_connector: ", "
117 117 two_words_connector: " et "
118 118 last_word_connector: " et "
119 119
120 120 activerecord:
121 121 errors:
122 122 template:
123 123 header:
124 124 one: "Impossible d'enregistrer %{model} : une erreur"
125 125 other: "Impossible d'enregistrer %{model} : %{count} erreurs."
126 126 body: "Veuillez vérifier les champs suivants :"
127 127 messages:
128 128 inclusion: "n'est pas inclus(e) dans la liste"
129 129 exclusion: "n'est pas disponible"
130 130 invalid: "n'est pas valide"
131 131 confirmation: "ne concorde pas avec la confirmation"
132 132 accepted: "doit être accepté(e)"
133 133 empty: "doit être renseigné(e)"
134 134 blank: "doit être renseigné(e)"
135 135 too_long: "est trop long (pas plus de %{count} caractères)"
136 136 too_short: "est trop court (au moins %{count} caractères)"
137 137 wrong_length: "ne fait pas la bonne longueur (doit comporter %{count} caractères)"
138 138 taken: "est déjà utilisé"
139 139 not_a_number: "n'est pas un nombre"
140 140 not_a_date: "n'est pas une date valide"
141 141 greater_than: "doit être supérieur à %{count}"
142 142 greater_than_or_equal_to: "doit être supérieur ou égal à %{count}"
143 143 equal_to: "doit être égal à %{count}"
144 144 less_than: "doit être inférieur à %{count}"
145 145 less_than_or_equal_to: "doit être inférieur ou égal à %{count}"
146 146 odd: "doit être impair"
147 147 even: "doit être pair"
148 148 greater_than_start_date: "doit être postérieure à la date de début"
149 149 not_same_project: "n'appartient pas au même projet"
150 150 circular_dependency: "Cette relation créerait une dépendance circulaire"
151 151 cant_link_an_issue_with_a_descendant: "Une demande ne peut pas être liée à l'une de ses sous-tâches"
152 152 earlier_than_minimum_start_date: "ne peut pas être antérieure au %{date} à cause des demandes qui précèdent"
153 153
154 154 actionview_instancetag_blank_option: Choisir
155 155
156 156 general_text_No: 'Non'
157 157 general_text_Yes: 'Oui'
158 158 general_text_no: 'non'
159 159 general_text_yes: 'oui'
160 160 general_lang_name: 'Français'
161 161 general_csv_separator: ';'
162 162 general_csv_decimal_separator: ','
163 163 general_csv_encoding: ISO-8859-1
164 164 general_first_day_of_week: '1'
165 165
166 166 notice_account_updated: Le compte a été mis à jour avec succès.
167 167 notice_account_invalid_creditentials: Identifiant ou mot de passe invalide.
168 168 notice_account_password_updated: Mot de passe mis à jour avec succès.
169 169 notice_account_wrong_password: Mot de passe incorrect
170 170 notice_account_register_done: Un message contenant les instructions pour activer votre compte vous a été envoyé à l'adresse %{email}.
171 171 notice_account_unknown_email: Aucun compte ne correspond à cette adresse.
172 172 notice_account_not_activated_yet: Vous n'avez pas encore activé votre compte. Si vous voulez recevoir un nouveau message d'activation, veuillez <a href="%{url}">cliquer sur ce lien</a>.
173 173 notice_account_locked: Votre compte est verrouillé.
174 174 notice_can_t_change_password: Ce compte utilise une authentification externe. Impossible de changer le mot de passe.
175 175 notice_account_lost_email_sent: Un message contenant les instructions pour choisir un nouveau mot de passe vous a été envoyé.
176 176 notice_account_activated: Votre compte a été activé. Vous pouvez à présent vous connecter.
177 177 notice_successful_create: Création effectuée avec succès.
178 178 notice_successful_update: Mise à jour effectuée avec succès.
179 179 notice_successful_delete: Suppression effectuée avec succès.
180 180 notice_successful_connection: Connexion réussie.
181 181 notice_file_not_found: "La page à laquelle vous souhaitez accéder n'existe pas ou a été supprimée."
182 182 notice_locking_conflict: Les données ont été mises à jour par un autre utilisateur. Mise à jour impossible.
183 183 notice_not_authorized: "Vous n'êtes pas autorisé à accéder à cette page."
184 184 notice_not_authorized_archived_project: Le projet auquel vous tentez d'accéder a été archivé.
185 185 notice_email_sent: "Un email a été envoyé à %{value}"
186 186 notice_email_error: "Erreur lors de l'envoi de l'email (%{value})"
187 187 notice_feeds_access_key_reseted: "Votre clé d'accès aux flux Atom a été réinitialisée."
188 188 notice_api_access_key_reseted: Votre clé d'accès API a été réinitialisée.
189 189 notice_failed_to_save_issues: "%{count} demande(s) sur les %{total} sélectionnées n'ont pas pu être mise(s) à jour : %{ids}."
190 190 notice_failed_to_save_time_entries: "%{count} temps passé(s) sur les %{total} sélectionnés n'ont pas pu être mis à jour: %{ids}."
191 191 notice_failed_to_save_members: "Erreur lors de la sauvegarde des membres: %{errors}."
192 192 notice_no_issue_selected: "Aucune demande sélectionnée ! Cochez les demandes que vous voulez mettre à jour."
193 193 notice_account_pending: "Votre compte a été créé et attend l'approbation de l'administrateur."
194 194 notice_default_data_loaded: Paramétrage par défaut chargé avec succès.
195 195 notice_unable_delete_version: Impossible de supprimer cette version.
196 196 notice_unable_delete_time_entry: Impossible de supprimer le temps passé.
197 197 notice_issue_done_ratios_updated: L'avancement des demandes a été mis à jour.
198 198 notice_gantt_chart_truncated: "Le diagramme a été tronqué car il excède le nombre maximal d'éléments pouvant être affichés (%{max})"
199 199 notice_issue_successful_create: "Demande %{id} créée."
200 200 notice_issue_update_conflict: "La demande a été mise à jour par un autre utilisateur pendant que vous la modifiez."
201 201 notice_account_deleted: "Votre compte a été définitivement supprimé."
202 202 notice_user_successful_create: "Utilisateur %{id} créé."
203 203 notice_new_password_must_be_different: Votre nouveau mot de passe doit être différent de votre mot de passe actuel
204 204
205 205 error_can_t_load_default_data: "Une erreur s'est produite lors du chargement du paramétrage : %{value}"
206 206 error_scm_not_found: "L'entrée et/ou la révision demandée n'existe pas dans le dépôt."
207 207 error_scm_command_failed: "Une erreur s'est produite lors de l'accès au dépôt : %{value}"
208 208 error_scm_annotate: "L'entrée n'existe pas ou ne peut pas être annotée."
209 209 error_scm_annotate_big_text_file: Cette entrée ne peut pas être annotée car elle excède la taille maximale.
210 210 error_issue_not_found_in_project: "La demande n'existe pas ou n'appartient pas à ce projet"
211 211 error_no_tracker_in_project: "Aucun tracker n'est associé à ce projet. Vérifier la configuration du projet."
212 212 error_no_default_issue_status: "Aucun statut de demande n'est défini par défaut. Vérifier votre configuration (Administration -> Statuts de demandes)."
213 213 error_can_not_delete_custom_field: Impossible de supprimer le champ personnalisé
214 214 error_can_not_delete_tracker: Ce tracker contient des demandes et ne peut pas être supprimé.
215 215 error_can_not_remove_role: Ce rôle est utilisé et ne peut pas être supprimé.
216 216 error_can_not_reopen_issue_on_closed_version: 'Une demande assignée à une version fermée ne peut pas être réouverte'
217 217 error_can_not_archive_project: "Ce projet ne peut pas être archivé"
218 218 error_issue_done_ratios_not_updated: L'avancement des demandes n'a pas pu être mis à jour.
219 219 error_workflow_copy_source: 'Veuillez sélectionner un tracker et/ou un rôle source'
220 220 error_workflow_copy_target: 'Veuillez sélectionner les trackers et rôles cibles'
221 221 error_unable_delete_issue_status: Impossible de supprimer le statut de demande
222 222 error_unable_to_connect: Connexion impossible (%{value})
223 223 error_attachment_too_big: Ce fichier ne peut pas être attaché car il excède la taille maximale autorisée (%{max_size})
224 224 error_session_expired: "Votre session a expiré. Veuillez vous reconnecter."
225 225 warning_attachments_not_saved: "%{count} fichier(s) n'ont pas pu être sauvegardés."
226 226
227 227 mail_subject_lost_password: "Votre mot de passe %{value}"
228 228 mail_body_lost_password: 'Pour changer votre mot de passe, cliquez sur le lien suivant :'
229 229 mail_subject_register: "Activation de votre compte %{value}"
230 230 mail_body_register: 'Pour activer votre compte, cliquez sur le lien suivant :'
231 231 mail_body_account_information_external: "Vous pouvez utiliser votre compte %{value} pour vous connecter."
232 232 mail_body_account_information: Paramètres de connexion de votre compte
233 233 mail_subject_account_activation_request: "Demande d'activation d'un compte %{value}"
234 234 mail_body_account_activation_request: "Un nouvel utilisateur (%{value}) s'est inscrit. Son compte nécessite votre approbation :"
235 235 mail_subject_reminder: "%{count} demande(s) arrivent à échéance (%{days})"
236 236 mail_body_reminder: "%{count} demande(s) qui vous sont assignées arrivent à échéance dans les %{days} prochains jours :"
237 237 mail_subject_wiki_content_added: "Page wiki '%{id}' ajoutée"
238 238 mail_body_wiki_content_added: "La page wiki '%{id}' a été ajoutée par %{author}."
239 239 mail_subject_wiki_content_updated: "Page wiki '%{id}' mise à jour"
240 240 mail_body_wiki_content_updated: "La page wiki '%{id}' a été mise à jour par %{author}."
241 241
242 242 field_name: Nom
243 243 field_description: Description
244 244 field_summary: Résumé
245 245 field_is_required: Obligatoire
246 246 field_firstname: Prénom
247 247 field_lastname: Nom
248 248 field_mail: Email
249 249 field_filename: Fichier
250 250 field_filesize: Taille
251 251 field_downloads: Téléchargements
252 252 field_author: Auteur
253 253 field_created_on: Créé
254 254 field_updated_on: Mis-à-jour
255 255 field_closed_on: Fermé
256 256 field_field_format: Format
257 257 field_is_for_all: Pour tous les projets
258 258 field_possible_values: Valeurs possibles
259 259 field_regexp: Expression régulière
260 260 field_min_length: Longueur minimum
261 261 field_max_length: Longueur maximum
262 262 field_value: Valeur
263 263 field_category: Catégorie
264 264 field_title: Titre
265 265 field_project: Projet
266 266 field_issue: Demande
267 267 field_status: Statut
268 268 field_notes: Notes
269 269 field_is_closed: Demande fermée
270 270 field_is_default: Valeur par défaut
271 271 field_tracker: Tracker
272 272 field_subject: Sujet
273 273 field_due_date: Echéance
274 274 field_assigned_to: Assigné à
275 275 field_priority: Priorité
276 276 field_fixed_version: Version cible
277 277 field_user: Utilisateur
278 278 field_principal: Principal
279 279 field_role: Rôle
280 280 field_homepage: Site web
281 281 field_is_public: Public
282 282 field_parent: Sous-projet de
283 283 field_is_in_roadmap: Demandes affichées dans la roadmap
284 284 field_login: Identifiant
285 285 field_mail_notification: Notifications par mail
286 286 field_admin: Administrateur
287 287 field_last_login_on: Dernière connexion
288 288 field_language: Langue
289 289 field_effective_date: Date
290 290 field_password: Mot de passe
291 291 field_new_password: Nouveau mot de passe
292 292 field_password_confirmation: Confirmation
293 293 field_version: Version
294 294 field_type: Type
295 295 field_host: Hôte
296 296 field_port: Port
297 297 field_account: Compte
298 298 field_base_dn: Base DN
299 299 field_attr_login: Attribut Identifiant
300 300 field_attr_firstname: Attribut Prénom
301 301 field_attr_lastname: Attribut Nom
302 302 field_attr_mail: Attribut Email
303 303 field_onthefly: Création des utilisateurs à la volée
304 304 field_start_date: Début
305 305 field_done_ratio: "% réalisé"
306 306 field_auth_source: Mode d'authentification
307 307 field_hide_mail: Cacher mon adresse mail
308 308 field_comments: Commentaire
309 309 field_url: URL
310 310 field_start_page: Page de démarrage
311 311 field_subproject: Sous-projet
312 312 field_hours: Heures
313 313 field_activity: Activité
314 314 field_spent_on: Date
315 315 field_identifier: Identifiant
316 316 field_is_filter: Utilisé comme filtre
317 317 field_issue_to: Demande liée
318 318 field_delay: Retard
319 319 field_assignable: Demandes assignables à ce rôle
320 320 field_redirect_existing_links: Rediriger les liens existants
321 321 field_estimated_hours: Temps estimé
322 322 field_column_names: Colonnes
323 323 field_time_entries: Temps passé
324 324 field_time_zone: Fuseau horaire
325 325 field_searchable: Utilisé pour les recherches
326 326 field_default_value: Valeur par défaut
327 327 field_comments_sorting: Afficher les commentaires
328 328 field_parent_title: Page parent
329 329 field_editable: Modifiable
330 330 field_watcher: Observateur
331 331 field_identity_url: URL OpenID
332 332 field_content: Contenu
333 333 field_group_by: Grouper par
334 334 field_sharing: Partage
335 335 field_parent_issue: Tâche parente
336 336 field_member_of_group: Groupe de l'assigné
337 337 field_assigned_to_role: Rôle de l'assigné
338 338 field_text: Champ texte
339 339 field_visible: Visible
340 340 field_warn_on_leaving_unsaved: "M'avertir lorsque je quitte une page contenant du texte non sauvegardé"
341 341 field_issues_visibility: Visibilité des demandes
342 342 field_is_private: Privée
343 343 field_commit_logs_encoding: Encodage des messages de commit
344 344 field_scm_path_encoding: Encodage des chemins
345 345 field_path_to_repository: Chemin du dépôt
346 346 field_root_directory: Répertoire racine
347 347 field_cvsroot: CVSROOT
348 348 field_cvs_module: Module
349 349 field_repository_is_default: Dépôt principal
350 350 field_multiple: Valeurs multiples
351 351 field_auth_source_ldap_filter: Filtre LDAP
352 352 field_core_fields: Champs standards
353 353 field_timeout: "Timeout (en secondes)"
354 354 field_board_parent: Forum parent
355 355 field_private_notes: Notes privées
356 356 field_inherit_members: Hériter les membres
357 357 field_generate_password: Générer un mot de passe
358 358 field_must_change_passwd: Doit changer de mot de passe à la prochaine connexion
359 359
360 360 setting_app_title: Titre de l'application
361 361 setting_app_subtitle: Sous-titre de l'application
362 362 setting_welcome_text: Texte d'accueil
363 363 setting_default_language: Langue par défaut
364 364 setting_login_required: Authentification obligatoire
365 365 setting_self_registration: Inscription des nouveaux utilisateurs
366 366 setting_attachment_max_size: Taille maximale des fichiers
367 367 setting_issues_export_limit: Limite d'exportation des demandes
368 368 setting_mail_from: Adresse d'émission
369 369 setting_bcc_recipients: Destinataires en copie cachée (cci)
370 370 setting_plain_text_mail: Mail en texte brut (non HTML)
371 371 setting_host_name: Nom d'hôte et chemin
372 372 setting_text_formatting: Formatage du texte
373 373 setting_wiki_compression: Compression de l'historique des pages wiki
374 374 setting_feeds_limit: Nombre maximal d'éléments dans les flux Atom
375 375 setting_default_projects_public: Définir les nouveaux projets comme publics par défaut
376 376 setting_autofetch_changesets: Récupération automatique des commits
377 377 setting_sys_api_enabled: Activer les WS pour la gestion des dépôts
378 378 setting_commit_ref_keywords: Mots-clés de référencement
379 379 setting_commit_fix_keywords: Mots-clés de résolution
380 380 setting_autologin: Durée maximale de connexion automatique
381 381 setting_date_format: Format de date
382 382 setting_time_format: Format d'heure
383 383 setting_cross_project_issue_relations: Autoriser les relations entre demandes de différents projets
384 384 setting_cross_project_subtasks: Autoriser les sous-tâches dans des projets différents
385 385 setting_issue_list_default_columns: Colonnes affichées par défaut sur la liste des demandes
386 386 setting_repositories_encodings: Encodages des fichiers et des dépôts
387 387 setting_emails_header: En-tête des emails
388 388 setting_emails_footer: Pied-de-page des emails
389 389 setting_protocol: Protocole
390 390 setting_per_page_options: Options d'objets affichés par page
391 391 setting_user_format: Format d'affichage des utilisateurs
392 392 setting_activity_days_default: Nombre de jours affichés sur l'activité des projets
393 393 setting_display_subprojects_issues: Afficher par défaut les demandes des sous-projets sur les projets principaux
394 394 setting_enabled_scm: SCM activés
395 395 setting_mail_handler_body_delimiters: "Tronquer les emails après l'une de ces lignes"
396 396 setting_mail_handler_api_enabled: "Activer le WS pour la réception d'emails"
397 397 setting_mail_handler_api_key: Clé de protection de l'API
398 398 setting_sequential_project_identifiers: Générer des identifiants de projet séquentiels
399 399 setting_gravatar_enabled: Afficher les Gravatar des utilisateurs
400 400 setting_gravatar_default: Image Gravatar par défaut
401 401 setting_diff_max_lines_displayed: Nombre maximum de lignes de diff affichées
402 402 setting_file_max_size_displayed: Taille maximum des fichiers texte affichés en ligne
403 403 setting_repository_log_display_limit: "Nombre maximum de révisions affichées sur l'historique d'un fichier"
404 404 setting_openid: "Autoriser l'authentification et l'enregistrement OpenID"
405 405 setting_password_min_length: Longueur minimum des mots de passe
406 406 setting_new_project_user_role_id: Rôle donné à un utilisateur non-administrateur qui crée un projet
407 407 setting_default_projects_modules: Modules activés par défaut pour les nouveaux projets
408 408 setting_issue_done_ratio: Calcul de l'avancement des demandes
409 409 setting_issue_done_ratio_issue_field: 'Utiliser le champ % effectué'
410 410 setting_issue_done_ratio_issue_status: Utiliser le statut
411 411 setting_start_of_week: Jour de début des calendriers
412 412 setting_rest_api_enabled: Activer l'API REST
413 413 setting_cache_formatted_text: Mettre en cache le texte formaté
414 414 setting_default_notification_option: Option de notification par défaut
415 415 setting_commit_logtime_enabled: Permettre la saisie de temps
416 416 setting_commit_logtime_activity_id: Activité pour le temps saisi
417 417 setting_gantt_items_limit: Nombre maximum d'éléments affichés sur le gantt
418 418 setting_issue_group_assignment: Permettre l'assignement des demandes aux groupes
419 419 setting_default_issue_start_date_to_creation_date: Donner à la date de début d'une nouvelle demande la valeur de la date du jour
420 420 setting_commit_cross_project_ref: Permettre le référencement et la résolution des demandes de tous les autres projets
421 421 setting_unsubscribe: Permettre aux utilisateurs de supprimer leur propre compte
422 422 setting_session_lifetime: Durée de vie maximale des sessions
423 423 setting_session_timeout: Durée maximale d'inactivité
424 424 setting_thumbnails_enabled: Afficher les vignettes des images
425 425 setting_thumbnails_size: Taille des vignettes (en pixels)
426 426 setting_non_working_week_days: Jours non travaillés
427 427 setting_jsonp_enabled: Activer le support JSONP
428 428 setting_default_projects_tracker_ids: Trackers par défaut pour les nouveaux projets
429 429 setting_mail_handler_excluded_filenames: Exclure les fichiers attachés par leur nom
430 430 setting_force_default_language_for_anonymous: Forcer la langue par défault pour les utilisateurs anonymes
431 431 setting_force_default_language_for_loggedin: Forcer la langue par défault pour les utilisateurs identifiés
432 432
433 433 permission_add_project: Créer un projet
434 434 permission_add_subprojects: Créer des sous-projets
435 435 permission_edit_project: Modifier le projet
436 436 permission_close_project: Fermer / réouvrir le projet
437 437 permission_select_project_modules: Choisir les modules
438 438 permission_manage_members: Gérer les membres
439 439 permission_manage_project_activities: Gérer les activités
440 440 permission_manage_versions: Gérer les versions
441 441 permission_manage_categories: Gérer les catégories de demandes
442 442 permission_view_issues: Voir les demandes
443 443 permission_add_issues: Créer des demandes
444 444 permission_edit_issues: Modifier les demandes
445 445 permission_manage_issue_relations: Gérer les relations
446 446 permission_set_issues_private: Rendre les demandes publiques ou privées
447 447 permission_set_own_issues_private: Rendre ses propres demandes publiques ou privées
448 448 permission_add_issue_notes: Ajouter des notes
449 449 permission_edit_issue_notes: Modifier les notes
450 450 permission_edit_own_issue_notes: Modifier ses propres notes
451 451 permission_view_private_notes: Voir les notes privées
452 452 permission_set_notes_private: Rendre les notes privées
453 453 permission_move_issues: Déplacer les demandes
454 454 permission_delete_issues: Supprimer les demandes
455 455 permission_manage_public_queries: Gérer les requêtes publiques
456 456 permission_save_queries: Sauvegarder les requêtes
457 457 permission_view_gantt: Voir le gantt
458 458 permission_view_calendar: Voir le calendrier
459 459 permission_view_issue_watchers: Voir la liste des observateurs
460 460 permission_add_issue_watchers: Ajouter des observateurs
461 461 permission_delete_issue_watchers: Supprimer des observateurs
462 462 permission_log_time: Saisir le temps passé
463 463 permission_view_time_entries: Voir le temps passé
464 464 permission_edit_time_entries: Modifier les temps passés
465 465 permission_edit_own_time_entries: Modifier son propre temps passé
466 466 permission_manage_news: Gérer les annonces
467 467 permission_comment_news: Commenter les annonces
468 468 permission_view_documents: Voir les documents
469 469 permission_add_documents: Ajouter des documents
470 470 permission_edit_documents: Modifier les documents
471 471 permission_delete_documents: Supprimer les documents
472 472 permission_manage_files: Gérer les fichiers
473 473 permission_view_files: Voir les fichiers
474 474 permission_manage_wiki: Gérer le wiki
475 475 permission_rename_wiki_pages: Renommer les pages
476 476 permission_delete_wiki_pages: Supprimer les pages
477 477 permission_view_wiki_pages: Voir le wiki
478 478 permission_view_wiki_edits: "Voir l'historique des modifications"
479 479 permission_edit_wiki_pages: Modifier les pages
480 480 permission_delete_wiki_pages_attachments: Supprimer les fichiers joints
481 481 permission_protect_wiki_pages: Protéger les pages
482 482 permission_manage_repository: Gérer le dépôt de sources
483 483 permission_browse_repository: Parcourir les sources
484 484 permission_view_changesets: Voir les révisions
485 485 permission_commit_access: Droit de commit
486 486 permission_manage_boards: Gérer les forums
487 487 permission_view_messages: Voir les messages
488 488 permission_add_messages: Poster un message
489 489 permission_edit_messages: Modifier les messages
490 490 permission_edit_own_messages: Modifier ses propres messages
491 491 permission_delete_messages: Supprimer les messages
492 492 permission_delete_own_messages: Supprimer ses propres messages
493 493 permission_export_wiki_pages: Exporter les pages
494 494 permission_manage_subtasks: Gérer les sous-tâches
495 495 permission_manage_related_issues: Gérer les demandes associées
496 496
497 497 project_module_issue_tracking: Suivi des demandes
498 498 project_module_time_tracking: Suivi du temps passé
499 499 project_module_news: Publication d'annonces
500 500 project_module_documents: Publication de documents
501 501 project_module_files: Publication de fichiers
502 502 project_module_wiki: Wiki
503 503 project_module_repository: Dépôt de sources
504 504 project_module_boards: Forums de discussion
505 505 project_module_calendar: Calendrier
506 506 project_module_gantt: Gantt
507 507
508 508 label_user: Utilisateur
509 509 label_user_plural: Utilisateurs
510 510 label_user_new: Nouvel utilisateur
511 511 label_user_anonymous: Anonyme
512 512 label_project: Projet
513 513 label_project_new: Nouveau projet
514 514 label_project_plural: Projets
515 515 label_x_projects:
516 516 zero: aucun projet
517 517 one: un projet
518 518 other: "%{count} projets"
519 519 label_project_all: Tous les projets
520 520 label_project_latest: Derniers projets
521 521 label_issue: Demande
522 522 label_issue_new: Nouvelle demande
523 523 label_issue_plural: Demandes
524 524 label_issue_view_all: Voir toutes les demandes
525 525 label_issues_by: "Demandes par %{value}"
526 526 label_issue_added: Demande ajoutée
527 527 label_issue_updated: Demande mise à jour
528 528 label_issue_note_added: Note ajoutée
529 529 label_issue_status_updated: Statut changé
530 530 label_issue_assigned_to_updated: Assigné changé
531 531 label_issue_priority_updated: Priorité changée
532 532 label_document: Document
533 533 label_document_new: Nouveau document
534 534 label_document_plural: Documents
535 535 label_document_added: Document ajouté
536 536 label_role: Rôle
537 537 label_role_plural: Rôles
538 538 label_role_new: Nouveau rôle
539 539 label_role_and_permissions: Rôles et permissions
540 540 label_role_anonymous: Anonyme
541 541 label_role_non_member: Non membre
542 542 label_member: Membre
543 543 label_member_new: Nouveau membre
544 544 label_member_plural: Membres
545 545 label_tracker: Tracker
546 546 label_tracker_plural: Trackers
547 547 label_tracker_new: Nouveau tracker
548 548 label_workflow: Workflow
549 549 label_issue_status: Statut de demandes
550 550 label_issue_status_plural: Statuts de demandes
551 551 label_issue_status_new: Nouveau statut
552 552 label_issue_category: Catégorie de demandes
553 553 label_issue_category_plural: Catégories de demandes
554 554 label_issue_category_new: Nouvelle catégorie
555 555 label_custom_field: Champ personnalisé
556 556 label_custom_field_plural: Champs personnalisés
557 557 label_custom_field_new: Nouveau champ personnalisé
558 558 label_enumerations: Listes de valeurs
559 559 label_enumeration_new: Nouvelle valeur
560 560 label_information: Information
561 561 label_information_plural: Informations
562 562 label_please_login: Identification
563 563 label_register: S'enregistrer
564 564 label_login_with_open_id_option: S'authentifier avec OpenID
565 565 label_password_lost: Mot de passe perdu
566 566 label_home: Accueil
567 567 label_my_page: Ma page
568 568 label_my_account: Mon compte
569 569 label_my_projects: Mes projets
570 570 label_my_page_block: Blocs disponibles
571 571 label_administration: Administration
572 572 label_login: Connexion
573 573 label_logout: Déconnexion
574 574 label_help: Aide
575 575 label_reported_issues: Demandes soumises
576 576 label_assigned_to_me_issues: Demandes qui me sont assignées
577 577 label_last_login: Dernière connexion
578 578 label_registered_on: Inscrit le
579 579 label_activity: Activité
580 580 label_overall_activity: Activité globale
581 581 label_user_activity: "Activité de %{value}"
582 582 label_new: Nouveau
583 583 label_logged_as: Connecté en tant que
584 584 label_environment: Environnement
585 585 label_authentication: Authentification
586 586 label_auth_source: Mode d'authentification
587 587 label_auth_source_new: Nouveau mode d'authentification
588 588 label_auth_source_plural: Modes d'authentification
589 589 label_subproject_plural: Sous-projets
590 590 label_subproject_new: Nouveau sous-projet
591 591 label_and_its_subprojects: "%{value} et ses sous-projets"
592 592 label_min_max_length: Longueurs mini - maxi
593 593 label_list: Liste
594 594 label_date: Date
595 595 label_integer: Entier
596 596 label_float: Nombre décimal
597 597 label_boolean: Booléen
598 598 label_string: Texte
599 599 label_text: Texte long
600 600 label_attribute: Attribut
601 601 label_attribute_plural: Attributs
602 602 label_no_data: Aucune donnée à afficher
603 603 label_change_status: Changer le statut
604 604 label_history: Historique
605 605 label_attachment: Fichier
606 606 label_attachment_new: Nouveau fichier
607 607 label_attachment_delete: Supprimer le fichier
608 608 label_attachment_plural: Fichiers
609 609 label_file_added: Fichier ajouté
610 610 label_report: Rapport
611 611 label_report_plural: Rapports
612 612 label_news: Annonce
613 613 label_news_new: Nouvelle annonce
614 614 label_news_plural: Annonces
615 615 label_news_latest: Dernières annonces
616 616 label_news_view_all: Voir toutes les annonces
617 617 label_news_added: Annonce ajoutée
618 618 label_news_comment_added: Commentaire ajouté à une annonce
619 619 label_settings: Configuration
620 620 label_overview: Aperçu
621 621 label_version: Version
622 622 label_version_new: Nouvelle version
623 623 label_version_plural: Versions
624 624 label_close_versions: Fermer les versions terminées
625 625 label_confirmation: Confirmation
626 626 label_export_to: 'Formats disponibles :'
627 627 label_read: Lire...
628 628 label_public_projects: Projets publics
629 629 label_open_issues: ouvert
630 630 label_open_issues_plural: ouverts
631 631 label_closed_issues: fermé
632 632 label_closed_issues_plural: fermés
633 633 label_x_open_issues_abbr_on_total:
634 634 zero: 0 ouverte sur %{total}
635 635 one: 1 ouverte sur %{total}
636 636 other: "%{count} ouvertes sur %{total}"
637 637 label_x_open_issues_abbr:
638 638 zero: 0 ouverte
639 639 one: 1 ouverte
640 640 other: "%{count} ouvertes"
641 641 label_x_closed_issues_abbr:
642 642 zero: 0 fermée
643 643 one: 1 fermée
644 644 other: "%{count} fermées"
645 645 label_x_issues:
646 646 zero: 0 demande
647 647 one: 1 demande
648 648 other: "%{count} demandes"
649 649 label_total: Total
650 650 label_total_time: Temps total
651 651 label_permissions: Permissions
652 652 label_current_status: Statut actuel
653 653 label_new_statuses_allowed: Nouveaux statuts autorisés
654 654 label_all: tous
655 655 label_any: tous
656 656 label_none: aucun
657 657 label_nobody: personne
658 658 label_next: Suivant
659 659 label_previous: Précédent
660 660 label_used_by: Utilisé par
661 661 label_details: Détails
662 662 label_add_note: Ajouter une note
663 663 label_per_page: Par page
664 664 label_calendar: Calendrier
665 665 label_months_from: mois depuis
666 666 label_gantt: Gantt
667 667 label_internal: Interne
668 668 label_last_changes: "%{count} derniers changements"
669 669 label_change_view_all: Voir tous les changements
670 670 label_personalize_page: Personnaliser cette page
671 671 label_comment: Commentaire
672 672 label_comment_plural: Commentaires
673 673 label_x_comments:
674 674 zero: aucun commentaire
675 675 one: un commentaire
676 676 other: "%{count} commentaires"
677 677 label_comment_add: Ajouter un commentaire
678 678 label_comment_added: Commentaire ajouté
679 679 label_comment_delete: Supprimer les commentaires
680 680 label_query: Rapport personnalisé
681 681 label_query_plural: Rapports personnalisés
682 682 label_query_new: Nouveau rapport
683 683 label_my_queries: Mes rapports personnalisés
684 684 label_filter_add: Ajouter le filtre
685 685 label_filter_plural: Filtres
686 686 label_equals: égal
687 687 label_not_equals: différent
688 688 label_in_less_than: dans moins de
689 689 label_in_more_than: dans plus de
690 690 label_in_the_next_days: dans les prochains jours
691 691 label_in_the_past_days: dans les derniers jours
692 692 label_greater_or_equal: '>='
693 693 label_less_or_equal: '<='
694 694 label_between: entre
695 695 label_in: dans
696 696 label_today: aujourd'hui
697 697 label_all_time: toute la période
698 698 label_yesterday: hier
699 699 label_this_week: cette semaine
700 700 label_last_week: la semaine dernière
701 701 label_last_n_weeks: "les %{count} dernières semaines"
702 702 label_last_n_days: "les %{count} derniers jours"
703 703 label_this_month: ce mois-ci
704 704 label_last_month: le mois dernier
705 705 label_this_year: cette année
706 706 label_date_range: Période
707 707 label_less_than_ago: il y a moins de
708 708 label_more_than_ago: il y a plus de
709 709 label_ago: il y a
710 710 label_contains: contient
711 711 label_not_contains: ne contient pas
712 712 label_any_issues_in_project: une demande du projet
713 713 label_any_issues_not_in_project: une demande hors du projet
714 714 label_no_issues_in_project: aucune demande du projet
715 715 label_day_plural: jours
716 716 label_repository: Dépôt
717 717 label_repository_new: Nouveau dépôt
718 718 label_repository_plural: Dépôts
719 719 label_browse: Parcourir
720 720 label_branch: Branche
721 721 label_tag: Tag
722 722 label_revision: Révision
723 723 label_revision_plural: Révisions
724 724 label_revision_id: "Révision %{value}"
725 725 label_associated_revisions: Révisions associées
726 726 label_added: ajouté
727 727 label_modified: modifié
728 728 label_copied: copié
729 729 label_renamed: renommé
730 730 label_deleted: supprimé
731 731 label_latest_revision: Dernière révision
732 732 label_latest_revision_plural: Dernières révisions
733 733 label_view_revisions: Voir les révisions
734 734 label_view_all_revisions: Voir toutes les révisions
735 735 label_max_size: Taille maximale
736 736 label_sort_highest: Remonter en premier
737 737 label_sort_higher: Remonter
738 738 label_sort_lower: Descendre
739 739 label_sort_lowest: Descendre en dernier
740 740 label_roadmap: Roadmap
741 741 label_roadmap_due_in: "Échéance dans %{value}"
742 742 label_roadmap_overdue: "En retard de %{value}"
743 743 label_roadmap_no_issues: Aucune demande pour cette version
744 744 label_search: Recherche
745 745 label_result_plural: Résultats
746 746 label_all_words: Tous les mots
747 747 label_wiki: Wiki
748 748 label_wiki_edit: Révision wiki
749 749 label_wiki_edit_plural: Révisions wiki
750 750 label_wiki_page: Page wiki
751 751 label_wiki_page_plural: Pages wiki
752 752 label_index_by_title: Index par titre
753 753 label_index_by_date: Index par date
754 754 label_current_version: Version actuelle
755 755 label_preview: Prévisualisation
756 756 label_feed_plural: Flux Atom
757 757 label_changes_details: Détails de tous les changements
758 758 label_issue_tracking: Suivi des demandes
759 759 label_spent_time: Temps passé
760 760 label_overall_spent_time: Temps passé global
761 761 label_f_hour: "%{value} heure"
762 762 label_f_hour_plural: "%{value} heures"
763 763 label_time_tracking: Suivi du temps
764 764 label_change_plural: Changements
765 765 label_statistics: Statistiques
766 766 label_commits_per_month: Commits par mois
767 767 label_commits_per_author: Commits par auteur
768 768 label_diff: diff
769 769 label_view_diff: Voir les différences
770 770 label_diff_inline: en ligne
771 771 label_diff_side_by_side: côte à côte
772 772 label_options: Options
773 773 label_copy_workflow_from: Copier le workflow de
774 774 label_permissions_report: Synthèse des permissions
775 775 label_watched_issues: Demandes surveillées
776 776 label_related_issues: Demandes liées
777 777 label_applied_status: Statut appliqué
778 778 label_loading: Chargement...
779 779 label_relation_new: Nouvelle relation
780 780 label_relation_delete: Supprimer la relation
781 781 label_relates_to: Lié à
782 782 label_duplicates: Duplique
783 783 label_duplicated_by: Dupliqué par
784 784 label_blocks: Bloque
785 785 label_blocked_by: Bloqué par
786 786 label_precedes: Précède
787 787 label_follows: Suit
788 788 label_copied_to: Copié vers
789 789 label_copied_from: Copié depuis
790 790 label_end_to_start: fin à début
791 791 label_end_to_end: fin à fin
792 792 label_start_to_start: début à début
793 793 label_start_to_end: début à fin
794 794 label_stay_logged_in: Rester connecté
795 795 label_disabled: désactivé
796 796 label_show_completed_versions: Voir les versions passées
797 797 label_me: moi
798 798 label_board: Forum
799 799 label_board_new: Nouveau forum
800 800 label_board_plural: Forums
801 801 label_board_locked: Verrouillé
802 802 label_board_sticky: Sticky
803 803 label_topic_plural: Discussions
804 804 label_message_plural: Messages
805 805 label_message_last: Dernier message
806 806 label_message_new: Nouveau message
807 807 label_message_posted: Message ajouté
808 808 label_reply_plural: Réponses
809 809 label_send_information: Envoyer les informations à l'utilisateur
810 810 label_year: Année
811 811 label_month: Mois
812 812 label_week: Semaine
813 813 label_date_from: Du
814 814 label_date_to: Au
815 815 label_language_based: Basé sur la langue de l'utilisateur
816 816 label_sort_by: "Trier par %{value}"
817 817 label_send_test_email: Envoyer un email de test
818 818 label_feeds_access_key: Clé d'accès Atom
819 819 label_missing_feeds_access_key: Clé d'accès Atom manquante
820 820 label_feeds_access_key_created_on: "Clé d'accès Atom créée il y a %{value}"
821 821 label_module_plural: Modules
822 822 label_added_time_by: "Ajouté par %{author} il y a %{age}"
823 823 label_updated_time_by: "Mis à jour par %{author} il y a %{age}"
824 824 label_updated_time: "Mis à jour il y a %{value}"
825 825 label_jump_to_a_project: Aller à un projet...
826 826 label_file_plural: Fichiers
827 827 label_changeset_plural: Révisions
828 828 label_default_columns: Colonnes par défaut
829 829 label_no_change_option: (Pas de changement)
830 830 label_bulk_edit_selected_issues: Modifier les demandes sélectionnées
831 831 label_bulk_edit_selected_time_entries: Modifier les temps passés sélectionnés
832 832 label_theme: Thème
833 833 label_default: Défaut
834 834 label_search_titles_only: Uniquement dans les titres
835 835 label_user_mail_option_all: "Pour tous les événements de tous mes projets"
836 836 label_user_mail_option_selected: "Pour tous les événements des projets sélectionnés..."
837 837 label_user_mail_option_none: Aucune notification
838 838 label_user_mail_option_only_my_events: Seulement pour ce que je surveille
839 839 label_user_mail_option_only_assigned: Seulement pour ce qui m'est assigné
840 840 label_user_mail_option_only_owner: Seulement pour ce que j'ai créé
841 841 label_user_mail_no_self_notified: "Je ne veux pas être notifié des changements que j'effectue"
842 842 label_registration_activation_by_email: activation du compte par email
843 843 label_registration_manual_activation: activation manuelle du compte
844 844 label_registration_automatic_activation: activation automatique du compte
845 845 label_display_per_page: "Par page : %{value}"
846 846 label_age: Âge
847 847 label_change_properties: Changer les propriétés
848 848 label_general: Général
849 849 label_more: Plus
850 850 label_scm: SCM
851 851 label_plugins: Plugins
852 852 label_ldap_authentication: Authentification LDAP
853 853 label_downloads_abbr: D/L
854 854 label_optional_description: Description facultative
855 855 label_add_another_file: Ajouter un autre fichier
856 856 label_preferences: Préférences
857 857 label_chronological_order: Dans l'ordre chronologique
858 858 label_reverse_chronological_order: Dans l'ordre chronologique inverse
859 859 label_planning: Planning
860 860 label_incoming_emails: Emails entrants
861 861 label_generate_key: Générer une clé
862 862 label_issue_watchers: Observateurs
863 863 label_example: Exemple
864 864 label_display: Affichage
865 865 label_sort: Tri
866 866 label_ascending: Croissant
867 867 label_descending: Décroissant
868 868 label_date_from_to: Du %{start} au %{end}
869 869 label_wiki_content_added: Page wiki ajoutée
870 870 label_wiki_content_updated: Page wiki mise à jour
871 871 label_group: Groupe
872 872 label_group_plural: Groupes
873 873 label_group_new: Nouveau groupe
874 label_group_anonymous: Utilisateurs anonymes
875 label_group_non_member: Utilisateurs non membres
874 876 label_time_entry_plural: Temps passé
875 877 label_version_sharing_none: Non partagé
876 878 label_version_sharing_descendants: Avec les sous-projets
877 879 label_version_sharing_hierarchy: Avec toute la hiérarchie
878 880 label_version_sharing_tree: Avec tout l'arbre
879 881 label_version_sharing_system: Avec tous les projets
880 882 label_update_issue_done_ratios: Mettre à jour l'avancement des demandes
881 883 label_copy_source: Source
882 884 label_copy_target: Cible
883 885 label_copy_same_as_target: Comme la cible
884 886 label_display_used_statuses_only: N'afficher que les statuts utilisés dans ce tracker
885 887 label_api_access_key: Clé d'accès API
886 888 label_missing_api_access_key: Clé d'accès API manquante
887 889 label_api_access_key_created_on: Clé d'accès API créée il y a %{value}
888 890 label_profile: Profil
889 891 label_subtask_plural: Sous-tâches
890 892 label_project_copy_notifications: Envoyer les notifications durant la copie du projet
891 893 label_principal_search: "Rechercher un utilisateur ou un groupe :"
892 894 label_user_search: "Rechercher un utilisateur :"
893 895 label_additional_workflow_transitions_for_author: Autorisations supplémentaires lorsque l'utilisateur a créé la demande
894 896 label_additional_workflow_transitions_for_assignee: Autorisations supplémentaires lorsque la demande est assignée à l'utilisateur
895 897 label_issues_visibility_all: Toutes les demandes
896 898 label_issues_visibility_public: Toutes les demandes non privées
897 899 label_issues_visibility_own: Demandes créées par ou assignées à l'utilisateur
898 900 label_git_report_last_commit: Afficher le dernier commit des fichiers et répertoires
899 901 label_parent_revision: Parent
900 902 label_child_revision: Enfant
901 903 label_export_options: Options d'exportation %{export_format}
902 904 label_copy_attachments: Copier les fichiers
903 905 label_copy_subtasks: Copier les sous-tâches
904 906 label_item_position: "%{position} sur %{count}"
905 907 label_completed_versions: Versions passées
906 908 label_search_for_watchers: Rechercher des observateurs
907 909 label_session_expiration: Expiration des sessions
908 910 label_show_closed_projects: Voir les projets fermés
909 911 label_status_transitions: Changements de statut
910 912 label_fields_permissions: Permissions sur les champs
911 913 label_readonly: Lecture
912 914 label_required: Obligatoire
913 915 label_hidden: Caché
914 916 label_attribute_of_project: "%{name} du projet"
915 917 label_attribute_of_issue: "%{name} de la demande"
916 918 label_attribute_of_author: "%{name} de l'auteur"
917 919 label_attribute_of_assigned_to: "%{name} de l'assigné"
918 920 label_attribute_of_user: "%{name} de l'utilisateur"
919 921 label_attribute_of_fixed_version: "%{name} de la version cible"
920 922 label_cross_project_descendants: Avec les sous-projets
921 923 label_cross_project_tree: Avec tout l'arbre
922 924 label_cross_project_hierarchy: Avec toute la hiérarchie
923 925 label_cross_project_system: Avec tous les projets
924 926 label_gantt_progress_line: Ligne de progression
925 927 label_visibility_private: par moi uniquement
926 928 label_visibility_roles: par ces rôles uniquement
927 929 label_visibility_public: par tout le monde
928 930 label_link: Lien
929 931 label_only: seulement
930 932 label_drop_down_list: liste déroulante
931 933 label_checkboxes: cases à cocher
932 934 label_radio_buttons: boutons radio
933 935 label_link_values_to: Lier les valeurs vers l'URL
934 936 label_custom_field_select_type: Selectionner le type d'objet auquel attacher le champ personnalisé
935 937 label_check_for_updates: Vérifier les mises à jour
936 938 label_latest_compatible_version: Dernière version compatible
937 939 label_unknown_plugin: Plugin inconnu
938 940
939 941 button_login: Connexion
940 942 button_submit: Soumettre
941 943 button_save: Sauvegarder
942 944 button_check_all: Tout cocher
943 945 button_uncheck_all: Tout décocher
944 946 button_collapse_all: Plier tout
945 947 button_expand_all: Déplier tout
946 948 button_delete: Supprimer
947 949 button_create: Créer
948 950 button_create_and_continue: Créer et continuer
949 951 button_test: Tester
950 952 button_edit: Modifier
951 953 button_edit_associated_wikipage: "Modifier la page wiki associée: %{page_title}"
952 954 button_add: Ajouter
953 955 button_change: Changer
954 956 button_apply: Appliquer
955 957 button_clear: Effacer
956 958 button_lock: Verrouiller
957 959 button_unlock: Déverrouiller
958 960 button_download: Télécharger
959 961 button_list: Lister
960 962 button_view: Voir
961 963 button_move: Déplacer
962 964 button_move_and_follow: Déplacer et suivre
963 965 button_back: Retour
964 966 button_cancel: Annuler
965 967 button_activate: Activer
966 968 button_sort: Trier
967 969 button_log_time: Saisir temps
968 970 button_rollback: Revenir à cette version
969 971 button_watch: Surveiller
970 972 button_unwatch: Ne plus surveiller
971 973 button_reply: Répondre
972 974 button_archive: Archiver
973 975 button_unarchive: Désarchiver
974 976 button_reset: Réinitialiser
975 977 button_rename: Renommer
976 978 button_change_password: Changer de mot de passe
977 979 button_copy: Copier
978 980 button_copy_and_follow: Copier et suivre
979 981 button_annotate: Annoter
980 982 button_update: Mettre à jour
981 983 button_configure: Configurer
982 984 button_quote: Citer
983 985 button_duplicate: Dupliquer
984 986 button_show: Afficher
985 987 button_hide: Cacher
986 988 button_edit_section: Modifier cette section
987 989 button_export: Exporter
988 990 button_delete_my_account: Supprimer mon compte
989 991 button_close: Fermer
990 992 button_reopen: Réouvrir
991 993
992 994 status_active: actif
993 995 status_registered: enregistré
994 996 status_locked: verrouillé
995 997
996 998 project_status_active: actif
997 999 project_status_closed: fermé
998 1000 project_status_archived: archivé
999 1001
1000 1002 version_status_open: ouvert
1001 1003 version_status_locked: verrouillé
1002 1004 version_status_closed: fermé
1003 1005
1004 1006 field_active: Actif
1005 1007
1006 1008 text_select_mail_notifications: Actions pour lesquelles une notification par e-mail est envoyée
1007 1009 text_regexp_info: ex. ^[A-Z0-9]+$
1008 1010 text_min_max_length_info: 0 pour aucune restriction
1009 1011 text_project_destroy_confirmation: Êtes-vous sûr de vouloir supprimer ce projet et toutes ses données ?
1010 1012 text_subprojects_destroy_warning: "Ses sous-projets : %{value} seront également supprimés."
1011 1013 text_workflow_edit: Sélectionner un tracker et un rôle pour éditer le workflow
1012 1014 text_are_you_sure: Êtes-vous sûr ?
1013 1015 text_journal_changed: "%{label} changé de %{old} à %{new}"
1014 1016 text_journal_changed_no_detail: "%{label} mis à jour"
1015 1017 text_journal_set_to: "%{label} mis à %{value}"
1016 1018 text_journal_deleted: "%{label} %{old} supprimé"
1017 1019 text_journal_added: "%{label} %{value} ajouté"
1018 1020 text_tip_issue_begin_day: tâche commençant ce jour
1019 1021 text_tip_issue_end_day: tâche finissant ce jour
1020 1022 text_tip_issue_begin_end_day: tâche commençant et finissant ce jour
1021 1023 text_project_identifier_info: 'Seuls les lettres minuscules (a-z), chiffres, tirets et tirets bas sont autorisés, doit commencer par une minuscule.<br />Un fois sauvegardé, l''identifiant ne pourra plus être modifié.'
1022 1024 text_caracters_maximum: "%{count} caractères maximum."
1023 1025 text_caracters_minimum: "%{count} caractères minimum."
1024 1026 text_length_between: "Longueur comprise entre %{min} et %{max} caractères."
1025 1027 text_tracker_no_workflow: Aucun worflow n'est défini pour ce tracker
1026 1028 text_unallowed_characters: Caractères non autorisés
1027 1029 text_comma_separated: Plusieurs valeurs possibles (séparées par des virgules).
1028 1030 text_line_separated: Plusieurs valeurs possibles (une valeur par ligne).
1029 1031 text_issues_ref_in_commit_messages: Référencement et résolution des demandes dans les commentaires de commits
1030 1032 text_issue_added: "La demande %{id} a été soumise par %{author}."
1031 1033 text_issue_updated: "La demande %{id} a été mise à jour par %{author}."
1032 1034 text_wiki_destroy_confirmation: Etes-vous sûr de vouloir supprimer ce wiki et tout son contenu ?
1033 1035 text_issue_category_destroy_question: "%{count} demandes sont affectées à cette catégorie. Que voulez-vous faire ?"
1034 1036 text_issue_category_destroy_assignments: N'affecter les demandes à aucune autre catégorie
1035 1037 text_issue_category_reassign_to: Réaffecter les demandes à cette catégorie
1036 1038 text_user_mail_option: "Pour les projets non sélectionnés, vous recevrez seulement des notifications pour ce que vous surveillez ou à quoi vous participez (exemple: demandes dont vous êtes l'auteur ou la personne assignée)."
1037 1039 text_no_configuration_data: "Les rôles, trackers, statuts et le workflow ne sont pas encore paramétrés.\nIl est vivement recommandé de charger le paramétrage par defaut. Vous pourrez le modifier une fois chargé."
1038 1040 text_load_default_configuration: Charger le paramétrage par défaut
1039 1041 text_status_changed_by_changeset: "Appliqué par commit %{value}."
1040 1042 text_time_logged_by_changeset: "Appliqué par commit %{value}"
1041 1043 text_issues_destroy_confirmation: 'Êtes-vous sûr de vouloir supprimer la ou les demandes(s) selectionnée(s) ?'
1042 1044 text_issues_destroy_descendants_confirmation: "Cela entrainera également la suppression de %{count} sous-tâche(s)."
1043 1045 text_time_entries_destroy_confirmation: "Etes-vous sûr de vouloir supprimer les temps passés sélectionnés ?"
1044 1046 text_select_project_modules: 'Sélectionner les modules à activer pour ce projet :'
1045 1047 text_default_administrator_account_changed: Compte administrateur par défaut changé
1046 1048 text_file_repository_writable: Répertoire de stockage des fichiers accessible en écriture
1047 1049 text_plugin_assets_writable: Répertoire public des plugins accessible en écriture
1048 1050 text_rmagick_available: Bibliothèque RMagick présente (optionnelle)
1049 1051 text_convert_available: Binaire convert de ImageMagick présent (optionel)
1050 1052 text_destroy_time_entries_question: "%{hours} heures ont été enregistrées sur les demandes à supprimer. Que voulez-vous faire ?"
1051 1053 text_destroy_time_entries: Supprimer les heures
1052 1054 text_assign_time_entries_to_project: Reporter les heures sur le projet
1053 1055 text_reassign_time_entries: 'Reporter les heures sur cette demande:'
1054 1056 text_user_wrote: "%{value} a écrit :"
1055 1057 text_enumeration_destroy_question: "Cette valeur est affectée à %{count} objets."
1056 1058 text_enumeration_category_reassign_to: 'Réaffecter les objets à cette valeur:'
1057 1059 text_email_delivery_not_configured: "L'envoi de mail n'est pas configuré, les notifications sont désactivées.\nConfigurez votre serveur SMTP dans config/configuration.yml et redémarrez l'application pour les activer."
1058 1060 text_repository_usernames_mapping: "Vous pouvez sélectionner ou modifier l'utilisateur Redmine associé à chaque nom d'utilisateur figurant dans l'historique du dépôt.\nLes utilisateurs avec le même identifiant ou la même adresse mail seront automatiquement associés."
1059 1061 text_diff_truncated: '... Ce différentiel a été tronqué car il excède la taille maximale pouvant être affichée.'
1060 1062 text_custom_field_possible_values_info: 'Une ligne par valeur'
1061 1063 text_wiki_page_destroy_question: "Cette page possède %{descendants} sous-page(s) et descendante(s). Que voulez-vous faire ?"
1062 1064 text_wiki_page_nullify_children: "Conserver les sous-pages en tant que pages racines"
1063 1065 text_wiki_page_destroy_children: "Supprimer les sous-pages et toutes leurs descedantes"
1064 1066 text_wiki_page_reassign_children: "Réaffecter les sous-pages à cette page"
1065 1067 text_own_membership_delete_confirmation: "Vous allez supprimer tout ou partie de vos permissions sur ce projet et ne serez peut-être plus autorisé à modifier ce projet.\nEtes-vous sûr de vouloir continuer ?"
1066 1068 text_zoom_in: Zoom avant
1067 1069 text_zoom_out: Zoom arrière
1068 1070 text_warn_on_leaving_unsaved: "Cette page contient du texte non sauvegardé qui sera perdu si vous quittez la page."
1069 1071 text_scm_path_encoding_note: "Défaut : UTF-8"
1070 1072 text_git_repository_note: "Le dépôt est vide et local (exemples : /gitrepo, c:\\gitrepo)"
1071 1073 text_mercurial_repository_note: "Dépôt local (exemples : /hgrepo, c:\\hgrepo)"
1072 1074 text_scm_command: Commande
1073 1075 text_scm_command_version: Version
1074 1076 text_scm_config: Vous pouvez configurer les commandes des SCM dans config/configuration.yml. Redémarrer l'application après modification.
1075 1077 text_scm_command_not_available: Ce SCM n'est pas disponible. Vérifier les paramètres dans la section administration.
1076 1078 text_issue_conflict_resolution_overwrite: "Appliquer quand même ma mise à jour (les notes précédentes seront conservées mais des changements pourront être écrasés)"
1077 1079 text_issue_conflict_resolution_add_notes: "Ajouter mes notes et ignorer mes autres changements"
1078 1080 text_issue_conflict_resolution_cancel: "Annuler ma mise à jour et réafficher %{link}"
1079 1081 text_account_destroy_confirmation: "Êtes-vous sûr de vouloir continuer ?\nVotre compte sera définitivement supprimé, sans aucune possibilité de le réactiver."
1080 1082 text_session_expiration_settings: "Attention : le changement de ces paramètres peut entrainer l'expiration des sessions utilisateurs en cours, y compris la vôtre."
1081 1083 text_project_closed: Ce projet est fermé et accessible en lecture seule.
1082 1084 text_turning_multiple_off: "Si vous désactivez les valeurs multiples, les valeurs multiples seront supprimées pour n'en conserver qu'une par objet."
1083 1085
1084 1086 default_role_manager: Manager
1085 1087 default_role_developer: Développeur
1086 1088 default_role_reporter: Rapporteur
1087 1089 default_tracker_bug: Anomalie
1088 1090 default_tracker_feature: Evolution
1089 1091 default_tracker_support: Assistance
1090 1092 default_issue_status_new: Nouveau
1091 1093 default_issue_status_in_progress: En cours
1092 1094 default_issue_status_resolved: Résolu
1093 1095 default_issue_status_feedback: Commentaire
1094 1096 default_issue_status_closed: Fermé
1095 1097 default_issue_status_rejected: Rejeté
1096 1098 default_doc_category_user: Documentation utilisateur
1097 1099 default_doc_category_tech: Documentation technique
1098 1100 default_priority_low: Bas
1099 1101 default_priority_normal: Normal
1100 1102 default_priority_high: Haut
1101 1103 default_priority_urgent: Urgent
1102 1104 default_priority_immediate: Immédiat
1103 1105 default_activity_design: Conception
1104 1106 default_activity_development: Développement
1105 1107
1106 1108 enumeration_issue_priorities: Priorités des demandes
1107 1109 enumeration_doc_categories: Catégories des documents
1108 1110 enumeration_activities: Activités (suivi du temps)
1109 1111 enumeration_system_activity: Activité système
1110 1112 description_filter: Filtre
1111 1113 description_search: Champ de recherche
1112 1114 description_choose_project: Projets
1113 1115 description_project_scope: Périmètre de recherche
1114 1116 description_notes: Notes
1115 1117 description_message_content: Contenu du message
1116 1118 description_query_sort_criteria_attribute: Critère de tri
1117 1119 description_query_sort_criteria_direction: Ordre de tri
1118 1120 description_user_mail_notification: Option de notification
1119 1121 description_available_columns: Colonnes disponibles
1120 1122 description_selected_columns: Colonnes sélectionnées
1121 1123 description_all_columns: Toutes les colonnes
1122 1124 description_issue_category_reassign: Choisir une catégorie
1123 1125 description_wiki_subpages_reassign: Choisir une nouvelle page parent
1124 1126 description_date_range_list: Choisir une période prédéfinie
1125 1127 description_date_range_interval: Choisir une période
1126 1128 description_date_from: Date de début
1127 1129 description_date_to: Date de fin
1128 1130 text_repository_identifier_info: 'Seuls les lettres minuscules (a-z), chiffres, tirets et tirets bas sont autorisés.<br />Un fois sauvegardé, l''identifiant ne pourra plus être modifié.'
@@ -1,544 +1,554
1 1 package Apache::Authn::Redmine;
2 2
3 3 =head1 Apache::Authn::Redmine
4 4
5 5 Redmine - a mod_perl module to authenticate webdav subversion users
6 6 against redmine database
7 7
8 8 =head1 SYNOPSIS
9 9
10 10 This module allow anonymous users to browse public project and
11 11 registred users to browse and commit their project. Authentication is
12 12 done against the redmine database or the LDAP configured in redmine.
13 13
14 14 This method is far simpler than the one with pam_* and works with all
15 15 database without an hassle but you need to have apache/mod_perl on the
16 16 svn server.
17 17
18 18 =head1 INSTALLATION
19 19
20 20 For this to automagically work, you need to have a recent reposman.rb
21 21 (after r860) and if you already use reposman, read the last section to
22 22 migrate.
23 23
24 24 Sorry ruby users but you need some perl modules, at least mod_perl2,
25 25 DBI and DBD::mysql (or the DBD driver for you database as it should
26 26 work on allmost all databases).
27 27
28 28 On debian/ubuntu you must do :
29 29
30 30 aptitude install libapache-dbi-perl libapache2-mod-perl2 libdbd-mysql-perl
31 31
32 32 If your Redmine users use LDAP authentication, you will also need
33 33 Authen::Simple::LDAP (and IO::Socket::SSL if LDAPS is used):
34 34
35 35 aptitude install libauthen-simple-ldap-perl libio-socket-ssl-perl
36 36
37 37 =head1 CONFIGURATION
38 38
39 39 ## This module has to be in your perl path
40 40 ## eg: /usr/lib/perl5/Apache/Authn/Redmine.pm
41 41 PerlLoadModule Apache::Authn::Redmine
42 42 <Location /svn>
43 43 DAV svn
44 44 SVNParentPath "/var/svn"
45 45
46 46 AuthType Basic
47 47 AuthName redmine
48 48 Require valid-user
49 49
50 50 PerlAccessHandler Apache::Authn::Redmine::access_handler
51 51 PerlAuthenHandler Apache::Authn::Redmine::authen_handler
52 52
53 53 ## for mysql
54 54 RedmineDSN "DBI:mysql:database=databasename;host=my.db.server"
55 55 ## for postgres
56 56 # RedmineDSN "DBI:Pg:dbname=databasename;host=my.db.server"
57 57
58 58 RedmineDbUser "redmine"
59 59 RedmineDbPass "password"
60 60 ## Optional where clause (fulltext search would be slow and
61 61 ## database dependant).
62 62 # RedmineDbWhereClause "and members.role_id IN (1,2)"
63 63 ## Optional credentials cache size
64 64 # RedmineCacheCredsMax 50
65 65 </Location>
66 66
67 67 To be able to browse repository inside redmine, you must add something
68 68 like that :
69 69
70 70 <Location /svn-private>
71 71 DAV svn
72 72 SVNParentPath "/var/svn"
73 73 Order deny,allow
74 74 Deny from all
75 75 # only allow reading orders
76 76 <Limit GET PROPFIND OPTIONS REPORT>
77 77 Allow from redmine.server.ip
78 78 </Limit>
79 79 </Location>
80 80
81 81 and you will have to use this reposman.rb command line to create repository :
82 82
83 83 reposman.rb --redmine my.redmine.server --svn-dir /var/svn --owner www-data -u http://svn.server/svn-private/
84 84
85 85 =head1 REPOSITORIES NAMING
86 86
87 87 A projet repository must be named with the projet identifier. In case
88 88 of multiple repositories for the same project, use the project identifier
89 89 and the repository identifier separated with a dot:
90 90
91 91 /var/svn/foo
92 92 /var/svn/foo.otherrepo
93 93
94 94 =head1 MIGRATION FROM OLDER RELEASES
95 95
96 96 If you use an older reposman.rb (r860 or before), you need to change
97 97 rights on repositories to allow the apache user to read and write
98 98 S<them :>
99 99
100 100 sudo chown -R www-data /var/svn/*
101 101 sudo chmod -R u+w /var/svn/*
102 102
103 103 And you need to upgrade at least reposman.rb (after r860).
104 104
105 105 =head1 GIT SMART HTTP SUPPORT
106 106
107 107 Git's smart HTTP protocol (available since Git 1.7.0) will not work with the
108 108 above settings. Redmine.pm normally does access control depending on the HTTP
109 109 method used: read-only methods are OK for everyone in public projects and
110 110 members with read rights in private projects. The rest require membership with
111 111 commit rights in the project.
112 112
113 113 However, this scheme doesn't work for Git's smart HTTP protocol, as it will use
114 114 POST even for a simple clone. Instead, read-only requests must be detected using
115 115 the full URL (including the query string): anything that doesn't belong to the
116 116 git-receive-pack service is read-only.
117 117
118 118 To activate this mode of operation, add this line inside your <Location /git>
119 119 block:
120 120
121 121 RedmineGitSmartHttp yes
122 122
123 123 Here's a sample Apache configuration which integrates git-http-backend with
124 124 a MySQL database and this new option:
125 125
126 126 SetEnv GIT_PROJECT_ROOT /var/www/git/
127 127 SetEnv GIT_HTTP_EXPORT_ALL
128 128 ScriptAlias /git/ /usr/libexec/git-core/git-http-backend/
129 129 <Location /git>
130 130 Order allow,deny
131 131 Allow from all
132 132
133 133 AuthType Basic
134 134 AuthName Git
135 135 Require valid-user
136 136
137 137 PerlAccessHandler Apache::Authn::Redmine::access_handler
138 138 PerlAuthenHandler Apache::Authn::Redmine::authen_handler
139 139 # for mysql
140 140 RedmineDSN "DBI:mysql:database=redmine;host=127.0.0.1"
141 141 RedmineDbUser "redmine"
142 142 RedmineDbPass "xxx"
143 143 RedmineGitSmartHttp yes
144 144 </Location>
145 145
146 146 Make sure that all the names of the repositories under /var/www/git/ have a
147 147 matching identifier for some project: /var/www/git/myproject and
148 148 /var/www/git/myproject.git will work. You can put both bare and non-bare
149 149 repositories in /var/www/git, though bare repositories are strongly
150 150 recommended. You should create them with the rights of the user running Redmine,
151 151 like this:
152 152
153 153 cd /var/www/git
154 154 sudo -u user-running-redmine mkdir myproject
155 155 cd myproject
156 156 sudo -u user-running-redmine git init --bare
157 157
158 158 Once you have activated this option, you have three options when cloning a
159 159 repository:
160 160
161 161 - Cloning using "http://user@host/git/repo(.git)" works, but will ask for the password
162 162 all the time.
163 163
164 164 - Cloning with "http://user:pass@host/git/repo(.git)" does not have this problem, but
165 165 this could reveal accidentally your password to the console in some versions
166 166 of Git, and you would have to ensure that .git/config is not readable except
167 167 by the owner for each of your projects.
168 168
169 169 - Use "http://host/git/repo(.git)", and store your credentials in the ~/.netrc
170 170 file. This is the recommended solution, as you only have one file to protect
171 171 and passwords will not be leaked accidentally to the console.
172 172
173 173 IMPORTANT NOTE: It is *very important* that the file cannot be read by other
174 174 users, as it will contain your password in cleartext. To create the file, you
175 175 can use the following commands, replacing yourhost, youruser and yourpassword
176 176 with the right values:
177 177
178 178 touch ~/.netrc
179 179 chmod 600 ~/.netrc
180 180 echo -e "machine yourhost\nlogin youruser\npassword yourpassword" > ~/.netrc
181 181
182 182 =cut
183 183
184 184 use strict;
185 185 use warnings FATAL => 'all', NONFATAL => 'redefine';
186 186
187 187 use DBI;
188 188 use Digest::SHA;
189 189 # optional module for LDAP authentication
190 190 my $CanUseLDAPAuth = eval("use Authen::Simple::LDAP; 1");
191 191
192 192 use Apache2::Module;
193 193 use Apache2::Access;
194 194 use Apache2::ServerRec qw();
195 195 use Apache2::RequestRec qw();
196 196 use Apache2::RequestUtil qw();
197 197 use Apache2::Const qw(:common :override :cmd_how);
198 198 use APR::Pool ();
199 199 use APR::Table ();
200 200
201 201 # use Apache2::Directive qw();
202 202
203 203 my @directives = (
204 204 {
205 205 name => 'RedmineDSN',
206 206 req_override => OR_AUTHCFG,
207 207 args_how => TAKE1,
208 208 errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
209 209 },
210 210 {
211 211 name => 'RedmineDbUser',
212 212 req_override => OR_AUTHCFG,
213 213 args_how => TAKE1,
214 214 },
215 215 {
216 216 name => 'RedmineDbPass',
217 217 req_override => OR_AUTHCFG,
218 218 args_how => TAKE1,
219 219 },
220 220 {
221 221 name => 'RedmineDbWhereClause',
222 222 req_override => OR_AUTHCFG,
223 223 args_how => TAKE1,
224 224 },
225 225 {
226 226 name => 'RedmineCacheCredsMax',
227 227 req_override => OR_AUTHCFG,
228 228 args_how => TAKE1,
229 229 errmsg => 'RedmineCacheCredsMax must be decimal number',
230 230 },
231 231 {
232 232 name => 'RedmineGitSmartHttp',
233 233 req_override => OR_AUTHCFG,
234 234 args_how => TAKE1,
235 235 },
236 236 );
237 237
238 238 sub RedmineDSN {
239 239 my ($self, $parms, $arg) = @_;
240 240 $self->{RedmineDSN} = $arg;
241 241 my $query = "SELECT
242 242 users.hashed_password, users.salt, users.auth_source_id, roles.permissions, projects.status
243 243 FROM projects, users, roles
244 244 WHERE
245 245 users.login=?
246 246 AND projects.identifier=?
247 247 AND users.status=1
248 248 AND (
249 249 roles.id IN (SELECT member_roles.role_id FROM members, member_roles WHERE members.user_id = users.id AND members.project_id = projects.id AND members.id = member_roles.member_id)
250 250 OR
251 (roles.builtin=1 AND cast(projects.is_public as CHAR) IN ('t', '1'))
251 (cast(projects.is_public as CHAR) IN ('t', '1')
252 AND (roles.builtin=1
253 OR roles.id IN (SELECT member_roles.role_id FROM members, member_roles, users g
254 WHERE members.user_id = g.id AND members.project_id = projects.id AND members.id = member_roles.member_id
255 AND g.type = 'GroupNonMember'))
256 )
252 257 )
253 258 AND roles.permissions IS NOT NULL";
254 259 $self->{RedmineQuery} = trim($query);
255 260 }
256 261
257 262 sub RedmineDbUser { set_val('RedmineDbUser', @_); }
258 263 sub RedmineDbPass { set_val('RedmineDbPass', @_); }
259 264 sub RedmineDbWhereClause {
260 265 my ($self, $parms, $arg) = @_;
261 266 $self->{RedmineQuery} = trim($self->{RedmineQuery}.($arg ? $arg : "")." ");
262 267 }
263 268
264 269 sub RedmineCacheCredsMax {
265 270 my ($self, $parms, $arg) = @_;
266 271 if ($arg) {
267 272 $self->{RedmineCachePool} = APR::Pool->new;
268 273 $self->{RedmineCacheCreds} = APR::Table::make($self->{RedmineCachePool}, $arg);
269 274 $self->{RedmineCacheCredsCount} = 0;
270 275 $self->{RedmineCacheCredsMax} = $arg;
271 276 }
272 277 }
273 278
274 279 sub RedmineGitSmartHttp {
275 280 my ($self, $parms, $arg) = @_;
276 281 $arg = lc $arg;
277 282
278 283 if ($arg eq "yes" || $arg eq "true") {
279 284 $self->{RedmineGitSmartHttp} = 1;
280 285 } else {
281 286 $self->{RedmineGitSmartHttp} = 0;
282 287 }
283 288 }
284 289
285 290 sub trim {
286 291 my $string = shift;
287 292 $string =~ s/\s{2,}/ /g;
288 293 return $string;
289 294 }
290 295
291 296 sub set_val {
292 297 my ($key, $self, $parms, $arg) = @_;
293 298 $self->{$key} = $arg;
294 299 }
295 300
296 301 Apache2::Module::add(__PACKAGE__, \@directives);
297 302
298 303
299 304 my %read_only_methods = map { $_ => 1 } qw/GET HEAD PROPFIND REPORT OPTIONS/;
300 305
301 306 sub request_is_read_only {
302 307 my ($r) = @_;
303 308 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
304 309
305 310 # Do we use Git's smart HTTP protocol, or not?
306 311 if (defined $cfg->{RedmineGitSmartHttp} and $cfg->{RedmineGitSmartHttp}) {
307 312 my $uri = $r->unparsed_uri;
308 313 my $location = $r->location;
309 314 my $is_read_only = $uri !~ m{^$location/*[^/]+/+(info/refs\?service=)?git\-receive\-pack$}o;
310 315 return $is_read_only;
311 316 } else {
312 317 # Standard behaviour: check the HTTP method
313 318 my $method = $r->method;
314 319 return defined $read_only_methods{$method};
315 320 }
316 321 }
317 322
318 323 sub access_handler {
319 324 my $r = shift;
320 325
321 326 unless ($r->some_auth_required) {
322 327 $r->log_reason("No authentication has been configured");
323 328 return FORBIDDEN;
324 329 }
325 330
326 331 return OK unless request_is_read_only($r);
327 332
328 333 my $project_id = get_project_identifier($r);
329 334
330 335 $r->set_handlers(PerlAuthenHandler => [\&OK])
331 if is_public_project($project_id, $r) && anonymous_role_allows_browse_repository($r);
336 if is_public_project($project_id, $r) && anonymous_allowed_to_browse_repository($project_id, $r);
332 337
333 338 return OK
334 339 }
335 340
336 341 sub authen_handler {
337 342 my $r = shift;
338 343
339 344 my ($res, $redmine_pass) = $r->get_basic_auth_pw();
340 345 return $res unless $res == OK;
341 346
342 347 if (is_member($r->user, $redmine_pass, $r)) {
343 348 return OK;
344 349 } else {
345 350 $r->note_auth_failure();
346 351 return DECLINED;
347 352 }
348 353 }
349 354
350 355 # check if authentication is forced
351 356 sub is_authentication_forced {
352 357 my $r = shift;
353 358
354 359 my $dbh = connect_database($r);
355 360 my $sth = $dbh->prepare(
356 361 "SELECT value FROM settings where settings.name = 'login_required';"
357 362 );
358 363
359 364 $sth->execute();
360 365 my $ret = 0;
361 366 if (my @row = $sth->fetchrow_array) {
362 367 if ($row[0] eq "1" || $row[0] eq "t") {
363 368 $ret = 1;
364 369 }
365 370 }
366 371 $sth->finish();
367 372 undef $sth;
368 373
369 374 $dbh->disconnect();
370 375 undef $dbh;
371 376
372 377 $ret;
373 378 }
374 379
375 380 sub is_public_project {
376 381 my $project_id = shift;
377 382 my $r = shift;
378 383
379 384 if (is_authentication_forced($r)) {
380 385 return 0;
381 386 }
382 387
383 388 my $dbh = connect_database($r);
384 389 my $sth = $dbh->prepare(
385 390 "SELECT is_public FROM projects WHERE projects.identifier = ? AND projects.status <> 9;"
386 391 );
387 392
388 393 $sth->execute($project_id);
389 394 my $ret = 0;
390 395 if (my @row = $sth->fetchrow_array) {
391 396 if ($row[0] eq "1" || $row[0] eq "t") {
392 397 $ret = 1;
393 398 }
394 399 }
395 400 $sth->finish();
396 401 undef $sth;
397 402 $dbh->disconnect();
398 403 undef $dbh;
399 404
400 405 $ret;
401 406 }
402 407
403 sub anonymous_role_allows_browse_repository {
408 sub anonymous_allowed_to_browse_repository {
409 my $project_id = shift;
404 410 my $r = shift;
405 411
406 412 my $dbh = connect_database($r);
407 413 my $sth = $dbh->prepare(
408 "SELECT permissions FROM roles WHERE builtin = 2;"
414 "SELECT permissions FROM roles WHERE permissions like '%browse_repository%'
415 AND (roles.builtin = 2
416 OR roles.id IN (SELECT member_roles.role_id FROM projects, members, member_roles, users
417 WHERE members.user_id = users.id AND members.project_id = projects.id AND members.id = member_roles.member_id
418 AND projects.identifier = ? AND users.type = 'GroupAnonymous'));"
409 419 );
410 420
411 $sth->execute();
421 $sth->execute($project_id);
412 422 my $ret = 0;
413 423 if (my @row = $sth->fetchrow_array) {
414 424 if ($row[0] =~ /:browse_repository/) {
415 425 $ret = 1;
416 426 }
417 427 }
418 428 $sth->finish();
419 429 undef $sth;
420 430 $dbh->disconnect();
421 431 undef $dbh;
422 432
423 433 $ret;
424 434 }
425 435
426 436 # perhaps we should use repository right (other read right) to check public access.
427 437 # it could be faster BUT it doesn't work for the moment.
428 438 # sub is_public_project_by_file {
429 439 # my $project_id = shift;
430 440 # my $r = shift;
431 441
432 442 # my $tree = Apache2::Directive::conftree();
433 443 # my $node = $tree->lookup('Location', $r->location);
434 444 # my $hash = $node->as_hash;
435 445
436 446 # my $svnparentpath = $hash->{SVNParentPath};
437 447 # my $repos_path = $svnparentpath . "/" . $project_id;
438 448 # return 1 if (stat($repos_path))[2] & 00007;
439 449 # }
440 450
441 451 sub is_member {
442 452 my $redmine_user = shift;
443 453 my $redmine_pass = shift;
444 454 my $r = shift;
445 455
446 456 my $dbh = connect_database($r);
447 457 my $project_id = get_project_identifier($r);
448 458
449 459 my $pass_digest = Digest::SHA::sha1_hex($redmine_pass);
450 460
451 461 my $access_mode = request_is_read_only($r) ? "R" : "W";
452 462
453 463 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
454 464 my $usrprojpass;
455 465 if ($cfg->{RedmineCacheCredsMax}) {
456 466 $usrprojpass = $cfg->{RedmineCacheCreds}->get($redmine_user.":".$project_id.":".$access_mode);
457 467 return 1 if (defined $usrprojpass and ($usrprojpass eq $pass_digest));
458 468 }
459 469 my $query = $cfg->{RedmineQuery};
460 470 my $sth = $dbh->prepare($query);
461 471 $sth->execute($redmine_user, $project_id);
462 472
463 473 my $ret;
464 474 while (my ($hashed_password, $salt, $auth_source_id, $permissions, $project_status) = $sth->fetchrow_array) {
465 475 if ($project_status eq "9" || ($project_status ne "1" && $access_mode eq "W")) {
466 476 last;
467 477 }
468 478
469 479 unless ($auth_source_id) {
470 480 my $method = $r->method;
471 481 my $salted_password = Digest::SHA::sha1_hex($salt.$pass_digest);
472 482 if ($hashed_password eq $salted_password && (($access_mode eq "R" && $permissions =~ /:browse_repository/) || $permissions =~ /:commit_access/) ) {
473 483 $ret = 1;
474 484 last;
475 485 }
476 486 } elsif ($CanUseLDAPAuth) {
477 487 my $sthldap = $dbh->prepare(
478 488 "SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?;"
479 489 );
480 490 $sthldap->execute($auth_source_id);
481 491 while (my @rowldap = $sthldap->fetchrow_array) {
482 492 my $bind_as = $rowldap[3] ? $rowldap[3] : "";
483 493 my $bind_pw = $rowldap[4] ? $rowldap[4] : "";
484 494 if ($bind_as =~ m/\$login/) {
485 495 # replace $login with $redmine_user and use $redmine_pass
486 496 $bind_as =~ s/\$login/$redmine_user/g;
487 497 $bind_pw = $redmine_pass
488 498 }
489 499 my $ldap = Authen::Simple::LDAP->new(
490 500 host => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]:$rowldap[1]" : $rowldap[0],
491 501 port => $rowldap[1],
492 502 basedn => $rowldap[5],
493 503 binddn => $bind_as,
494 504 bindpw => $bind_pw,
495 505 filter => "(".$rowldap[6]."=%s)"
496 506 );
497 507 my $method = $r->method;
498 508 $ret = 1 if ($ldap->authenticate($redmine_user, $redmine_pass) && (($access_mode eq "R" && $permissions =~ /:browse_repository/) || $permissions =~ /:commit_access/));
499 509
500 510 }
501 511 $sthldap->finish();
502 512 undef $sthldap;
503 513 }
504 514 }
505 515 $sth->finish();
506 516 undef $sth;
507 517 $dbh->disconnect();
508 518 undef $dbh;
509 519
510 520 if ($cfg->{RedmineCacheCredsMax} and $ret) {
511 521 if (defined $usrprojpass) {
512 522 $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id.":".$access_mode, $pass_digest);
513 523 } else {
514 524 if ($cfg->{RedmineCacheCredsCount} < $cfg->{RedmineCacheCredsMax}) {
515 525 $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id.":".$access_mode, $pass_digest);
516 526 $cfg->{RedmineCacheCredsCount}++;
517 527 } else {
518 528 $cfg->{RedmineCacheCreds}->clear();
519 529 $cfg->{RedmineCacheCredsCount} = 0;
520 530 }
521 531 }
522 532 }
523 533
524 534 $ret;
525 535 }
526 536
527 537 sub get_project_identifier {
528 538 my $r = shift;
529 539
530 540 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
531 541 my $location = $r->location;
532 542 $location =~ s/\.git$// if (defined $cfg->{RedmineGitSmartHttp} and $cfg->{RedmineGitSmartHttp});
533 543 my ($identifier) = $r->uri =~ m{$location/*([^/.]+)};
534 544 $identifier;
535 545 }
536 546
537 547 sub connect_database {
538 548 my $r = shift;
539 549
540 550 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
541 551 return DBI->connect($cfg->{RedmineDSN}, $cfg->{RedmineDbUser}, $cfg->{RedmineDbPass});
542 552 }
543 553
544 554 1;
@@ -1,1193 +1,1196
1 1 html {overflow-y:scroll;}
2 2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
3 3
4 4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
5 5 #content h1, h2, h3, h4 {color: #555;}
6 6 h2, .wiki h1 {font-size: 20px;}
7 7 h3, .wiki h2 {font-size: 16px;}
8 8 h4, .wiki h3 {font-size: 13px;}
9 9 h4 {border-bottom: 1px dotted #bbb;}
10 10
11 11 /***** Layout *****/
12 12 #wrapper {background: white;}
13 13
14 14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
15 15 #top-menu ul {margin: 0; padding: 0;}
16 16 #top-menu li {
17 17 float:left;
18 18 list-style-type:none;
19 19 margin: 0px 0px 0px 0px;
20 20 padding: 0px 0px 0px 0px;
21 21 white-space:nowrap;
22 22 }
23 23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
24 24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
25 25
26 26 #account {float:right;}
27 27
28 28 #header {min-height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 20px 6px; position:relative;}
29 29 #header a {color:#f8f8f8;}
30 30 #header h1 a.ancestor { font-size: 80%; }
31 31 #quick-search {float:right;}
32 32
33 33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
34 34 #main-menu ul {margin: 0; padding: 0;}
35 35 #main-menu li {
36 36 float:left;
37 37 list-style-type:none;
38 38 margin: 0px 2px 0px 0px;
39 39 padding: 0px 0px 0px 0px;
40 40 white-space:nowrap;
41 41 }
42 42 #main-menu li a {
43 43 display: block;
44 44 color: #fff;
45 45 text-decoration: none;
46 46 font-weight: bold;
47 47 margin: 0;
48 48 padding: 4px 10px 4px 10px;
49 49 }
50 50 #main-menu li a:hover {background:#759FCF; color:#fff;}
51 51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
52 52
53 53 #admin-menu ul {margin: 0; padding: 0;}
54 54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
55 55
56 56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
57 57 #admin-menu a.projects { background-image: url(../images/projects.png); }
58 58 #admin-menu a.users { background-image: url(../images/user.png); }
59 59 #admin-menu a.groups { background-image: url(../images/group.png); }
60 60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
61 61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
62 62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
63 63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
64 64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
65 65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
66 66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
67 67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
68 68 #admin-menu a.info { background-image: url(../images/help.png); }
69 69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
70 70
71 71 #main {background-color:#EEEEEE;}
72 72
73 73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
74 74 * html #sidebar{ width: 22%; }
75 75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
76 76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
77 77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
78 78 #sidebar .contextual { margin-right: 1em; }
79 79 #sidebar ul {margin: 0; padding: 0;}
80 80 #sidebar ul li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
81 81
82 82 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
83 83 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
84 84 html>body #content { min-height: 600px; }
85 85 * html body #content { height: 600px; } /* IE */
86 86
87 87 #main.nosidebar #sidebar{ display: none; }
88 88 #main.nosidebar #content{ width: auto; border-right: 0; }
89 89
90 90 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
91 91
92 92 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
93 93 #login-form table td {padding: 6px;}
94 94 #login-form label {font-weight: bold;}
95 95 #login-form input#username, #login-form input#password { width: 300px; }
96 96
97 97 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
98 98 div.modal h3.title {display:none;}
99 99 div.modal p.buttons {text-align:right; margin-bottom:0;}
100 100
101 101 input#openid_url { background: url(../images/openid-bg.gif) no-repeat; background-color: #fff; background-position: 0 50%; padding-left: 18px; }
102 102
103 103 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
104 104
105 105 /***** Links *****/
106 106 a, a:link, a:visited{ color: #169; text-decoration: none; }
107 107 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
108 108 a img{ border: 0; }
109 109
110 110 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
111 111 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
112 112 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
113 113
114 114 #sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
115 115 #sidebar a.selected:hover {text-decoration:none;}
116 116 #admin-menu a {line-height:1.7em;}
117 117 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
118 118
119 119 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
120 120 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
121 121
122 122 a#toggle-completed-versions {color:#999;}
123 123 /***** Tables *****/
124 124 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
125 125 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
126 126 table.list td {text-align:center; vertical-align:top; padding-right:10px;}
127 127 table.list td.id { width: 2%; text-align: center;}
128 128 table.list td.name, table.list td.description, table.list td.subject, table.list td.comments, table.list td.roles {text-align: left;}
129 129 table.list td.tick {width:15%}
130 130 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
131 131 table.list td.checkbox input {padding:0px;}
132 132 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
133 133 table.list td.buttons a { padding-right: 0.6em; }
134 134 table.list td.reorder {width:15%; white-space:nowrap; text-align:center; }
135 135 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
136 136
137 137 tr.project td.name a { white-space:nowrap; }
138 138 tr.project.closed, tr.project.archived { color: #aaa; }
139 139 tr.project.closed a, tr.project.archived a { color: #aaa; }
140 140
141 141 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
142 142 tr.project.idnt-1 td.name {padding-left: 0.5em;}
143 143 tr.project.idnt-2 td.name {padding-left: 2em;}
144 144 tr.project.idnt-3 td.name {padding-left: 3.5em;}
145 145 tr.project.idnt-4 td.name {padding-left: 5em;}
146 146 tr.project.idnt-5 td.name {padding-left: 6.5em;}
147 147 tr.project.idnt-6 td.name {padding-left: 8em;}
148 148 tr.project.idnt-7 td.name {padding-left: 9.5em;}
149 149 tr.project.idnt-8 td.name {padding-left: 11em;}
150 150 tr.project.idnt-9 td.name {padding-left: 12.5em;}
151 151
152 152 tr.issue { text-align: center; white-space: nowrap; }
153 153 tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; }
154 154 tr.issue td.relations { text-align: left; }
155 155 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
156 156 tr.issue td.relations span {white-space: nowrap;}
157 157 table.issues td.description {color:#777; font-size:90%; padding:4px 4px 4px 24px; text-align:left; white-space:normal;}
158 158 table.issues td.description pre {white-space:normal;}
159 159
160 160 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
161 161 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
162 162 tr.issue.idnt-2 td.subject {padding-left: 2em;}
163 163 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
164 164 tr.issue.idnt-4 td.subject {padding-left: 5em;}
165 165 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
166 166 tr.issue.idnt-6 td.subject {padding-left: 8em;}
167 167 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
168 168 tr.issue.idnt-8 td.subject {padding-left: 11em;}
169 169 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
170 170
171 171 table.issue-report {table-layout:fixed;}
172 172
173 173 tr.entry { border: 1px solid #f8f8f8; }
174 174 tr.entry td { white-space: nowrap; }
175 175 tr.entry td.filename {width:30%; text-align:left;}
176 176 tr.entry td.filename_no_report {width:70%; text-align:left;}
177 177 tr.entry td.size { text-align: right; font-size: 90%; }
178 178 tr.entry td.revision, tr.entry td.author { text-align: center; }
179 179 tr.entry td.age { text-align: right; }
180 180 tr.entry.file td.filename a { margin-left: 16px; }
181 181 tr.entry.file td.filename_no_report a { margin-left: 16px; }
182 182
183 183 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
184 184 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
185 185
186 186 tr.changeset { height: 20px }
187 187 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
188 188 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
189 189 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
190 190 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
191 191
192 192 table.files tbody th {text-align:left;}
193 193 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
194 194 table.files tr.file td.digest { font-size: 80%; }
195 195
196 196 table.members td.roles, table.memberships td.roles { width: 45%; }
197 197
198 198 tr.message { height: 2.6em; }
199 199 tr.message td.subject { padding-left: 20px; }
200 200 tr.message td.created_on { white-space: nowrap; }
201 201 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
202 202 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
203 203 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
204 204
205 205 tr.version.closed, tr.version.closed a { color: #999; }
206 206 tr.version td.name { padding-left: 20px; }
207 207 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
208 208 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
209 209
210 210 tr.user td {width:13%;white-space: nowrap;}
211 211 tr.user td.username, tr.user td.firstname, tr.user td.lastname, tr.user td.email {text-align:left;}
212 212 tr.user td.email { width:18%; }
213 213 tr.user.locked, tr.user.registered { color: #aaa; }
214 214 tr.user.locked a, tr.user.registered a { color: #aaa; }
215 215
216 216 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
217 217
218 218 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
219 219
220 220 tr.time-entry { text-align: center; white-space: nowrap; }
221 221 tr.time-entry td.issue, tr.time-entry td.comments { text-align: left; white-space: normal; }
222 222 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
223 223 td.hours .hours-dec { font-size: 0.9em; }
224 224
225 225 table.plugins td { vertical-align: middle; }
226 226 table.plugins td.configure { text-align: right; padding-right: 1em; }
227 227 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
228 228 table.plugins span.description { display: block; font-size: 0.9em; }
229 229 table.plugins span.url { display: block; font-size: 0.9em; }
230 230
231 231 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; text-align:left; }
232 232 table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
233 233 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
234 234 tr.group:hover a.toggle-all { display:inline;}
235 235 a.toggle-all:hover {text-decoration:none;}
236 236
237 237 table.list tbody tr:hover { background-color:#ffffdd; }
238 238 table.list tbody tr.group:hover { background-color:inherit; }
239 239 table td {padding:2px;}
240 240 table p {margin:0;}
241 241 .odd {background-color:#f6f7f8;}
242 242 .even {background-color: #fff;}
243 243
244 tr.builtin td.name {font-style:italic;}
245
244 246 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
245 247 a.sort.asc { background-image: url(../images/sort_asc.png); }
246 248 a.sort.desc { background-image: url(../images/sort_desc.png); }
247 249
248 250 table.attributes { width: 100% }
249 251 table.attributes th { vertical-align: top; text-align: left; }
250 252 table.attributes td { vertical-align: top; }
251 253
252 254 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
253 255 table.boards td.last-message {text-align:left;font-size:80%;}
254 256
255 257 table.messages td.last_message {text-align:left;}
256 258
257 259 #query_form_content {font-size:90%;}
258 260
259 261 table.query-columns {
260 262 border-collapse: collapse;
261 263 border: 0;
262 264 }
263 265
264 266 table.query-columns td.buttons {
265 267 vertical-align: middle;
266 268 text-align: center;
267 269 }
268 270 table.query-columns td.buttons input[type=button] {width:35px;}
269 271
270 272 td.center {text-align:center;}
271 273
272 274 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
273 275
274 276 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
275 277 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
276 278 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
277 279 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
278 280
279 281 #watchers select {width: 95%; display: block;}
280 282 #watchers a.delete {opacity: 0.4; vertical-align: middle;}
281 283 #watchers a.delete:hover {opacity: 1;}
282 284 #watchers img.gravatar {margin: 0 4px 2px 0;}
283 285
284 286 span#watchers_inputs {overflow:auto; display:block;}
285 287 span.search_for_watchers {display:block;}
286 288 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
287 289 span.search_for_watchers a, span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
288 290
289 291
290 292 .highlight { background-color: #FCFD8D;}
291 293 .highlight.token-1 { background-color: #faa;}
292 294 .highlight.token-2 { background-color: #afa;}
293 295 .highlight.token-3 { background-color: #aaf;}
294 296
295 297 .box{
296 298 padding:6px;
297 299 margin-bottom: 10px;
298 300 background-color:#f6f6f6;
299 301 color:#505050;
300 302 line-height:1.5em;
301 303 border: 1px solid #e4e4e4;
302 304 word-wrap: break-word;
303 305 }
304 306
305 307 div.square {
306 308 border: 1px solid #999;
307 309 float: left;
308 310 margin: .3em .4em 0 .4em;
309 311 overflow: hidden;
310 312 width: .6em; height: .6em;
311 313 }
312 314 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
313 315 .contextual input, .contextual select {font-size:0.9em;}
314 316 .message .contextual { margin-top: 0; }
315 317
316 318 .splitcontent {overflow:auto;}
317 319 .splitcontentleft{float:left; width:49%;}
318 320 .splitcontentright{float:right; width:49%;}
319 321 form {display: inline;}
320 322 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
321 323 fieldset {border: 1px solid #e4e4e4; margin:0;}
322 324 legend {color: #484848;}
323 325 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
324 326 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
325 327 blockquote blockquote { margin-left: 0;}
326 328 abbr { border-bottom: 1px dotted; cursor: help; }
327 329 textarea.wiki-edit {width:99%; resize:vertical;}
328 330 li p {margin-top: 0;}
329 331 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7; word-wrap: break-word;}
330 332 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
331 333 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
332 334 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
333 335
334 336 div.issue div.subject div div { padding-left: 16px; }
335 337 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
336 338 div.issue div.subject>div>p { margin-top: 0.5em; }
337 339 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
338 340 div.issue span.private, div.journal span.private { position:relative; bottom: 2px; text-transform: uppercase; background: #d22; color: #fff; font-weight:bold; padding: 0px 2px 0px 2px; font-size: 60%; margin-right: 2px; border-radius: 2px;}
339 341 div.issue .next-prev-links {color:#999;}
340 342 div.issue table.attributes th {width:22%;}
341 343 div.issue table.attributes td {width:28%;}
342 344
343 345 #issue_tree table.issues, #relations table.issues { border: 0; }
344 346 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
345 347 #relations td.buttons {padding:0;}
346 348
347 349 fieldset.collapsible {border-width: 1px 0 0 0;}
348 350 fieldset.collapsible>legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
349 351 fieldset.collapsible.collapsed>legend { background-image: url(../images/arrow_collapsed.png); }
350 352
351 353 fieldset#date-range p { margin: 2px 0 2px 0; }
352 354 fieldset#filters table { border-collapse: collapse; }
353 355 fieldset#filters table td { padding: 0; vertical-align: middle; }
354 356 fieldset#filters tr.filter { height: 2.1em; }
355 357 fieldset#filters td.field { width:230px; }
356 358 fieldset#filters td.operator { width:180px; }
357 359 fieldset#filters td.operator select {max-width:170px;}
358 360 fieldset#filters td.values { white-space:nowrap; }
359 361 fieldset#filters td.values select {min-width:130px;}
360 362 fieldset#filters td.values input {height:1em;}
361 363 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
362 364
363 365 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
364 366 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
365 367
366 368 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
367 369 div#issue-changesets div.changeset { padding: 4px;}
368 370 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
369 371 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
370 372
371 373 .journal ul.details img {margin:0 0 -3px 4px;}
372 374 div.journal {overflow:auto;}
373 375 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
374 376
375 377 div#activity dl, #search-results { margin-left: 2em; }
376 378 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
377 379 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
378 380 div#activity dt.me .time { border-bottom: 1px solid #999; }
379 381 div#activity dt .time { color: #777; font-size: 80%; }
380 382 div#activity dd .description, #search-results dd .description { font-style: italic; }
381 383 div#activity span.project:after, #search-results span.project:after { content: " -"; }
382 384 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
383 385 div#activity dt.grouped {margin-left:5em;}
384 386 div#activity dd.grouped {margin-left:9em;}
385 387
386 388 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
387 389
388 390 div#search-results-counts {float:right;}
389 391 div#search-results-counts ul { margin-top: 0.5em; }
390 392 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
391 393
392 394 dt.issue { background-image: url(../images/ticket.png); }
393 395 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
394 396 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
395 397 dt.issue-note { background-image: url(../images/ticket_note.png); }
396 398 dt.changeset { background-image: url(../images/changeset.png); }
397 399 dt.news { background-image: url(../images/news.png); }
398 400 dt.message { background-image: url(../images/message.png); }
399 401 dt.reply { background-image: url(../images/comments.png); }
400 402 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
401 403 dt.attachment { background-image: url(../images/attachment.png); }
402 404 dt.document { background-image: url(../images/document.png); }
403 405 dt.project { background-image: url(../images/projects.png); }
404 406 dt.time-entry { background-image: url(../images/time.png); }
405 407
406 408 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
407 409
408 410 div#roadmap .related-issues { margin-bottom: 1em; }
409 411 div#roadmap .related-issues td.checkbox { display: none; }
410 412 div#roadmap .wiki h1:first-child { display: none; }
411 413 div#roadmap .wiki h1 { font-size: 120%; }
412 414 div#roadmap .wiki h2 { font-size: 110%; }
413 415 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
414 416
415 417 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
416 418 div#version-summary fieldset { margin-bottom: 1em; }
417 419 div#version-summary fieldset.time-tracking table { width:100%; }
418 420 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
419 421
420 422 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
421 423 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
422 424 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
423 425 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
424 426 table#time-report .hours-dec { font-size: 0.9em; }
425 427
426 428 div.wiki-page .contextual a {opacity: 0.4}
427 429 div.wiki-page .contextual a:hover {opacity: 1}
428 430
429 431 form .attributes select { width: 60%; }
430 432 input#issue_subject { width: 99%; }
431 433 select#issue_done_ratio { width: 95px; }
432 434
433 435 ul.projects {margin:0; padding-left:1em;}
434 436 ul.projects ul {padding-left:1.6em;}
435 437 ul.projects.root {margin:0; padding:0;}
436 438 ul.projects li {list-style-type:none;}
437 439
438 440 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
439 441 #projects-index ul.projects li.root {margin-bottom: 1em;}
440 442 #projects-index ul.projects li.child {margin-top: 1em;}
441 443 #projects-index ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
442 444 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
443 445
444 446 #notified-projects>ul, #tracker_project_ids>ul, #custom_field_project_ids>ul {max-height:250px; overflow-y:auto;}
445 447
446 448 #related-issues li img {vertical-align:middle;}
447 449
448 450 ul.properties {padding:0; font-size: 0.9em; color: #777;}
449 451 ul.properties li {list-style-type:none;}
450 452 ul.properties li span {font-style:italic;}
451 453
452 454 .total-hours { font-size: 110%; font-weight: bold; }
453 455 .total-hours span.hours-int { font-size: 120%; }
454 456
455 457 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
456 458 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
457 459
458 460 #workflow_copy_form select { width: 200px; }
459 461 table.transitions td.enabled {background: #bfb;}
460 462 #workflow_form table select {font-size:90%; max-width:100px;}
461 463 table.fields_permissions td.readonly {background:#ddd;}
462 464 table.fields_permissions td.required {background:#d88;}
463 465
464 466 select.expandable {vertical-align:top;}
465 467
466 468 textarea#custom_field_possible_values {width: 95%; resize:vertical}
467 469 textarea#custom_field_default_value {width: 95%; resize:vertical}
468 470
469 471 input#content_comments {width: 99%}
470 472
471 473 p.pagination {margin-top:8px; font-size: 90%}
472 474
473 475 /***** Tabular forms ******/
474 476 .tabular p{
475 477 margin: 0;
476 478 padding: 3px 0 3px 0;
477 479 padding-left: 180px; /* width of left column containing the label elements */
478 480 min-height: 1.8em;
479 481 clear:left;
480 482 }
481 483
482 484 html>body .tabular p {overflow:hidden;}
483 485
484 486 .tabular input, .tabular select {max-width:95%}
485 487 .tabular textarea {width:95%; resize:vertical;}
486 488 .tabular span[title] {border-bottom:1px dotted #aaa;}
487 489
488 490 .tabular label{
489 491 font-weight: bold;
490 492 float: left;
491 493 text-align: right;
492 494 /* width of left column */
493 495 margin-left: -180px;
494 496 /* width of labels. Should be smaller than left column to create some right margin */
495 497 width: 175px;
496 498 }
497 499
498 500 .tabular label.floating{
499 501 font-weight: normal;
500 502 margin-left: 0px;
501 503 text-align: left;
502 504 width: 270px;
503 505 }
504 506
505 507 .tabular label.block{
506 508 font-weight: normal;
507 509 margin-left: 0px !important;
508 510 text-align: left;
509 511 float: none;
510 512 display: block;
511 513 width: auto !important;
512 514 }
513 515
514 516 .tabular label.inline{
515 517 font-weight: normal;
516 518 float:none;
517 519 margin-left: 5px !important;
518 520 width: auto;
519 521 }
520 522
521 523 label.no-css {
522 524 font-weight: inherit;
523 525 float:none;
524 526 text-align:left;
525 527 margin-left:0px;
526 528 width:auto;
527 529 }
528 530 input#time_entry_comments { width: 90%;}
529 531
530 532 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
531 533
532 534 .tabular.settings p{ padding-left: 300px; }
533 535 .tabular.settings label{ margin-left: -300px; width: 295px; }
534 536 .tabular.settings textarea { width: 99%; }
535 537
536 538 .settings.enabled_scm table {width:100%}
537 539 .settings.enabled_scm td.scm_name{ font-weight: bold; }
538 540
539 541 fieldset.settings label { display: block; }
540 542 fieldset#notified_events .parent { padding-left: 20px; }
541 543
542 544 span.required {color: #bb0000;}
543 545 .summary {font-style: italic;}
544 546
545 547 .check_box_group {
546 548 display:block;
547 549 width:95%;
548 550 max-height:300px;
549 551 overflow-y:auto;
550 552 padding:2px 4px 4px 2px;
551 553 background:#fff;
552 554 border:1px solid #9EB1C2;
553 555 border-radius:2px
554 556 }
555 557 .check_box_group label {
556 558 font-weight: normal;
557 559 margin-left: 0px !important;
558 560 text-align: left;
559 561 float: none;
560 562 display: block;
561 563 width: auto;
562 564 }
563 565 .check_box_group.bool_cf {border:0; background:inherit;}
564 566 .check_box_group.bool_cf label {display: inline;}
565 567
566 568 #attachments_fields input.description {margin-left:4px; width:340px;}
567 569 #attachments_fields span {display:block; white-space:nowrap;}
568 570 #attachments_fields input.filename {border:0; height:1.8em; width:250px; color:#555; background-color:inherit; background:url(../images/attachment.png) no-repeat 1px 50%; padding-left:18px;}
569 571 #attachments_fields .ajax-waiting input.filename {background:url(../images/hourglass.png) no-repeat 0px 50%;}
570 572 #attachments_fields .ajax-loading input.filename {background:url(../images/loading.gif) no-repeat 0px 50%;}
571 573 #attachments_fields div.ui-progressbar { width: 100px; height:14px; margin: 2px 0 -5px 8px; display: inline-block; }
572 574 a.remove-upload {background: url(../images/delete.png) no-repeat 1px 50%; width:1px; display:inline-block; padding-left:16px;}
573 575 a.remove-upload:hover {text-decoration:none !important;}
574 576
575 577 div.fileover { background-color: lavender; }
576 578
577 579 div.attachments { margin-top: 12px; }
578 580 div.attachments p { margin:4px 0 2px 0; }
579 581 div.attachments img { vertical-align: middle; }
580 582 div.attachments span.author { font-size: 0.9em; color: #888; }
581 583
582 584 div.thumbnails {margin-top:0.6em;}
583 585 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
584 586 div.thumbnails img {margin: 3px;}
585 587
586 588 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
587 589 .other-formats span + span:before { content: "| "; }
588 590
589 591 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
590 592
591 593 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
592 594 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
593 595
594 596 textarea.text_cf {width:95%; resize:vertical;}
595 597 input.string_cf, input.link_cf {width:95%;}
596 598 select.bool_cf {width:auto !important;}
597 599
598 600 #tab-content-modules fieldset p {margin:3px 0 4px 0;}
599 601
600 602 #tab-content-members .splitcontentleft, #tab-content-memberships .splitcontentleft, #tab-content-users .splitcontentleft {width: 64%;}
601 603 #tab-content-members .splitcontentright, #tab-content-memberships .splitcontentright, #tab-content-users .splitcontentright {width: 34%;}
602 604 #tab-content-members fieldset, #tab-content-memberships fieldset, #tab-content-users fieldset {padding:1em; margin-bottom: 1em;}
603 605 #tab-content-members fieldset legend, #tab-content-memberships fieldset legend, #tab-content-users fieldset legend {font-weight: bold;}
604 606 #tab-content-members fieldset label, #tab-content-memberships fieldset label, #tab-content-users fieldset label {display: block;}
605 607 #tab-content-members #principals, #tab-content-users #principals {max-height: 400px; overflow: auto;}
606 608
607 609 #tab-content-memberships .splitcontentright select {width:90%}
608 610
609 611 #users_for_watcher {height: 200px; overflow:auto;}
610 612 #users_for_watcher label {display: block;}
611 613
612 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
614 table.members td.name {padding-left: 20px;}
615 table.members td.group, table.members td.groupnonmember, table.members td.groupanonymous {background: url(../images/group.png) no-repeat 0% 1px;}
613 616
614 617 input#principal_search, input#user_search {width:90%}
615 618
616 619 input.autocomplete {
617 620 background: #fff url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px !important;
618 621 border:1px solid #9EB1C2; border-radius:2px; height:1.5em;
619 622 }
620 623 input.autocomplete.ajax-loading {
621 624 background-image: url(../images/loading.gif);
622 625 }
623 626
624 627 .role-visibility {padding-left:2em;}
625 628
626 629 /***** Flash & error messages ****/
627 630 #errorExplanation, div.flash, .nodata, .warning, .conflict {
628 631 padding: 4px 4px 4px 30px;
629 632 margin-bottom: 12px;
630 633 font-size: 1.1em;
631 634 border: 2px solid;
632 635 }
633 636
634 637 div.flash {margin-top: 8px;}
635 638
636 639 div.flash.error, #errorExplanation {
637 640 background: url(../images/exclamation.png) 8px 50% no-repeat;
638 641 background-color: #ffe3e3;
639 642 border-color: #dd0000;
640 643 color: #880000;
641 644 }
642 645
643 646 div.flash.notice {
644 647 background: url(../images/true.png) 8px 5px no-repeat;
645 648 background-color: #dfffdf;
646 649 border-color: #9fcf9f;
647 650 color: #005f00;
648 651 }
649 652
650 653 div.flash.warning, .conflict {
651 654 background: url(../images/warning.png) 8px 5px no-repeat;
652 655 background-color: #FFEBC1;
653 656 border-color: #FDBF3B;
654 657 color: #A6750C;
655 658 text-align: left;
656 659 }
657 660
658 661 .nodata, .warning {
659 662 text-align: center;
660 663 background-color: #FFEBC1;
661 664 border-color: #FDBF3B;
662 665 color: #A6750C;
663 666 }
664 667
665 668 #errorExplanation ul { font-size: 0.9em;}
666 669 #errorExplanation h2, #errorExplanation p { display: none; }
667 670
668 671 .conflict-details {font-size:80%;}
669 672
670 673 /***** Ajax indicator ******/
671 674 #ajax-indicator {
672 675 position: absolute; /* fixed not supported by IE */
673 676 background-color:#eee;
674 677 border: 1px solid #bbb;
675 678 top:35%;
676 679 left:40%;
677 680 width:20%;
678 681 font-weight:bold;
679 682 text-align:center;
680 683 padding:0.6em;
681 684 z-index:100;
682 685 opacity: 0.5;
683 686 }
684 687
685 688 html>body #ajax-indicator { position: fixed; }
686 689
687 690 #ajax-indicator span {
688 691 background-position: 0% 40%;
689 692 background-repeat: no-repeat;
690 693 background-image: url(../images/loading.gif);
691 694 padding-left: 26px;
692 695 vertical-align: bottom;
693 696 }
694 697
695 698 /***** Calendar *****/
696 699 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
697 700 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
698 701 table.cal thead th.week-number {width: auto;}
699 702 table.cal tbody tr {height: 100px;}
700 703 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
701 704 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
702 705 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
703 706 table.cal td.odd p.day-num {color: #bbb;}
704 707 table.cal td.today {background:#ffffdd;}
705 708 table.cal td.today p.day-num {font-weight: bold;}
706 709 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
707 710 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
708 711 table.cal .starting.ending a, p.cal.legend .starting.ending {background: url(../images/bullet_diamond.png) no-repeat -1px -2px; padding-left:16px;}
709 712 p.cal.legend span {display:block;}
710 713
711 714 /***** Tooltips ******/
712 715 .tooltip{position:relative;z-index:24;}
713 716 .tooltip:hover{z-index:25;color:#000;}
714 717 .tooltip span.tip{display: none; text-align:left;}
715 718
716 719 div.tooltip:hover span.tip{
717 720 display:block;
718 721 position:absolute;
719 722 top:12px; left:24px; width:270px;
720 723 border:1px solid #555;
721 724 background-color:#fff;
722 725 padding: 4px;
723 726 font-size: 0.8em;
724 727 color:#505050;
725 728 }
726 729
727 730 img.ui-datepicker-trigger {
728 731 cursor: pointer;
729 732 vertical-align: middle;
730 733 margin-left: 4px;
731 734 }
732 735
733 736 /***** Progress bar *****/
734 737 table.progress {
735 738 border-collapse: collapse;
736 739 border-spacing: 0pt;
737 740 empty-cells: show;
738 741 text-align: center;
739 742 float:left;
740 743 margin: 1px 6px 1px 0px;
741 744 }
742 745
743 746 table.progress td { height: 1em; }
744 747 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
745 748 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
746 749 table.progress td.todo { background: #eee none repeat scroll 0%; }
747 750 p.percent {font-size: 80%;}
748 751 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
749 752
750 753 #roadmap table.progress td { height: 1.2em; }
751 754 /***** Tabs *****/
752 755 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
753 756 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
754 757 #content .tabs ul li {
755 758 float:left;
756 759 list-style-type:none;
757 760 white-space:nowrap;
758 761 margin-right:4px;
759 762 background:#fff;
760 763 position:relative;
761 764 margin-bottom:-1px;
762 765 }
763 766 #content .tabs ul li a{
764 767 display:block;
765 768 font-size: 0.9em;
766 769 text-decoration:none;
767 770 line-height:1.3em;
768 771 padding:4px 6px 4px 6px;
769 772 border: 1px solid #ccc;
770 773 border-bottom: 1px solid #bbbbbb;
771 774 background-color: #f6f6f6;
772 775 color:#999;
773 776 font-weight:bold;
774 777 border-top-left-radius:3px;
775 778 border-top-right-radius:3px;
776 779 }
777 780
778 781 #content .tabs ul li a:hover {
779 782 background-color: #ffffdd;
780 783 text-decoration:none;
781 784 }
782 785
783 786 #content .tabs ul li a.selected {
784 787 background-color: #fff;
785 788 border: 1px solid #bbbbbb;
786 789 border-bottom: 1px solid #fff;
787 790 color:#444;
788 791 }
789 792
790 793 #content .tabs ul li a.selected:hover {background-color: #fff;}
791 794
792 795 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
793 796
794 797 button.tab-left, button.tab-right {
795 798 font-size: 0.9em;
796 799 cursor: pointer;
797 800 height:24px;
798 801 border: 1px solid #ccc;
799 802 border-bottom: 1px solid #bbbbbb;
800 803 position:absolute;
801 804 padding:4px;
802 805 width: 20px;
803 806 bottom: -1px;
804 807 }
805 808
806 809 button.tab-left {
807 810 right: 20px;
808 811 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
809 812 border-top-left-radius:3px;
810 813 }
811 814
812 815 button.tab-right {
813 816 right: 0;
814 817 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
815 818 border-top-right-radius:3px;
816 819 }
817 820
818 821 /***** Diff *****/
819 822 .diff_out { background: #fcc; }
820 823 .diff_out span { background: #faa; }
821 824 .diff_in { background: #cfc; }
822 825 .diff_in span { background: #afa; }
823 826
824 827 .text-diff {
825 828 padding: 1em;
826 829 background-color:#f6f6f6;
827 830 color:#505050;
828 831 border: 1px solid #e4e4e4;
829 832 }
830 833
831 834 /***** Wiki *****/
832 835 div.wiki table {
833 836 border-collapse: collapse;
834 837 margin-bottom: 1em;
835 838 }
836 839
837 840 div.wiki table, div.wiki td, div.wiki th {
838 841 border: 1px solid #bbb;
839 842 padding: 4px;
840 843 }
841 844
842 845 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
843 846
844 847 div.wiki .external {
845 848 background-position: 0% 60%;
846 849 background-repeat: no-repeat;
847 850 padding-left: 12px;
848 851 background-image: url(../images/external.png);
849 852 }
850 853
851 854 div.wiki a.new {color: #b73535;}
852 855
853 856 div.wiki ul, div.wiki ol {margin-bottom:1em;}
854 857
855 858 div.wiki pre {
856 859 margin: 1em 1em 1em 1.6em;
857 860 padding: 8px;
858 861 background-color: #fafafa;
859 862 border: 1px solid #e2e2e2;
860 863 width:auto;
861 864 overflow-x: auto;
862 865 overflow-y: hidden;
863 866 }
864 867
865 868 div.wiki ul.toc {
866 869 background-color: #ffffdd;
867 870 border: 1px solid #e4e4e4;
868 871 padding: 4px;
869 872 line-height: 1.2em;
870 873 margin-bottom: 12px;
871 874 margin-right: 12px;
872 875 margin-left: 0;
873 876 display: table
874 877 }
875 878 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
876 879
877 880 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
878 881 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
879 882 div.wiki ul.toc ul { margin: 0; padding: 0; }
880 883 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
881 884 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
882 885 div.wiki ul.toc a {
883 886 font-size: 0.9em;
884 887 font-weight: normal;
885 888 text-decoration: none;
886 889 color: #606060;
887 890 }
888 891 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
889 892
890 893 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
891 894 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
892 895 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
893 896
894 897 div.wiki img {vertical-align:middle; max-width:100%;}
895 898
896 899 /***** My page layout *****/
897 900 .block-receiver {
898 901 border:1px dashed #c0c0c0;
899 902 margin-bottom: 20px;
900 903 padding: 15px 0 15px 0;
901 904 }
902 905
903 906 .mypage-box {
904 907 margin:0 0 20px 0;
905 908 color:#505050;
906 909 line-height:1.5em;
907 910 }
908 911
909 912 .handle {cursor: move;}
910 913
911 914 a.close-icon {
912 915 display:block;
913 916 margin-top:3px;
914 917 overflow:hidden;
915 918 width:12px;
916 919 height:12px;
917 920 background-repeat: no-repeat;
918 921 cursor:pointer;
919 922 background-image:url('../images/close.png');
920 923 }
921 924 a.close-icon:hover {background-image:url('../images/close_hl.png');}
922 925
923 926 /***** Gantt chart *****/
924 927 .gantt_hdr {
925 928 position:absolute;
926 929 top:0;
927 930 height:16px;
928 931 border-top: 1px solid #c0c0c0;
929 932 border-bottom: 1px solid #c0c0c0;
930 933 border-right: 1px solid #c0c0c0;
931 934 text-align: center;
932 935 overflow: hidden;
933 936 }
934 937
935 938 .gantt_hdr.nwday {background-color:#f1f1f1;}
936 939
937 940 .gantt_subjects { font-size: 0.8em; }
938 941 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
939 942
940 943 .task {
941 944 position: absolute;
942 945 height:8px;
943 946 font-size:0.8em;
944 947 color:#888;
945 948 padding:0;
946 949 margin:0;
947 950 line-height:16px;
948 951 white-space:nowrap;
949 952 }
950 953
951 954 .task.label {width:100%;}
952 955 .task.label.project, .task.label.version { font-weight: bold; }
953 956
954 957 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
955 958 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
956 959 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
957 960
958 961 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
959 962 .task_late.parent, .task_done.parent { height: 3px;}
960 963 .task.parent.marker.starting { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
961 964 .task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
962 965
963 966 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
964 967 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
965 968 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
966 969 .version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
967 970
968 971 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
969 972 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
970 973 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
971 974 .project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
972 975
973 976 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
974 977 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
975 978
976 979 /***** Icons *****/
977 980 .icon {
978 981 background-position: 0% 50%;
979 982 background-repeat: no-repeat;
980 983 padding-left: 20px;
981 984 padding-top: 2px;
982 985 padding-bottom: 3px;
983 986 }
984 987
985 988 .icon-add { background-image: url(../images/add.png); }
986 989 .icon-edit { background-image: url(../images/edit.png); }
987 990 .icon-copy { background-image: url(../images/copy.png); }
988 991 .icon-duplicate { background-image: url(../images/duplicate.png); }
989 992 .icon-del { background-image: url(../images/delete.png); }
990 993 .icon-move { background-image: url(../images/move.png); }
991 994 .icon-save { background-image: url(../images/save.png); }
992 995 .icon-cancel { background-image: url(../images/cancel.png); }
993 996 .icon-multiple { background-image: url(../images/table_multiple.png); }
994 997 .icon-folder { background-image: url(../images/folder.png); }
995 998 .open .icon-folder { background-image: url(../images/folder_open.png); }
996 999 .icon-package { background-image: url(../images/package.png); }
997 1000 .icon-user { background-image: url(../images/user.png); }
998 1001 .icon-projects { background-image: url(../images/projects.png); }
999 1002 .icon-help { background-image: url(../images/help.png); }
1000 1003 .icon-attachment { background-image: url(../images/attachment.png); }
1001 1004 .icon-history { background-image: url(../images/history.png); }
1002 1005 .icon-time { background-image: url(../images/time.png); }
1003 1006 .icon-time-add { background-image: url(../images/time_add.png); }
1004 1007 .icon-stats { background-image: url(../images/stats.png); }
1005 1008 .icon-warning { background-image: url(../images/warning.png); }
1006 1009 .icon-fav { background-image: url(../images/fav.png); }
1007 1010 .icon-fav-off { background-image: url(../images/fav_off.png); }
1008 1011 .icon-reload { background-image: url(../images/reload.png); }
1009 1012 .icon-lock { background-image: url(../images/locked.png); }
1010 1013 .icon-unlock { background-image: url(../images/unlock.png); }
1011 1014 .icon-checked { background-image: url(../images/true.png); }
1012 1015 .icon-details { background-image: url(../images/zoom_in.png); }
1013 1016 .icon-report { background-image: url(../images/report.png); }
1014 1017 .icon-comment { background-image: url(../images/comment.png); }
1015 1018 .icon-summary { background-image: url(../images/lightning.png); }
1016 1019 .icon-server-authentication { background-image: url(../images/server_key.png); }
1017 1020 .icon-issue { background-image: url(../images/ticket.png); }
1018 1021 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
1019 1022 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
1020 1023 .icon-passwd { background-image: url(../images/textfield_key.png); }
1021 1024 .icon-test { background-image: url(../images/bullet_go.png); }
1022 1025
1023 1026 .icon-file { background-image: url(../images/files/default.png); }
1024 1027 .icon-file.text-plain { background-image: url(../images/files/text.png); }
1025 1028 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
1026 1029 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
1027 1030 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
1028 1031 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
1029 1032 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
1030 1033 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
1031 1034 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
1032 1035 .icon-file.text-css { background-image: url(../images/files/css.png); }
1033 1036 .icon-file.text-html { background-image: url(../images/files/html.png); }
1034 1037 .icon-file.image-gif { background-image: url(../images/files/image.png); }
1035 1038 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
1036 1039 .icon-file.image-png { background-image: url(../images/files/image.png); }
1037 1040 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
1038 1041 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
1039 1042 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
1040 1043 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
1041 1044
1042 1045 img.gravatar {
1043 1046 padding: 2px;
1044 1047 border: solid 1px #d5d5d5;
1045 1048 background: #fff;
1046 1049 vertical-align: middle;
1047 1050 }
1048 1051
1049 1052 div.issue img.gravatar {
1050 1053 float: left;
1051 1054 margin: 0 6px 0 0;
1052 1055 padding: 5px;
1053 1056 }
1054 1057
1055 1058 div.issue table img.gravatar {
1056 1059 height: 14px;
1057 1060 width: 14px;
1058 1061 padding: 2px;
1059 1062 float: left;
1060 1063 margin: 0 0.5em 0 0;
1061 1064 }
1062 1065
1063 1066 h2 img.gravatar {margin: -2px 4px -4px 0;}
1064 1067 h3 img.gravatar {margin: -4px 4px -4px 0;}
1065 1068 h4 img.gravatar {margin: -6px 4px -4px 0;}
1066 1069 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1067 1070 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1068 1071 /* Used on 12px Gravatar img tags without the icon background */
1069 1072 .icon-gravatar {float: left; margin-right: 4px;}
1070 1073
1071 1074 #activity dt, .journal {clear: left;}
1072 1075
1073 1076 .journal-link {float: right;}
1074 1077
1075 1078 h2 img { vertical-align:middle; }
1076 1079
1077 1080 .hascontextmenu { cursor: context-menu; }
1078 1081
1079 1082 /* Custom JQuery styles */
1080 1083 .ui-datepicker-title select {width:70px !important; margin-top:-2px !important; margin-right:4px !important;}
1081 1084
1082 1085
1083 1086 /************* CodeRay styles *************/
1084 1087 .syntaxhl div {display: inline;}
1085 1088 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1086 1089 .syntaxhl .code pre { overflow: auto }
1087 1090 .syntaxhl .debug { color: white !important; background: blue !important; }
1088 1091
1089 1092 .syntaxhl .annotation { color:#007 }
1090 1093 .syntaxhl .attribute-name { color:#b48 }
1091 1094 .syntaxhl .attribute-value { color:#700 }
1092 1095 .syntaxhl .binary { color:#509 }
1093 1096 .syntaxhl .char .content { color:#D20 }
1094 1097 .syntaxhl .char .delimiter { color:#710 }
1095 1098 .syntaxhl .char { color:#D20 }
1096 1099 .syntaxhl .class { color:#258; font-weight:bold }
1097 1100 .syntaxhl .class-variable { color:#369 }
1098 1101 .syntaxhl .color { color:#0A0 }
1099 1102 .syntaxhl .comment { color:#385 }
1100 1103 .syntaxhl .comment .char { color:#385 }
1101 1104 .syntaxhl .comment .delimiter { color:#385 }
1102 1105 .syntaxhl .complex { color:#A08 }
1103 1106 .syntaxhl .constant { color:#258; font-weight:bold }
1104 1107 .syntaxhl .decorator { color:#B0B }
1105 1108 .syntaxhl .definition { color:#099; font-weight:bold }
1106 1109 .syntaxhl .delimiter { color:black }
1107 1110 .syntaxhl .directive { color:#088; font-weight:bold }
1108 1111 .syntaxhl .doc { color:#970 }
1109 1112 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1110 1113 .syntaxhl .doctype { color:#34b }
1111 1114 .syntaxhl .entity { color:#800; font-weight:bold }
1112 1115 .syntaxhl .error { color:#F00; background-color:#FAA }
1113 1116 .syntaxhl .escape { color:#666 }
1114 1117 .syntaxhl .exception { color:#C00; font-weight:bold }
1115 1118 .syntaxhl .float { color:#06D }
1116 1119 .syntaxhl .function { color:#06B; font-weight:bold }
1117 1120 .syntaxhl .global-variable { color:#d70 }
1118 1121 .syntaxhl .hex { color:#02b }
1119 1122 .syntaxhl .imaginary { color:#f00 }
1120 1123 .syntaxhl .include { color:#B44; font-weight:bold }
1121 1124 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1122 1125 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1123 1126 .syntaxhl .instance-variable { color:#33B }
1124 1127 .syntaxhl .integer { color:#06D }
1125 1128 .syntaxhl .key .char { color: #60f }
1126 1129 .syntaxhl .key .delimiter { color: #404 }
1127 1130 .syntaxhl .key { color: #606 }
1128 1131 .syntaxhl .keyword { color:#939; font-weight:bold }
1129 1132 .syntaxhl .label { color:#970; font-weight:bold }
1130 1133 .syntaxhl .local-variable { color:#963 }
1131 1134 .syntaxhl .namespace { color:#707; font-weight:bold }
1132 1135 .syntaxhl .octal { color:#40E }
1133 1136 .syntaxhl .operator { }
1134 1137 .syntaxhl .predefined { color:#369; font-weight:bold }
1135 1138 .syntaxhl .predefined-constant { color:#069 }
1136 1139 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1137 1140 .syntaxhl .preprocessor { color:#579 }
1138 1141 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1139 1142 .syntaxhl .regexp .content { color:#808 }
1140 1143 .syntaxhl .regexp .delimiter { color:#404 }
1141 1144 .syntaxhl .regexp .modifier { color:#C2C }
1142 1145 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1143 1146 .syntaxhl .reserved { color:#080; font-weight:bold }
1144 1147 .syntaxhl .shell .content { color:#2B2 }
1145 1148 .syntaxhl .shell .delimiter { color:#161 }
1146 1149 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1147 1150 .syntaxhl .string .char { color: #46a }
1148 1151 .syntaxhl .string .content { color: #46a }
1149 1152 .syntaxhl .string .delimiter { color: #46a }
1150 1153 .syntaxhl .string .modifier { color: #46a }
1151 1154 .syntaxhl .symbol .content { color:#d33 }
1152 1155 .syntaxhl .symbol .delimiter { color:#d33 }
1153 1156 .syntaxhl .symbol { color:#d33 }
1154 1157 .syntaxhl .tag { color:#070 }
1155 1158 .syntaxhl .type { color:#339; font-weight:bold }
1156 1159 .syntaxhl .value { color: #088; }
1157 1160 .syntaxhl .variable { color:#037 }
1158 1161
1159 1162 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1160 1163 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1161 1164 .syntaxhl .change { color: #bbf; background: #007; }
1162 1165 .syntaxhl .head { color: #f8f; background: #505 }
1163 1166 .syntaxhl .head .filename { color: white; }
1164 1167
1165 1168 .syntaxhl .delete .eyecatcher { background-color: hsla(0,100%,50%,0.2); border: 1px solid hsla(0,100%,45%,0.5); margin: -1px; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; }
1166 1169 .syntaxhl .insert .eyecatcher { background-color: hsla(120,100%,50%,0.2); border: 1px solid hsla(120,100%,25%,0.5); margin: -1px; border-top: none; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; }
1167 1170
1168 1171 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1169 1172 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1170 1173 .syntaxhl .change .change { color: #88f }
1171 1174 .syntaxhl .head .head { color: #f4f }
1172 1175
1173 1176 /***** Media print specific styles *****/
1174 1177 @media print {
1175 1178 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1176 1179 #main { background: #fff; }
1177 1180 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1178 1181 #wiki_add_attachment { display:none; }
1179 1182 .hide-when-print { display: none; }
1180 1183 .autoscroll {overflow-x: visible;}
1181 1184 table.list {margin-top:0.5em;}
1182 1185 table.list th, table.list td {border: 1px solid #aaa;}
1183 1186 }
1184 1187
1185 1188 /* Accessibility specific styles */
1186 1189 .hidden-for-sighted {
1187 1190 position:absolute;
1188 1191 left:-10000px;
1189 1192 top:auto;
1190 1193 width:1px;
1191 1194 height:1px;
1192 1195 overflow:hidden;
1193 1196 }
@@ -1,294 +1,309
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../test_case', __FILE__)
19 19 require 'tmpdir'
20 20
21 21 class RedminePmTest::RepositorySubversionTest < RedminePmTest::TestCase
22 22 fixtures :projects, :users, :members, :roles, :member_roles, :auth_sources
23 23
24 24 SVN_BIN = Redmine::Configuration['scm_subversion_command'] || "svn"
25 25
26 26 def test_anonymous_read_on_public_repo_with_permission_should_succeed
27 27 assert_success "ls", svn_url
28 28 end
29 29
30 def test_anonymous_read_on_public_repo_with_anonymous_group_permission_should_succeed
31 Role.anonymous.remove_permission! :browse_repository
32 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [2])
33 assert_success "ls", svn_url
34 end
35
30 36 def test_anonymous_read_on_public_repo_without_permission_should_fail
31 37 Role.anonymous.remove_permission! :browse_repository
32 38 assert_failure "ls", svn_url
33 39 end
34 40
35 41 def test_anonymous_read_on_private_repo_should_fail
36 42 Project.find(1).update_attribute :is_public, false
37 43 assert_failure "ls", svn_url
38 44 end
39 45
40 46 def test_anonymous_commit_on_public_repo_should_fail
41 47 Role.anonymous.add_permission! :commit_access
42 48 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
43 49 end
44 50
45 51 def test_anonymous_commit_on_private_repo_should_fail
46 52 Role.anonymous.add_permission! :commit_access
47 53 Project.find(1).update_attribute :is_public, false
48 54 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
49 55 end
50 56
51 57 def test_non_member_read_on_public_repo_with_permission_should_succeed
52 58 Role.anonymous.remove_permission! :browse_repository
53 59 with_credentials "miscuser8", "foo" do
54 60 assert_success "ls", svn_url
55 61 end
56 62 end
57 63
64 def test_non_member_read_on_public_repo_with_non_member_group_permission_should_succeed
65 Role.anonymous.remove_permission! :browse_repository
66 Role.non_member.remove_permission! :browse_repository
67 Member.create!(:project_id => 1, :principal => Group.non_member, :role_ids => [2])
68 with_credentials "miscuser8", "foo" do
69 assert_success "ls", svn_url
70 end
71 end
72
58 73 def test_non_member_read_on_public_repo_without_permission_should_fail
59 74 Role.anonymous.remove_permission! :browse_repository
60 75 Role.non_member.remove_permission! :browse_repository
61 76 with_credentials "miscuser8", "foo" do
62 77 assert_failure "ls", svn_url
63 78 end
64 79 end
65 80
66 81 def test_non_member_read_on_private_repo_should_fail
67 82 Project.find(1).update_attribute :is_public, false
68 83 with_credentials "miscuser8", "foo" do
69 84 assert_failure "ls", svn_url
70 85 end
71 86 end
72 87
73 88 def test_non_member_commit_on_public_repo_should_fail
74 89 Role.non_member.add_permission! :commit_access
75 90 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
76 91 end
77 92
78 93 def test_non_member_commit_on_private_repo_should_fail
79 94 Role.non_member.add_permission! :commit_access
80 95 Project.find(1).update_attribute :is_public, false
81 96 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
82 97 end
83 98
84 99 def test_member_read_on_public_repo_with_permission_should_succeed
85 100 Role.anonymous.remove_permission! :browse_repository
86 101 Role.non_member.remove_permission! :browse_repository
87 102 with_credentials "dlopper", "foo" do
88 103 assert_success "ls", svn_url
89 104 end
90 105 end
91 106
92 107 def test_member_read_on_public_repo_without_permission_should_fail
93 108 Role.anonymous.remove_permission! :browse_repository
94 109 Role.non_member.remove_permission! :browse_repository
95 110 Role.find(2).remove_permission! :browse_repository
96 111 with_credentials "dlopper", "foo" do
97 112 assert_failure "ls", svn_url
98 113 end
99 114 end
100 115
101 116 def test_member_read_on_private_repo_with_permission_should_succeed
102 117 Project.find(1).update_attribute :is_public, false
103 118 with_credentials "dlopper", "foo" do
104 119 assert_success "ls", svn_url
105 120 end
106 121 end
107 122
108 123 def test_member_read_on_private_repo_without_permission_should_fail
109 124 Role.find(2).remove_permission! :browse_repository
110 125 Project.find(1).update_attribute :is_public, false
111 126 with_credentials "dlopper", "foo" do
112 127 assert_failure "ls", svn_url
113 128 end
114 129 end
115 130
116 131 def test_member_commit_on_public_repo_with_permission_should_succeed
117 132 Role.find(2).add_permission! :commit_access
118 133 with_credentials "dlopper", "foo" do
119 134 assert_success "mkdir --message Creating_a_directory", svn_url(random_filename)
120 135 end
121 136 end
122 137
123 138 def test_member_commit_on_public_repo_without_permission_should_fail
124 139 Role.find(2).remove_permission! :commit_access
125 140 with_credentials "dlopper", "foo" do
126 141 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
127 142 end
128 143 end
129 144
130 145 def test_member_commit_on_private_repo_with_permission_should_succeed
131 146 Role.find(2).add_permission! :commit_access
132 147 Project.find(1).update_attribute :is_public, false
133 148 with_credentials "dlopper", "foo" do
134 149 assert_success "mkdir --message Creating_a_directory", svn_url(random_filename)
135 150 end
136 151 end
137 152
138 153 def test_member_commit_on_private_repo_without_permission_should_fail
139 154 Role.find(2).remove_permission! :commit_access
140 155 Project.find(1).update_attribute :is_public, false
141 156 with_credentials "dlopper", "foo" do
142 157 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
143 158 end
144 159 end
145 160
146 161 def test_invalid_credentials_should_fail
147 162 Project.find(1).update_attribute :is_public, false
148 163 with_credentials "dlopper", "foo" do
149 164 assert_success "ls", svn_url
150 165 end
151 166 with_credentials "dlopper", "wrong" do
152 167 assert_failure "ls", svn_url
153 168 end
154 169 end
155 170
156 171 def test_anonymous_read_should_fail_with_login_required
157 172 assert_success "ls", svn_url
158 173 with_settings :login_required => '1' do
159 174 assert_failure "ls", svn_url
160 175 end
161 176 end
162 177
163 178 def test_authenticated_read_should_succeed_with_login_required
164 179 with_settings :login_required => '1' do
165 180 with_credentials "miscuser8", "foo" do
166 181 assert_success "ls", svn_url
167 182 end
168 183 end
169 184 end
170 185
171 186 def test_read_on_archived_projects_should_fail
172 187 Project.find(1).update_attribute :status, Project::STATUS_ARCHIVED
173 188 assert_failure "ls", svn_url
174 189 end
175 190
176 191 def test_read_on_archived_private_projects_should_fail
177 192 Project.find(1).update_attribute :status, Project::STATUS_ARCHIVED
178 193 Project.find(1).update_attribute :is_public, false
179 194 with_credentials "dlopper", "foo" do
180 195 assert_failure "ls", svn_url
181 196 end
182 197 end
183 198
184 199 def test_read_on_closed_projects_should_succeed
185 200 Project.find(1).update_attribute :status, Project::STATUS_CLOSED
186 201 assert_success "ls", svn_url
187 202 end
188 203
189 204 def test_read_on_closed_private_projects_should_succeed
190 205 Project.find(1).update_attribute :status, Project::STATUS_CLOSED
191 206 Project.find(1).update_attribute :is_public, false
192 207 with_credentials "dlopper", "foo" do
193 208 assert_success "ls", svn_url
194 209 end
195 210 end
196 211
197 212 def test_commit_on_closed_projects_should_fail
198 213 Project.find(1).update_attribute :status, Project::STATUS_CLOSED
199 214 Role.find(2).add_permission! :commit_access
200 215 with_credentials "dlopper", "foo" do
201 216 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
202 217 end
203 218 end
204 219
205 220 def test_commit_on_closed_private_projects_should_fail
206 221 Project.find(1).update_attribute :status, Project::STATUS_CLOSED
207 222 Project.find(1).update_attribute :is_public, false
208 223 Role.find(2).add_permission! :commit_access
209 224 with_credentials "dlopper", "foo" do
210 225 assert_failure "mkdir --message Creating_a_directory", svn_url(random_filename)
211 226 end
212 227 end
213 228
214 229 if ldap_configured?
215 230 def test_user_with_ldap_auth_source_should_authenticate_with_ldap_credentials
216 231 ldap_user = User.new(:mail => 'example1@redmine.org', :firstname => 'LDAP', :lastname => 'user', :auth_source_id => 1)
217 232 ldap_user.login = 'example1'
218 233 ldap_user.save!
219 234
220 235 with_settings :login_required => '1' do
221 236 with_credentials "example1", "123456" do
222 237 assert_success "ls", svn_url
223 238 end
224 239 end
225 240
226 241 with_settings :login_required => '1' do
227 242 with_credentials "example1", "wrong" do
228 243 assert_failure "ls", svn_url
229 244 end
230 245 end
231 246 end
232 247 end
233 248
234 249 def test_checkout
235 250 Dir.mktmpdir do |dir|
236 251 assert_success "checkout", svn_url, dir
237 252 end
238 253 end
239 254
240 255 def test_read_commands
241 256 assert_success "info", svn_url
242 257 assert_success "ls", svn_url
243 258 assert_success "log", svn_url
244 259 end
245 260
246 261 def test_write_commands
247 262 Role.find(2).add_permission! :commit_access
248 263 filename = random_filename
249 264
250 265 Dir.mktmpdir do |dir|
251 266 assert_success "checkout", svn_url, dir
252 267 Dir.chdir(dir) do
253 268 # creates a file in the working copy
254 269 f = File.new(File.join(dir, filename), "w")
255 270 f.write "test file content"
256 271 f.close
257 272
258 273 assert_success "add", filename
259 274 with_credentials "dlopper", "foo" do
260 275 assert_success "commit --message Committing_a_file"
261 276 assert_success "copy --message Copying_a_file", svn_url(filename), svn_url("#{filename}_copy")
262 277 assert_success "delete --message Deleting_a_file", svn_url(filename)
263 278 assert_success "mkdir --message Creating_a_directory", svn_url("#{filename}_dir")
264 279 end
265 280 assert_success "update"
266 281
267 282 # checks that the working copy was updated
268 283 assert File.exists?(File.join(dir, "#{filename}_copy"))
269 284 assert File.directory?(File.join(dir, "#{filename}_dir"))
270 285 end
271 286 end
272 287 end
273 288
274 289 def test_read_invalid_repo_should_fail
275 290 assert_failure "ls", svn_url("invalid")
276 291 end
277 292
278 293 protected
279 294
280 295 def execute(*args)
281 296 a = [SVN_BIN, "--no-auth-cache --non-interactive"]
282 297 a << "--username #{username}" if username
283 298 a << "--password #{password}" if password
284 299
285 300 super a, *args
286 301 end
287 302
288 303 def svn_url(path=nil)
289 304 host = ENV['REDMINE_TEST_DAV_SERVER'] || '127.0.0.1'
290 305 url = "http://#{host}/svn/ecookbook"
291 306 url << "/#{path}" if path
292 307 url
293 308 end
294 309 end
@@ -1,81 +1,82
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 module RedminePmTest
21 21 class TestCase < ActiveSupport::TestCase
22 22 attr_reader :command, :response, :status, :username, :password
23 23
24 24 # Cannot use transactional fixtures here: database
25 25 # will be accessed from Redmine.pm with its own connection
26 26 self.use_transactional_fixtures = false
27 27
28 28 def test_dummy
29 29 end
30 30
31 31 protected
32 32
33 33 def assert_response(expected, msg=nil)
34 34 case expected
35 35 when :success
36 36 assert_equal 0, status,
37 37 (msg || "The command failed (exit: #{status}):\n #{command}\nOutput was:\n#{formatted_response}")
38 38 when :failure
39 39 assert_not_equal 0, status,
40 40 (msg || "The command succeed (exit: #{status}):\n #{command}\nOutput was:\n#{formatted_response}")
41 41 else
42 42 assert_equal expected, status, msg
43 43 end
44 44 end
45 45
46 46 def assert_success(*args)
47 47 execute *args
48 48 assert_response :success
49 49 end
50 50
51 51 def assert_failure(*args)
52 52 execute *args
53 53 assert_response :failure
54 54 end
55 55
56 56 def with_credentials(username, password)
57 57 old_username, old_password = @username, @password
58 58 @username, @password = username, password
59 59 yield if block_given?
60 60 ensure
61 61 @username, @password = old_username, old_password
62 62 end
63 63
64 64 def execute(*args)
65 65 @command = args.join(' ')
66 66 @status = nil
67 67 IO.popen("#{command} 2>&1") do |io|
68 io.set_encoding("ASCII-8BIT") if io.respond_to?(:set_encoding)
68 69 @response = io.read
69 70 end
70 71 @status = $?.exitstatus
71 72 end
72 73
73 74 def formatted_response
74 75 "#{'='*40}\n#{response}#{'='*40}"
75 76 end
76 77
77 78 def random_filename
78 79 Redmine::Utils.random_hex(16)
79 80 end
80 81 end
81 82 end
@@ -1,169 +1,180
1 1 ---
2 2 users_004:
3 3 created_on: 2006-07-19 19:34:07 +02:00
4 4 status: 1
5 5 last_login_on:
6 6 language: en
7 7 # password = foo
8 8 salt: 3126f764c3c5ac61cbfc103f25f934cf
9 9 hashed_password: 9e4dd7eeb172c12a0691a6d9d3a269f7e9fe671b
10 10 updated_on: 2006-07-19 19:34:07 +02:00
11 11 admin: false
12 12 mail: rhill@somenet.foo
13 13 lastname: Hill
14 14 firstname: Robert
15 15 id: 4
16 16 auth_source_id:
17 17 mail_notification: all
18 18 login: rhill
19 19 type: User
20 20 users_001:
21 21 created_on: 2006-07-19 19:12:21 +02:00
22 22 status: 1
23 23 last_login_on: 2006-07-19 22:57:52 +02:00
24 24 language: en
25 25 # password = admin
26 26 salt: 82090c953c4a0000a7db253b0691a6b4
27 27 hashed_password: b5b6ff9543bf1387374cdfa27a54c96d236a7150
28 28 updated_on: 2006-07-19 22:57:52 +02:00
29 29 admin: true
30 30 mail: admin@somenet.foo
31 31 lastname: Admin
32 32 firstname: Redmine
33 33 id: 1
34 34 auth_source_id:
35 35 mail_notification: all
36 36 login: admin
37 37 type: User
38 38 users_002:
39 39 created_on: 2006-07-19 19:32:09 +02:00
40 40 status: 1
41 41 last_login_on: 2006-07-19 22:42:15 +02:00
42 42 language: en
43 43 # password = jsmith
44 44 salt: 67eb4732624d5a7753dcea7ce0bb7d7d
45 45 hashed_password: bfbe06043353a677d0215b26a5800d128d5413bc
46 46 updated_on: 2006-07-19 22:42:15 +02:00
47 47 admin: false
48 48 mail: jsmith@somenet.foo
49 49 lastname: Smith
50 50 firstname: John
51 51 id: 2
52 52 auth_source_id:
53 53 mail_notification: all
54 54 login: jsmith
55 55 type: User
56 56 users_003:
57 57 created_on: 2006-07-19 19:33:19 +02:00
58 58 status: 1
59 59 last_login_on:
60 60 language: en
61 61 # password = foo
62 62 salt: 7599f9963ec07b5a3b55b354407120c0
63 63 hashed_password: 8f659c8d7c072f189374edacfa90d6abbc26d8ed
64 64 updated_on: 2006-07-19 19:33:19 +02:00
65 65 admin: false
66 66 mail: dlopper@somenet.foo
67 67 lastname: Lopper
68 68 firstname: Dave
69 69 id: 3
70 70 auth_source_id:
71 71 mail_notification: all
72 72 login: dlopper
73 73 type: User
74 74 users_005:
75 75 id: 5
76 76 created_on: 2006-07-19 19:33:19 +02:00
77 77 # Locked
78 78 status: 3
79 79 last_login_on:
80 80 language: en
81 81 hashed_password: 1
82 82 updated_on: 2006-07-19 19:33:19 +02:00
83 83 admin: false
84 84 mail: dlopper2@somenet.foo
85 85 lastname: Lopper2
86 86 firstname: Dave2
87 87 auth_source_id:
88 88 mail_notification: all
89 89 login: dlopper2
90 90 type: User
91 91 users_006:
92 92 id: 6
93 93 created_on: 2006-07-19 19:33:19 +02:00
94 94 status: 0
95 95 last_login_on:
96 96 language: ''
97 97 hashed_password: 1
98 98 updated_on: 2006-07-19 19:33:19 +02:00
99 99 admin: false
100 100 mail: ''
101 101 lastname: Anonymous
102 102 firstname: ''
103 103 auth_source_id:
104 104 mail_notification: only_my_events
105 105 login: ''
106 106 type: AnonymousUser
107 107 users_007:
108 108 # A user who does not belong to any project
109 109 id: 7
110 110 created_on: 2006-07-19 19:33:19 +02:00
111 111 status: 1
112 112 last_login_on:
113 113 language: 'en'
114 114 # password = foo
115 115 salt: 7599f9963ec07b5a3b55b354407120c0
116 116 hashed_password: 8f659c8d7c072f189374edacfa90d6abbc26d8ed
117 117 updated_on: 2006-07-19 19:33:19 +02:00
118 118 admin: false
119 119 mail: someone@foo.bar
120 120 lastname: One
121 121 firstname: Some
122 122 auth_source_id:
123 123 mail_notification: only_my_events
124 124 login: someone
125 125 type: User
126 126 users_008:
127 127 id: 8
128 128 created_on: 2006-07-19 19:33:19 +02:00
129 129 status: 1
130 130 last_login_on:
131 131 language: 'it'
132 132 # password = foo
133 133 salt: 7599f9963ec07b5a3b55b354407120c0
134 134 hashed_password: 8f659c8d7c072f189374edacfa90d6abbc26d8ed
135 135 updated_on: 2006-07-19 19:33:19 +02:00
136 136 admin: false
137 137 mail: miscuser8@foo.bar
138 138 lastname: Misc
139 139 firstname: User
140 140 auth_source_id:
141 141 mail_notification: only_my_events
142 142 login: miscuser8
143 143 type: User
144 144 users_009:
145 145 id: 9
146 146 created_on: 2006-07-19 19:33:19 +02:00
147 147 status: 1
148 148 last_login_on:
149 149 language: 'it'
150 150 hashed_password: 1
151 151 updated_on: 2006-07-19 19:33:19 +02:00
152 152 admin: false
153 153 mail: miscuser9@foo.bar
154 154 lastname: Misc
155 155 firstname: User
156 156 auth_source_id:
157 157 mail_notification: only_my_events
158 158 login: miscuser9
159 159 type: User
160 160 groups_010:
161 161 id: 10
162 162 lastname: A Team
163 163 type: Group
164 status: 1
164 165 groups_011:
165 166 id: 11
166 167 lastname: B Team
167 168 type: Group
169 status: 1
170 groups_non_member:
171 id: 12
172 lastname: Non member users
173 type: GroupNonMember
174 status: 1
175 groups_anonymous:
176 id: 13
177 lastname: Anonymous users
178 type: GroupAnonymous
179 status: 1
168 180
169
@@ -1,170 +1,200
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class Redmine::ApiTest::GroupsTest < Redmine::ApiTest::Base
21 21 fixtures :users, :groups_users
22 22
23 23 def setup
24 24 Setting.rest_api_enabled = '1'
25 25 end
26 26
27 27 test "GET /groups.xml should require authentication" do
28 28 get '/groups.xml'
29 29 assert_response 401
30 30 end
31 31
32 test "GET /groups.xml should return groups" do
32 test "GET /groups.xml should return givable groups" do
33 33 get '/groups.xml', {}, credentials('admin')
34 34 assert_response :success
35 35 assert_equal 'application/xml', response.content_type
36 36
37 37 assert_select 'groups' do
38 assert_select 'group', Group.givable.count
38 39 assert_select 'group' do
39 40 assert_select 'name', :text => 'A Team'
40 41 assert_select 'id', :text => '10'
41 42 end
42 43 end
43 44 end
44 45
46 test "GET /groups.xml?builtin=1 should return all groups" do
47 get '/groups.xml?builtin=1', {}, credentials('admin')
48 assert_response :success
49 assert_equal 'application/xml', response.content_type
50
51 assert_select 'groups' do
52 assert_select 'group', Group.givable.count + 2
53 assert_select 'group' do
54 assert_select 'builtin', :text => 'non_member'
55 assert_select 'id', :text => '12'
56 end
57 assert_select 'group' do
58 assert_select 'builtin', :text => 'anonymous'
59 assert_select 'id', :text => '13'
60 end
61 end
62 end
63
45 64 test "GET /groups.json should require authentication" do
46 65 get '/groups.json'
47 66 assert_response 401
48 67 end
49 68
50 69 test "GET /groups.json should return groups" do
51 70 get '/groups.json', {}, credentials('admin')
52 71 assert_response :success
53 72 assert_equal 'application/json', response.content_type
54 73
55 74 json = MultiJson.load(response.body)
56 75 groups = json['groups']
57 76 assert_kind_of Array, groups
58 77 group = groups.detect {|g| g['name'] == 'A Team'}
59 78 assert_not_nil group
60 79 assert_equal({'id' => 10, 'name' => 'A Team'}, group)
61 80 end
62 81
63 test "GET /groups/:id.xml should return the group with its users" do
82 test "GET /groups/:id.xml should return the group" do
64 83 get '/groups/10.xml', {}, credentials('admin')
65 84 assert_response :success
66 85 assert_equal 'application/xml', response.content_type
67 86
68 87 assert_select 'group' do
69 88 assert_select 'name', :text => 'A Team'
70 89 assert_select 'id', :text => '10'
71 90 end
72 91 end
73 92
93 test "GET /groups/:id.xml should return the builtin group" do
94 get '/groups/12.xml', {}, credentials('admin')
95 assert_response :success
96 assert_equal 'application/xml', response.content_type
97
98 assert_select 'group' do
99 assert_select 'builtin', :text => 'non_member'
100 assert_select 'id', :text => '12'
101 end
102 end
103
74 104 test "GET /groups/:id.xml should include users if requested" do
75 105 get '/groups/10.xml?include=users', {}, credentials('admin')
76 106 assert_response :success
77 107 assert_equal 'application/xml', response.content_type
78 108
79 109 assert_select 'group' do
80 110 assert_select 'users' do
81 111 assert_select 'user', Group.find(10).users.count
82 112 assert_select 'user[id=8]'
83 113 end
84 114 end
85 115 end
86 116
87 117 test "GET /groups/:id.xml include memberships if requested" do
88 118 get '/groups/10.xml?include=memberships', {}, credentials('admin')
89 119 assert_response :success
90 120 assert_equal 'application/xml', response.content_type
91 121
92 122 assert_select 'group' do
93 123 assert_select 'memberships'
94 124 end
95 125 end
96 126
97 127 test "POST /groups.xml with valid parameters should create the group" do
98 128 assert_difference('Group.count') do
99 129 post '/groups.xml', {:group => {:name => 'Test', :user_ids => [2, 3]}}, credentials('admin')
100 130 assert_response :created
101 131 assert_equal 'application/xml', response.content_type
102 132 end
103 133
104 134 group = Group.order('id DESC').first
105 135 assert_equal 'Test', group.name
106 136 assert_equal [2, 3], group.users.map(&:id).sort
107 137
108 138 assert_select 'group' do
109 139 assert_select 'name', :text => 'Test'
110 140 end
111 141 end
112 142
113 143 test "POST /groups.xml with invalid parameters should return errors" do
114 144 assert_no_difference('Group.count') do
115 145 post '/groups.xml', {:group => {:name => ''}}, credentials('admin')
116 146 end
117 147 assert_response :unprocessable_entity
118 148 assert_equal 'application/xml', response.content_type
119 149
120 150 assert_select 'errors' do
121 151 assert_select 'error', :text => /Name can't be blank/
122 152 end
123 153 end
124 154
125 155 test "PUT /groups/:id.xml with valid parameters should update the group" do
126 156 put '/groups/10.xml', {:group => {:name => 'New name', :user_ids => [2, 3]}}, credentials('admin')
127 157 assert_response :ok
128 158 assert_equal '', @response.body
129 159
130 160 group = Group.find(10)
131 161 assert_equal 'New name', group.name
132 162 assert_equal [2, 3], group.users.map(&:id).sort
133 163 end
134 164
135 165 test "PUT /groups/:id.xml with invalid parameters should return errors" do
136 166 put '/groups/10.xml', {:group => {:name => ''}}, credentials('admin')
137 167 assert_response :unprocessable_entity
138 168 assert_equal 'application/xml', response.content_type
139 169
140 170 assert_select 'errors' do
141 171 assert_select 'error', :text => /Name can't be blank/
142 172 end
143 173 end
144 174
145 175 test "DELETE /groups/:id.xml should delete the group" do
146 176 assert_difference 'Group.count', -1 do
147 177 delete '/groups/10.xml', {}, credentials('admin')
148 178 assert_response :ok
149 179 assert_equal '', @response.body
150 180 end
151 181 end
152 182
153 183 test "POST /groups/:id/users.xml should add user to the group" do
154 184 assert_difference 'Group.find(10).users.count' do
155 185 post '/groups/10/users.xml', {:user_id => 5}, credentials('admin')
156 186 assert_response :ok
157 187 assert_equal '', @response.body
158 188 end
159 189 assert_include User.find(5), Group.find(10).users
160 190 end
161 191
162 192 test "DELETE /groups/:id/users/:user_id.xml should remove user from the group" do
163 193 assert_difference 'Group.find(10).users.count', -1 do
164 194 delete '/groups/10/users/8.xml', {}, credentials('admin')
165 195 assert_response :ok
166 196 assert_equal '', @response.body
167 197 end
168 198 assert_not_include User.find(8), Group.find(10).users
169 199 end
170 200 end
@@ -1,220 +1,241
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class IssuesTest < ActionController::IntegrationTest
21 21 fixtures :projects,
22 22 :users,
23 23 :roles,
24 24 :members,
25 25 :member_roles,
26 26 :trackers,
27 27 :projects_trackers,
28 28 :enabled_modules,
29 29 :issue_statuses,
30 30 :issues,
31 31 :enumerations,
32 32 :custom_fields,
33 33 :custom_values,
34 34 :custom_fields_trackers
35 35
36 36 # create an issue
37 37 def test_add_issue
38 38 log_user('jsmith', 'jsmith')
39 39 get 'projects/1/issues/new', :tracker_id => '1'
40 40 assert_response :success
41 41 assert_template 'issues/new'
42 42
43 43 post 'projects/1/issues', :tracker_id => "1",
44 44 :issue => { :start_date => "2006-12-26",
45 45 :priority_id => "4",
46 46 :subject => "new test issue",
47 47 :category_id => "",
48 48 :description => "new issue",
49 49 :done_ratio => "0",
50 50 :due_date => "",
51 51 :assigned_to_id => "" },
52 52 :custom_fields => {'2' => 'Value for field 2'}
53 53 # find created issue
54 54 issue = Issue.find_by_subject("new test issue")
55 55 assert_kind_of Issue, issue
56 56
57 57 # check redirection
58 58 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
59 59 follow_redirect!
60 60 assert_equal issue, assigns(:issue)
61 61
62 62 # check issue attributes
63 63 assert_equal 'jsmith', issue.author.login
64 64 assert_equal 1, issue.project.id
65 65 assert_equal 1, issue.status.id
66 66 end
67 67
68 def test_create_issue_by_anonymous_without_permission_should_fail
69 Role.anonymous.remove_permission! :add_issues
70
71 assert_no_difference 'Issue.count' do
72 post 'projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
73 end
74 assert_response 302
75 end
76
77 def test_create_issue_by_anonymous_with_custom_permission_should_succeed
78 Role.anonymous.remove_permission! :add_issues
79 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [3])
80
81 assert_difference 'Issue.count' do
82 post 'projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
83 end
84 assert_response 302
85 issue = Issue.order("id DESC").first
86 assert_equal User.anonymous, issue.author
87 end
88
68 89 # add then remove 2 attachments to an issue
69 90 def test_issue_attachments
70 91 log_user('jsmith', 'jsmith')
71 92 set_tmp_attachments_directory
72 93
73 94 put 'issues/1',
74 95 :notes => 'Some notes',
75 96 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'This is an attachment'}}
76 97 assert_redirected_to "/issues/1"
77 98
78 99 # make sure attachment was saved
79 100 attachment = Issue.find(1).attachments.find_by_filename("testfile.txt")
80 101 assert_kind_of Attachment, attachment
81 102 assert_equal Issue.find(1), attachment.container
82 103 assert_equal 'This is an attachment', attachment.description
83 104 # verify the size of the attachment stored in db
84 105 #assert_equal file_data_1.length, attachment.filesize
85 106 # verify that the attachment was written to disk
86 107 assert File.exist?(attachment.diskfile)
87 108
88 109 # remove the attachments
89 110 Issue.find(1).attachments.each(&:destroy)
90 111 assert_equal 0, Issue.find(1).attachments.length
91 112 end
92 113
93 114 def test_other_formats_links_on_index
94 115 get '/projects/ecookbook/issues'
95 116
96 117 %w(Atom PDF CSV).each do |format|
97 118 assert_tag :a, :content => format,
98 119 :attributes => { :href => "/projects/ecookbook/issues.#{format.downcase}",
99 120 :rel => 'nofollow' }
100 121 end
101 122 end
102 123
103 124 def test_other_formats_links_on_index_without_project_id_in_url
104 125 get '/issues', :project_id => 'ecookbook'
105 126
106 127 %w(Atom PDF CSV).each do |format|
107 128 assert_tag :a, :content => format,
108 129 :attributes => { :href => "/projects/ecookbook/issues.#{format.downcase}",
109 130 :rel => 'nofollow' }
110 131 end
111 132 end
112 133
113 134 def test_pagination_links_on_index
114 135 Setting.per_page_options = '2'
115 136 get '/projects/ecookbook/issues'
116 137
117 138 assert_tag :a, :content => '2',
118 139 :attributes => { :href => '/projects/ecookbook/issues?page=2' }
119 140
120 141 end
121 142
122 143 def test_pagination_links_on_index_without_project_id_in_url
123 144 Setting.per_page_options = '2'
124 145 get '/issues', :project_id => 'ecookbook'
125 146
126 147 assert_tag :a, :content => '2',
127 148 :attributes => { :href => '/projects/ecookbook/issues?page=2' }
128 149
129 150 end
130 151
131 152 def test_issue_with_user_custom_field
132 153 @field = IssueCustomField.create!(:name => 'Tester', :field_format => 'user', :is_for_all => true, :trackers => Tracker.all)
133 154 Role.anonymous.add_permission! :add_issues, :edit_issues
134 155 users = Project.find(1).users
135 156 tester = users.first
136 157
137 158 # Issue form
138 159 get '/projects/ecookbook/issues/new'
139 160 assert_response :success
140 161 assert_tag :select,
141 162 :attributes => {:name => "issue[custom_field_values][#{@field.id}]"},
142 163 :children => {:count => (users.size + 1)}, # +1 for blank value
143 164 :child => {
144 165 :tag => 'option',
145 166 :attributes => {:value => tester.id.to_s},
146 167 :content => tester.name
147 168 }
148 169
149 170 # Create issue
150 171 assert_difference 'Issue.count' do
151 172 post '/projects/ecookbook/issues',
152 173 :issue => {
153 174 :tracker_id => '1',
154 175 :priority_id => '4',
155 176 :subject => 'Issue with user custom field',
156 177 :custom_field_values => {@field.id.to_s => users.first.id.to_s}
157 178 }
158 179 end
159 180 issue = Issue.order('id DESC').first
160 181 assert_response 302
161 182
162 183 # Issue view
163 184 follow_redirect!
164 185 assert_tag :th,
165 186 :content => /Tester/,
166 187 :sibling => {
167 188 :tag => 'td',
168 189 :content => tester.name
169 190 }
170 191 assert_tag :select,
171 192 :attributes => {:name => "issue[custom_field_values][#{@field.id}]"},
172 193 :children => {:count => (users.size + 1)}, # +1 for blank value
173 194 :child => {
174 195 :tag => 'option',
175 196 :attributes => {:value => tester.id.to_s, :selected => 'selected'},
176 197 :content => tester.name
177 198 }
178 199
179 200 # Update issue
180 201 new_tester = users[1]
181 202 assert_difference 'Journal.count' do
182 203 put "/issues/#{issue.id}",
183 204 :notes => 'Updating custom field',
184 205 :issue => {
185 206 :custom_field_values => {@field.id.to_s => new_tester.id.to_s}
186 207 }
187 208 end
188 209 assert_response 302
189 210
190 211 # Issue view
191 212 follow_redirect!
192 213 assert_tag :content => 'Tester',
193 214 :ancestor => {:tag => 'ul', :attributes => {:class => /details/}},
194 215 :sibling => {
195 216 :content => tester.name,
196 217 :sibling => {
197 218 :content => new_tester.name
198 219 }
199 220 }
200 221 end
201 222
202 223 def test_update_using_invalid_http_verbs
203 224 subject = 'Updated by an invalid http verb'
204 225
205 226 get '/issues/update/1', {:issue => {:subject => subject}}, credentials('jsmith')
206 227 assert_response 404
207 228 assert_not_equal subject, Issue.find(1).subject
208 229
209 230 post '/issues/1', {:issue => {:subject => subject}}, credentials('jsmith')
210 231 assert_response 404
211 232 assert_not_equal subject, Issue.find(1).subject
212 233 end
213 234
214 235 def test_get_watch_should_be_invalid
215 236 assert_no_difference 'Watcher.count' do
216 237 get '/watchers/watch?object_type=issue&object_id=1', {}, credentials('jsmith')
217 238 assert_response 404
218 239 end
219 240 end
220 241 end
@@ -1,136 +1,152
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class GroupTest < ActiveSupport::TestCase
21 21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 22 :enumerations, :users,
23 23 :projects_trackers,
24 24 :roles,
25 25 :member_roles,
26 26 :members,
27 27 :groups_users
28 28
29 29 include Redmine::I18n
30 30
31 31 def test_create
32 32 g = Group.new(:name => 'New group')
33 33 assert g.save
34 34 g.reload
35 35 assert_equal 'New group', g.name
36 36 end
37 37
38 38 def test_name_should_accept_255_characters
39 39 name = 'a' * 255
40 40 g = Group.new(:name => name)
41 41 assert g.save
42 42 g.reload
43 43 assert_equal name, g.name
44 44 end
45 45
46 46 def test_blank_name_error_message
47 47 set_language_if_valid 'en'
48 48 g = Group.new
49 49 assert !g.save
50 50 assert_include "Name can't be blank", g.errors.full_messages
51 51 end
52 52
53 53 def test_blank_name_error_message_fr
54 54 set_language_if_valid 'fr'
55 55 str = "Nom doit \xc3\xaatre renseign\xc3\xa9(e)"
56 56 str.force_encoding('UTF-8') if str.respond_to?(:force_encoding)
57 57 g = Group.new
58 58 assert !g.save
59 59 assert_include str, g.errors.full_messages
60 60 end
61 61
62 62 def test_group_roles_should_be_given_to_added_user
63 63 group = Group.find(11)
64 64 user = User.find(9)
65 65 project = Project.first
66 66
67 67 Member.create!(:principal => group, :project => project, :role_ids => [1, 2])
68 68 group.users << user
69 69 assert user.member_of?(project)
70 70 end
71 71
72 72 def test_new_roles_should_be_given_to_existing_user
73 73 group = Group.find(11)
74 74 user = User.find(9)
75 75 project = Project.first
76 76
77 77 group.users << user
78 78 m = Member.create!(:principal => group, :project => project, :role_ids => [1, 2])
79 79 assert user.member_of?(project)
80 80 end
81 81
82 82 def test_user_roles_should_updated_when_updating_user_ids
83 83 group = Group.find(11)
84 84 user = User.find(9)
85 85 project = Project.first
86 86
87 87 Member.create!(:principal => group, :project => project, :role_ids => [1, 2])
88 88 group.user_ids = [user.id]
89 89 group.save!
90 90 assert User.find(9).member_of?(project)
91 91
92 92 group.user_ids = [1]
93 93 group.save!
94 94 assert !User.find(9).member_of?(project)
95 95 end
96 96
97 97 def test_user_roles_should_updated_when_updating_group_roles
98 98 group = Group.find(11)
99 99 user = User.find(9)
100 100 project = Project.first
101 101 group.users << user
102 102 m = Member.create!(:principal => group, :project => project, :role_ids => [1])
103 103 assert_equal [1], user.reload.roles_for_project(project).collect(&:id).sort
104 104
105 105 m.role_ids = [1, 2]
106 106 assert_equal [1, 2], user.reload.roles_for_project(project).collect(&:id).sort
107 107
108 108 m.role_ids = [2]
109 109 assert_equal [2], user.reload.roles_for_project(project).collect(&:id).sort
110 110
111 111 m.role_ids = [1]
112 112 assert_equal [1], user.reload.roles_for_project(project).collect(&:id).sort
113 113 end
114 114
115 115 def test_user_memberships_should_be_removed_when_removing_group_membership
116 116 assert User.find(8).member_of?(Project.find(5))
117 117 Member.find_by_project_id_and_user_id(5, 10).destroy
118 118 assert !User.find(8).member_of?(Project.find(5))
119 119 end
120 120
121 121 def test_user_roles_should_be_removed_when_removing_user_from_group
122 122 assert User.find(8).member_of?(Project.find(5))
123 123 User.find(8).groups = []
124 124 assert !User.find(8).member_of?(Project.find(5))
125 125 end
126 126
127 127 def test_destroy_should_unassign_issues
128 128 group = Group.first
129 129 Issue.where(:id => 1).update_all(["assigned_to_id = ?", group.id])
130 130
131 131 assert group.destroy
132 132 assert group.destroyed?
133 133
134 134 assert_equal nil, Issue.find(1).assigned_to_id
135 135 end
136
137 def test_builtin_id_with_anonymous_user_should_return_anonymous_group
138 assert_equal 13, Group.builtin_id(User.anonymous)
139 end
140
141 def test_builtin_id_with_anonymous_role_should_return_anonymous_group
142 assert_equal 13, Group.builtin_id(Role.anonymous)
143 end
144
145 def test_builtin_id_with_user_should_return_non_member_group
146 assert_equal 12, Group.builtin_id(User.find(1))
147 end
148
149 def test_builtin_id_with_non_member_role_should_return_non_member_group
150 assert_equal 12, Group.builtin_id(Role.non_member)
151 end
136 152 end
@@ -1,2368 +1,2399
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class IssueTest < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :members, :member_roles, :roles,
22 22 :groups_users,
23 23 :trackers, :projects_trackers,
24 24 :enabled_modules,
25 25 :versions,
26 26 :issue_statuses, :issue_categories, :issue_relations, :workflows,
27 27 :enumerations,
28 28 :issues, :journals, :journal_details,
29 29 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
30 30 :time_entries
31 31
32 32 include Redmine::I18n
33 33
34 34 def teardown
35 35 User.current = nil
36 36 end
37 37
38 38 def test_initialize
39 39 issue = Issue.new
40 40
41 41 assert_nil issue.project_id
42 42 assert_nil issue.tracker_id
43 43 assert_nil issue.author_id
44 44 assert_nil issue.assigned_to_id
45 45 assert_nil issue.category_id
46 46
47 47 assert_equal IssueStatus.default, issue.status
48 48 assert_equal IssuePriority.default, issue.priority
49 49 end
50 50
51 51 def test_create
52 52 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
53 53 :status_id => 1, :priority => IssuePriority.all.first,
54 54 :subject => 'test_create',
55 55 :description => 'IssueTest#test_create', :estimated_hours => '1:30')
56 56 assert issue.save
57 57 issue.reload
58 58 assert_equal 1.5, issue.estimated_hours
59 59 end
60 60
61 61 def test_create_minimal
62 62 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
63 63 :status_id => 1, :priority => IssuePriority.all.first,
64 64 :subject => 'test_create')
65 65 assert issue.save
66 66 assert issue.description.nil?
67 67 assert_nil issue.estimated_hours
68 68 end
69 69
70 70 def test_start_date_format_should_be_validated
71 71 set_language_if_valid 'en'
72 72 ['2012', 'ABC', '2012-15-20'].each do |invalid_date|
73 73 issue = Issue.new(:start_date => invalid_date)
74 74 assert !issue.valid?
75 75 assert_include 'Start date is not a valid date', issue.errors.full_messages, "No error found for invalid date #{invalid_date}"
76 76 end
77 77 end
78 78
79 79 def test_due_date_format_should_be_validated
80 80 set_language_if_valid 'en'
81 81 ['2012', 'ABC', '2012-15-20'].each do |invalid_date|
82 82 issue = Issue.new(:due_date => invalid_date)
83 83 assert !issue.valid?
84 84 assert_include 'Due date is not a valid date', issue.errors.full_messages, "No error found for invalid date #{invalid_date}"
85 85 end
86 86 end
87 87
88 88 def test_due_date_lesser_than_start_date_should_not_validate
89 89 set_language_if_valid 'en'
90 90 issue = Issue.new(:start_date => '2012-10-06', :due_date => '2012-10-02')
91 91 assert !issue.valid?
92 92 assert_include 'Due date must be greater than start date', issue.errors.full_messages
93 93 end
94 94
95 95 def test_start_date_lesser_than_soonest_start_should_not_validate_on_create
96 96 issue = Issue.generate(:start_date => '2013-06-04')
97 97 issue.stubs(:soonest_start).returns(Date.parse('2013-06-10'))
98 98 assert !issue.valid?
99 99 assert_include "Start date cannot be earlier than 06/10/2013 because of preceding issues", issue.errors.full_messages
100 100 end
101 101
102 102 def test_start_date_lesser_than_soonest_start_should_not_validate_on_update_if_changed
103 103 issue = Issue.generate!(:start_date => '2013-06-04')
104 104 issue.stubs(:soonest_start).returns(Date.parse('2013-06-10'))
105 105 issue.start_date = '2013-06-07'
106 106 assert !issue.valid?
107 107 assert_include "Start date cannot be earlier than 06/10/2013 because of preceding issues", issue.errors.full_messages
108 108 end
109 109
110 110 def test_start_date_lesser_than_soonest_start_should_validate_on_update_if_unchanged
111 111 issue = Issue.generate!(:start_date => '2013-06-04')
112 112 issue.stubs(:soonest_start).returns(Date.parse('2013-06-10'))
113 113 assert issue.valid?
114 114 end
115 115
116 116 def test_estimated_hours_should_be_validated
117 117 set_language_if_valid 'en'
118 118 ['-2'].each do |invalid|
119 119 issue = Issue.new(:estimated_hours => invalid)
120 120 assert !issue.valid?
121 121 assert_include 'Estimated time is invalid', issue.errors.full_messages
122 122 end
123 123 end
124 124
125 125 def test_create_with_required_custom_field
126 126 set_language_if_valid 'en'
127 127 field = IssueCustomField.find_by_name('Database')
128 128 field.update_attribute(:is_required, true)
129 129
130 130 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
131 131 :status_id => 1, :subject => 'test_create',
132 132 :description => 'IssueTest#test_create_with_required_custom_field')
133 133 assert issue.available_custom_fields.include?(field)
134 134 # No value for the custom field
135 135 assert !issue.save
136 136 assert_equal ["Database can't be blank"], issue.errors.full_messages
137 137 # Blank value
138 138 issue.custom_field_values = { field.id => '' }
139 139 assert !issue.save
140 140 assert_equal ["Database can't be blank"], issue.errors.full_messages
141 141 # Invalid value
142 142 issue.custom_field_values = { field.id => 'SQLServer' }
143 143 assert !issue.save
144 144 assert_equal ["Database is not included in the list"], issue.errors.full_messages
145 145 # Valid value
146 146 issue.custom_field_values = { field.id => 'PostgreSQL' }
147 147 assert issue.save
148 148 issue.reload
149 149 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
150 150 end
151 151
152 152 def test_create_with_group_assignment
153 153 with_settings :issue_group_assignment => '1' do
154 154 assert Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
155 155 :subject => 'Group assignment',
156 156 :assigned_to_id => 11).save
157 157 issue = Issue.order('id DESC').first
158 158 assert_kind_of Group, issue.assigned_to
159 159 assert_equal Group.find(11), issue.assigned_to
160 160 end
161 161 end
162 162
163 163 def test_create_with_parent_issue_id
164 164 issue = Issue.new(:project_id => 1, :tracker_id => 1,
165 165 :author_id => 1, :subject => 'Group assignment',
166 166 :parent_issue_id => 1)
167 167 assert_save issue
168 168 assert_equal 1, issue.parent_issue_id
169 169 assert_equal Issue.find(1), issue.parent
170 170 end
171 171
172 172 def test_create_with_sharp_parent_issue_id
173 173 issue = Issue.new(:project_id => 1, :tracker_id => 1,
174 174 :author_id => 1, :subject => 'Group assignment',
175 175 :parent_issue_id => "#1")
176 176 assert_save issue
177 177 assert_equal 1, issue.parent_issue_id
178 178 assert_equal Issue.find(1), issue.parent
179 179 end
180 180
181 181 def test_create_with_invalid_parent_issue_id
182 182 set_language_if_valid 'en'
183 183 issue = Issue.new(:project_id => 1, :tracker_id => 1,
184 184 :author_id => 1, :subject => 'Group assignment',
185 185 :parent_issue_id => '01ABC')
186 186 assert !issue.save
187 187 assert_equal '01ABC', issue.parent_issue_id
188 188 assert_include 'Parent task is invalid', issue.errors.full_messages
189 189 end
190 190
191 191 def test_create_with_invalid_sharp_parent_issue_id
192 192 set_language_if_valid 'en'
193 193 issue = Issue.new(:project_id => 1, :tracker_id => 1,
194 194 :author_id => 1, :subject => 'Group assignment',
195 195 :parent_issue_id => '#01ABC')
196 196 assert !issue.save
197 197 assert_equal '#01ABC', issue.parent_issue_id
198 198 assert_include 'Parent task is invalid', issue.errors.full_messages
199 199 end
200 200
201 201 def assert_visibility_match(user, issues)
202 202 assert_equal issues.collect(&:id).sort, Issue.all.select {|issue| issue.visible?(user)}.collect(&:id).sort
203 203 end
204 204
205 205 def test_visible_scope_for_anonymous
206 206 # Anonymous user should see issues of public projects only
207 207 issues = Issue.visible(User.anonymous).all
208 208 assert issues.any?
209 209 assert_nil issues.detect {|issue| !issue.project.is_public?}
210 210 assert_nil issues.detect {|issue| issue.is_private?}
211 211 assert_visibility_match User.anonymous, issues
212 212 end
213 213
214 214 def test_visible_scope_for_anonymous_without_view_issues_permissions
215 215 # Anonymous user should not see issues without permission
216 216 Role.anonymous.remove_permission!(:view_issues)
217 217 issues = Issue.visible(User.anonymous).all
218 218 assert issues.empty?
219 219 assert_visibility_match User.anonymous, issues
220 220 end
221 221
222 def test_visible_scope_for_anonymous_without_view_issues_permissions_and_membership
223 Role.anonymous.remove_permission!(:view_issues)
224 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [2])
225
226 issues = Issue.visible(User.anonymous).all
227 assert issues.any?
228 assert_equal [1], issues.map(&:project_id).uniq.sort
229 assert_visibility_match User.anonymous, issues
230 end
231
222 232 def test_anonymous_should_not_see_private_issues_with_issues_visibility_set_to_default
223 233 assert Role.anonymous.update_attribute(:issues_visibility, 'default')
224 234 issue = Issue.generate!(:author => User.anonymous, :assigned_to => User.anonymous, :is_private => true)
225 235 assert_nil Issue.where(:id => issue.id).visible(User.anonymous).first
226 236 assert !issue.visible?(User.anonymous)
227 237 end
228 238
229 239 def test_anonymous_should_not_see_private_issues_with_issues_visibility_set_to_own
230 240 assert Role.anonymous.update_attribute(:issues_visibility, 'own')
231 241 issue = Issue.generate!(:author => User.anonymous, :assigned_to => User.anonymous, :is_private => true)
232 242 assert_nil Issue.where(:id => issue.id).visible(User.anonymous).first
233 243 assert !issue.visible?(User.anonymous)
234 244 end
235 245
236 246 def test_visible_scope_for_non_member
237 247 user = User.find(9)
238 248 assert user.projects.empty?
239 249 # Non member user should see issues of public projects only
240 250 issues = Issue.visible(user).all
241 251 assert issues.any?
242 252 assert_nil issues.detect {|issue| !issue.project.is_public?}
243 253 assert_nil issues.detect {|issue| issue.is_private?}
244 254 assert_visibility_match user, issues
245 255 end
246 256
247 257 def test_visible_scope_for_non_member_with_own_issues_visibility
248 258 Role.non_member.update_attribute :issues_visibility, 'own'
249 259 Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 9, :subject => 'Issue by non member')
250 260 user = User.find(9)
251 261
252 262 issues = Issue.visible(user).all
253 263 assert issues.any?
254 264 assert_nil issues.detect {|issue| issue.author != user}
255 265 assert_visibility_match user, issues
256 266 end
257 267
258 268 def test_visible_scope_for_non_member_without_view_issues_permissions
259 269 # Non member user should not see issues without permission
260 270 Role.non_member.remove_permission!(:view_issues)
261 271 user = User.find(9)
262 272 assert user.projects.empty?
263 273 issues = Issue.visible(user).all
264 274 assert issues.empty?
265 275 assert_visibility_match user, issues
266 276 end
267 277
278 def test_visible_scope_for_non_member_without_view_issues_permissions_and_membership
279 Role.non_member.remove_permission!(:view_issues)
280 Member.create!(:project_id => 1, :principal => Group.non_member, :role_ids => [2])
281 user = User.find(9)
282
283 issues = Issue.visible(user).all
284 assert issues.any?
285 assert_equal [1], issues.map(&:project_id).uniq.sort
286 assert_visibility_match user, issues
287 end
288
268 289 def test_visible_scope_for_member
269 290 user = User.find(9)
270 291 # User should see issues of projects for which user has view_issues permissions only
271 292 Role.non_member.remove_permission!(:view_issues)
272 293 Member.create!(:principal => user, :project_id => 3, :role_ids => [2])
273 294 issues = Issue.visible(user).all
274 295 assert issues.any?
275 296 assert_nil issues.detect {|issue| issue.project_id != 3}
276 297 assert_nil issues.detect {|issue| issue.is_private?}
277 298 assert_visibility_match user, issues
278 299 end
279 300
280 301 def test_visible_scope_for_member_with_groups_should_return_assigned_issues
281 302 user = User.find(8)
282 303 assert user.groups.any?
283 304 Member.create!(:principal => user.groups.first, :project_id => 1, :role_ids => [2])
284 305 Role.non_member.remove_permission!(:view_issues)
285 306
286 307 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
287 308 :status_id => 1, :priority => IssuePriority.all.first,
288 309 :subject => 'Assignment test',
289 310 :assigned_to => user.groups.first,
290 311 :is_private => true)
291 312
292 313 Role.find(2).update_attribute :issues_visibility, 'default'
293 314 issues = Issue.visible(User.find(8)).all
294 315 assert issues.any?
295 316 assert issues.include?(issue)
296 317
297 318 Role.find(2).update_attribute :issues_visibility, 'own'
298 319 issues = Issue.visible(User.find(8)).all
299 320 assert issues.any?
300 321 assert issues.include?(issue)
301 322 end
302 323
303 324 def test_visible_scope_for_admin
304 325 user = User.find(1)
305 326 user.members.each(&:destroy)
306 327 assert user.projects.empty?
307 328 issues = Issue.visible(user).all
308 329 assert issues.any?
309 330 # Admin should see issues on private projects that admin does not belong to
310 331 assert issues.detect {|issue| !issue.project.is_public?}
311 332 # Admin should see private issues of other users
312 333 assert issues.detect {|issue| issue.is_private? && issue.author != user}
313 334 assert_visibility_match user, issues
314 335 end
315 336
316 337 def test_visible_scope_with_project
317 338 project = Project.find(1)
318 339 issues = Issue.visible(User.find(2), :project => project).all
319 340 projects = issues.collect(&:project).uniq
320 341 assert_equal 1, projects.size
321 342 assert_equal project, projects.first
322 343 end
323 344
324 345 def test_visible_scope_with_project_and_subprojects
325 346 project = Project.find(1)
326 347 issues = Issue.visible(User.find(2), :project => project, :with_subprojects => true).all
327 348 projects = issues.collect(&:project).uniq
328 349 assert projects.size > 1
329 350 assert_equal [], projects.select {|p| !p.is_or_is_descendant_of?(project)}
330 351 end
331 352
332 353 def test_visible_and_nested_set_scopes
333 354 user = User.generate!
334 355 parent = Issue.generate!(:assigned_to => user)
335 356 assert parent.visible?(user)
336 357 child1 = Issue.generate!(:parent_issue_id => parent.id, :assigned_to => user)
337 358 child2 = Issue.generate!(:parent_issue_id => parent.id, :assigned_to => user)
338 359 parent.reload
339 360 child1.reload
340 361 child2.reload
341 362 assert child1.visible?(user)
342 363 assert child2.visible?(user)
343 364 assert_equal 2, parent.descendants.count
344 365 assert_equal 2, parent.descendants.visible(user).count
345 366 # awesome_nested_set 2-1-stable branch has regression.
346 367 # https://github.com/collectiveidea/awesome_nested_set/commit/3d5ac746542b564f6586c2316180254b088bebb6
347 368 # ActiveRecord::StatementInvalid: SQLite3::SQLException: ambiguous column name: lft:
348 369 assert_equal 2, parent.descendants.collect{|i| i}.size
349 370 assert_equal 2, parent.descendants.visible(user).collect{|i| i}.size
350 371 end
351 372
352 373 def test_open_scope
353 374 issues = Issue.open.all
354 375 assert_nil issues.detect(&:closed?)
355 376 end
356 377
357 378 def test_open_scope_with_arg
358 379 issues = Issue.open(false).all
359 380 assert_equal issues, issues.select(&:closed?)
360 381 end
361 382
362 383 def test_fixed_version_scope_with_a_version_should_return_its_fixed_issues
363 384 version = Version.find(2)
364 385 assert version.fixed_issues.any?
365 386 assert_equal version.fixed_issues.to_a.sort, Issue.fixed_version(version).to_a.sort
366 387 end
367 388
368 389 def test_fixed_version_scope_with_empty_array_should_return_no_result
369 390 assert_equal 0, Issue.fixed_version([]).count
370 391 end
371 392
372 393 def test_errors_full_messages_should_include_custom_fields_errors
373 394 field = IssueCustomField.find_by_name('Database')
374 395
375 396 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
376 397 :status_id => 1, :subject => 'test_create',
377 398 :description => 'IssueTest#test_create_with_required_custom_field')
378 399 assert issue.available_custom_fields.include?(field)
379 400 # Invalid value
380 401 issue.custom_field_values = { field.id => 'SQLServer' }
381 402
382 403 assert !issue.valid?
383 404 assert_equal 1, issue.errors.full_messages.size
384 405 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}",
385 406 issue.errors.full_messages.first
386 407 end
387 408
388 409 def test_update_issue_with_required_custom_field
389 410 field = IssueCustomField.find_by_name('Database')
390 411 field.update_attribute(:is_required, true)
391 412
392 413 issue = Issue.find(1)
393 414 assert_nil issue.custom_value_for(field)
394 415 assert issue.available_custom_fields.include?(field)
395 416 # No change to custom values, issue can be saved
396 417 assert issue.save
397 418 # Blank value
398 419 issue.custom_field_values = { field.id => '' }
399 420 assert !issue.save
400 421 # Valid value
401 422 issue.custom_field_values = { field.id => 'PostgreSQL' }
402 423 assert issue.save
403 424 issue.reload
404 425 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
405 426 end
406 427
407 428 def test_should_not_update_attributes_if_custom_fields_validation_fails
408 429 issue = Issue.find(1)
409 430 field = IssueCustomField.find_by_name('Database')
410 431 assert issue.available_custom_fields.include?(field)
411 432
412 433 issue.custom_field_values = { field.id => 'Invalid' }
413 434 issue.subject = 'Should be not be saved'
414 435 assert !issue.save
415 436
416 437 issue.reload
417 438 assert_equal "Can't print recipes", issue.subject
418 439 end
419 440
420 441 def test_should_not_recreate_custom_values_objects_on_update
421 442 field = IssueCustomField.find_by_name('Database')
422 443
423 444 issue = Issue.find(1)
424 445 issue.custom_field_values = { field.id => 'PostgreSQL' }
425 446 assert issue.save
426 447 custom_value = issue.custom_value_for(field)
427 448 issue.reload
428 449 issue.custom_field_values = { field.id => 'MySQL' }
429 450 assert issue.save
430 451 issue.reload
431 452 assert_equal custom_value.id, issue.custom_value_for(field).id
432 453 end
433 454
434 455 def test_should_not_update_custom_fields_on_changing_tracker_with_different_custom_fields
435 456 issue = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1,
436 457 :status_id => 1, :subject => 'Test',
437 458 :custom_field_values => {'2' => 'Test'})
438 459 assert !Tracker.find(2).custom_field_ids.include?(2)
439 460
440 461 issue = Issue.find(issue.id)
441 462 issue.attributes = {:tracker_id => 2, :custom_field_values => {'1' => ''}}
442 463
443 464 issue = Issue.find(issue.id)
444 465 custom_value = issue.custom_value_for(2)
445 466 assert_not_nil custom_value
446 467 assert_equal 'Test', custom_value.value
447 468 end
448 469
449 470 def test_assigning_tracker_id_should_reload_custom_fields_values
450 471 issue = Issue.new(:project => Project.find(1))
451 472 assert issue.custom_field_values.empty?
452 473 issue.tracker_id = 1
453 474 assert issue.custom_field_values.any?
454 475 end
455 476
456 477 def test_assigning_attributes_should_assign_project_and_tracker_first
457 478 seq = sequence('seq')
458 479 issue = Issue.new
459 480 issue.expects(:project_id=).in_sequence(seq)
460 481 issue.expects(:tracker_id=).in_sequence(seq)
461 482 issue.expects(:subject=).in_sequence(seq)
462 483 issue.attributes = {:tracker_id => 2, :project_id => 1, :subject => 'Test'}
463 484 end
464 485
465 486 def test_assigning_tracker_and_custom_fields_should_assign_custom_fields
466 487 attributes = ActiveSupport::OrderedHash.new
467 488 attributes['custom_field_values'] = { '1' => 'MySQL' }
468 489 attributes['tracker_id'] = '1'
469 490 issue = Issue.new(:project => Project.find(1))
470 491 issue.attributes = attributes
471 492 assert_equal 'MySQL', issue.custom_field_value(1)
472 493 end
473 494
474 495 def test_reload_should_reload_custom_field_values
475 496 issue = Issue.generate!
476 497 issue.custom_field_values = {'2' => 'Foo'}
477 498 issue.save!
478 499
479 500 issue = Issue.order('id desc').first
480 501 assert_equal 'Foo', issue.custom_field_value(2)
481 502
482 503 issue.custom_field_values = {'2' => 'Bar'}
483 504 assert_equal 'Bar', issue.custom_field_value(2)
484 505
485 506 issue.reload
486 507 assert_equal 'Foo', issue.custom_field_value(2)
487 508 end
488 509
489 510 def test_should_update_issue_with_disabled_tracker
490 511 p = Project.find(1)
491 512 issue = Issue.find(1)
492 513
493 514 p.trackers.delete(issue.tracker)
494 515 assert !p.trackers.include?(issue.tracker)
495 516
496 517 issue.reload
497 518 issue.subject = 'New subject'
498 519 assert issue.save
499 520 end
500 521
501 522 def test_should_not_set_a_disabled_tracker
502 523 p = Project.find(1)
503 524 p.trackers.delete(Tracker.find(2))
504 525
505 526 issue = Issue.find(1)
506 527 issue.tracker_id = 2
507 528 issue.subject = 'New subject'
508 529 assert !issue.save
509 530 assert_not_equal [], issue.errors[:tracker_id]
510 531 end
511 532
512 533 def test_category_based_assignment
513 534 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
514 535 :status_id => 1, :priority => IssuePriority.all.first,
515 536 :subject => 'Assignment test',
516 537 :description => 'Assignment test', :category_id => 1)
517 538 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
518 539 end
519 540
520 541 def test_new_statuses_allowed_to
521 542 WorkflowTransition.delete_all
522 543 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
523 544 :old_status_id => 1, :new_status_id => 2,
524 545 :author => false, :assignee => false)
525 546 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
526 547 :old_status_id => 1, :new_status_id => 3,
527 548 :author => true, :assignee => false)
528 549 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
529 550 :old_status_id => 1, :new_status_id => 4,
530 551 :author => false, :assignee => true)
531 552 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
532 553 :old_status_id => 1, :new_status_id => 5,
533 554 :author => true, :assignee => true)
534 555 status = IssueStatus.find(1)
535 556 role = Role.find(1)
536 557 tracker = Tracker.find(1)
537 558 user = User.find(2)
538 559
539 560 issue = Issue.generate!(:tracker => tracker, :status => status,
540 561 :project_id => 1, :author_id => 1)
541 562 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
542 563
543 564 issue = Issue.generate!(:tracker => tracker, :status => status,
544 565 :project_id => 1, :author => user)
545 566 assert_equal [1, 2, 3, 5], issue.new_statuses_allowed_to(user).map(&:id)
546 567
547 568 issue = Issue.generate!(:tracker => tracker, :status => status,
548 569 :project_id => 1, :author_id => 1,
549 570 :assigned_to => user)
550 571 assert_equal [1, 2, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
551 572
552 573 issue = Issue.generate!(:tracker => tracker, :status => status,
553 574 :project_id => 1, :author => user,
554 575 :assigned_to => user)
555 576 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
556 577
557 578 group = Group.generate!
558 579 group.users << user
559 580 issue = Issue.generate!(:tracker => tracker, :status => status,
560 581 :project_id => 1, :author => user,
561 582 :assigned_to => group)
562 583 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
563 584 end
564 585
565 586 def test_new_statuses_allowed_to_should_consider_group_assignment
566 587 WorkflowTransition.delete_all
567 588 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
568 589 :old_status_id => 1, :new_status_id => 4,
569 590 :author => false, :assignee => true)
570 591 user = User.find(2)
571 592 group = Group.generate!
572 593 group.users << user
573 594
574 595 issue = Issue.generate!(:author_id => 1, :assigned_to => group)
575 596 assert_include 4, issue.new_statuses_allowed_to(user).map(&:id)
576 597 end
577 598
578 599 def test_new_statuses_allowed_to_should_return_all_transitions_for_admin
579 600 admin = User.find(1)
580 601 issue = Issue.find(1)
581 602 assert !admin.member_of?(issue.project)
582 603 expected_statuses = [issue.status] +
583 604 WorkflowTransition.where(:old_status_id => issue.status_id).
584 605 map(&:new_status).uniq.sort
585 606 assert_equal expected_statuses, issue.new_statuses_allowed_to(admin)
586 607 end
587 608
588 609 def test_new_statuses_allowed_to_should_return_default_and_current_status_when_copying
589 610 issue = Issue.find(1).copy
590 611 assert_equal [1], issue.new_statuses_allowed_to(User.find(2)).map(&:id)
591 612
592 613 issue = Issue.find(2).copy
593 614 assert_equal [1, 2], issue.new_statuses_allowed_to(User.find(2)).map(&:id)
594 615 end
595 616
596 617 def test_safe_attributes_names_should_not_include_disabled_field
597 618 tracker = Tracker.new(:core_fields => %w(assigned_to_id fixed_version_id))
598 619
599 620 issue = Issue.new(:tracker => tracker)
600 621 assert_include 'tracker_id', issue.safe_attribute_names
601 622 assert_include 'status_id', issue.safe_attribute_names
602 623 assert_include 'subject', issue.safe_attribute_names
603 624 assert_include 'description', issue.safe_attribute_names
604 625 assert_include 'custom_field_values', issue.safe_attribute_names
605 626 assert_include 'custom_fields', issue.safe_attribute_names
606 627 assert_include 'lock_version', issue.safe_attribute_names
607 628
608 629 tracker.core_fields.each do |field|
609 630 assert_include field, issue.safe_attribute_names
610 631 end
611 632
612 633 tracker.disabled_core_fields.each do |field|
613 634 assert_not_include field, issue.safe_attribute_names
614 635 end
615 636 end
616 637
617 638 def test_safe_attributes_should_ignore_disabled_fields
618 639 tracker = Tracker.find(1)
619 640 tracker.core_fields = %w(assigned_to_id due_date)
620 641 tracker.save!
621 642
622 643 issue = Issue.new(:tracker => tracker)
623 644 issue.safe_attributes = {'start_date' => '2012-07-14', 'due_date' => '2012-07-14'}
624 645 assert_nil issue.start_date
625 646 assert_equal Date.parse('2012-07-14'), issue.due_date
626 647 end
627 648
628 649 def test_safe_attributes_should_accept_target_tracker_enabled_fields
629 650 source = Tracker.find(1)
630 651 source.core_fields = []
631 652 source.save!
632 653 target = Tracker.find(2)
633 654 target.core_fields = %w(assigned_to_id due_date)
634 655 target.save!
635 656
636 657 issue = Issue.new(:tracker => source)
637 658 issue.safe_attributes = {'tracker_id' => 2, 'due_date' => '2012-07-14'}
638 659 assert_equal target, issue.tracker
639 660 assert_equal Date.parse('2012-07-14'), issue.due_date
640 661 end
641 662
642 663 def test_safe_attributes_should_not_include_readonly_fields
643 664 WorkflowPermission.delete_all
644 665 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
645 666 :role_id => 1, :field_name => 'due_date',
646 667 :rule => 'readonly')
647 668 user = User.find(2)
648 669
649 670 issue = Issue.new(:project_id => 1, :tracker_id => 1)
650 671 assert_equal %w(due_date), issue.read_only_attribute_names(user)
651 672 assert_not_include 'due_date', issue.safe_attribute_names(user)
652 673
653 674 issue.send :safe_attributes=, {'start_date' => '2012-07-14', 'due_date' => '2012-07-14'}, user
654 675 assert_equal Date.parse('2012-07-14'), issue.start_date
655 676 assert_nil issue.due_date
656 677 end
657 678
658 679 def test_safe_attributes_should_not_include_readonly_custom_fields
659 680 cf1 = IssueCustomField.create!(:name => 'Writable field',
660 681 :field_format => 'string',
661 682 :is_for_all => true, :tracker_ids => [1])
662 683 cf2 = IssueCustomField.create!(:name => 'Readonly field',
663 684 :field_format => 'string',
664 685 :is_for_all => true, :tracker_ids => [1])
665 686 WorkflowPermission.delete_all
666 687 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
667 688 :role_id => 1, :field_name => cf2.id.to_s,
668 689 :rule => 'readonly')
669 690 user = User.find(2)
670 691 issue = Issue.new(:project_id => 1, :tracker_id => 1)
671 692 assert_equal [cf2.id.to_s], issue.read_only_attribute_names(user)
672 693 assert_not_include cf2.id.to_s, issue.safe_attribute_names(user)
673 694
674 695 issue.send :safe_attributes=, {'custom_field_values' => {
675 696 cf1.id.to_s => 'value1', cf2.id.to_s => 'value2'
676 697 }}, user
677 698 assert_equal 'value1', issue.custom_field_value(cf1)
678 699 assert_nil issue.custom_field_value(cf2)
679 700
680 701 issue.send :safe_attributes=, {'custom_fields' => [
681 702 {'id' => cf1.id.to_s, 'value' => 'valuea'},
682 703 {'id' => cf2.id.to_s, 'value' => 'valueb'}
683 704 ]}, user
684 705 assert_equal 'valuea', issue.custom_field_value(cf1)
685 706 assert_nil issue.custom_field_value(cf2)
686 707 end
687 708
688 709 def test_editable_custom_field_values_should_return_non_readonly_custom_values
689 710 cf1 = IssueCustomField.create!(:name => 'Writable field', :field_format => 'string',
690 711 :is_for_all => true, :tracker_ids => [1, 2])
691 712 cf2 = IssueCustomField.create!(:name => 'Readonly field', :field_format => 'string',
692 713 :is_for_all => true, :tracker_ids => [1, 2])
693 714 WorkflowPermission.delete_all
694 715 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1, :role_id => 1,
695 716 :field_name => cf2.id.to_s, :rule => 'readonly')
696 717 user = User.find(2)
697 718
698 719 issue = Issue.new(:project_id => 1, :tracker_id => 1)
699 720 values = issue.editable_custom_field_values(user)
700 721 assert values.detect {|value| value.custom_field == cf1}
701 722 assert_nil values.detect {|value| value.custom_field == cf2}
702 723
703 724 issue.tracker_id = 2
704 725 values = issue.editable_custom_field_values(user)
705 726 assert values.detect {|value| value.custom_field == cf1}
706 727 assert values.detect {|value| value.custom_field == cf2}
707 728 end
708 729
709 730 def test_editable_custom_fields_should_return_custom_field_that_is_enabled_for_the_role_only
710 731 enabled_cf = IssueCustomField.generate!(:is_for_all => true, :tracker_ids => [1], :visible => false, :role_ids => [1,2])
711 732 disabled_cf = IssueCustomField.generate!(:is_for_all => true, :tracker_ids => [1], :visible => false, :role_ids => [2])
712 733 user = User.find(2)
713 734 issue = Issue.new(:project_id => 1, :tracker_id => 1)
714 735
715 736 assert_include enabled_cf, issue.editable_custom_fields(user)
716 737 assert_not_include disabled_cf, issue.editable_custom_fields(user)
717 738 end
718 739
719 740 def test_safe_attributes_should_accept_target_tracker_writable_fields
720 741 WorkflowPermission.delete_all
721 742 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
722 743 :role_id => 1, :field_name => 'due_date',
723 744 :rule => 'readonly')
724 745 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
725 746 :role_id => 1, :field_name => 'start_date',
726 747 :rule => 'readonly')
727 748 user = User.find(2)
728 749
729 750 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
730 751
731 752 issue.send :safe_attributes=, {'start_date' => '2012-07-12',
732 753 'due_date' => '2012-07-14'}, user
733 754 assert_equal Date.parse('2012-07-12'), issue.start_date
734 755 assert_nil issue.due_date
735 756
736 757 issue.send :safe_attributes=, {'start_date' => '2012-07-15',
737 758 'due_date' => '2012-07-16',
738 759 'tracker_id' => 2}, user
739 760 assert_equal Date.parse('2012-07-12'), issue.start_date
740 761 assert_equal Date.parse('2012-07-16'), issue.due_date
741 762 end
742 763
743 764 def test_safe_attributes_should_accept_target_status_writable_fields
744 765 WorkflowPermission.delete_all
745 766 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
746 767 :role_id => 1, :field_name => 'due_date',
747 768 :rule => 'readonly')
748 769 WorkflowPermission.create!(:old_status_id => 2, :tracker_id => 1,
749 770 :role_id => 1, :field_name => 'start_date',
750 771 :rule => 'readonly')
751 772 user = User.find(2)
752 773
753 774 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
754 775
755 776 issue.send :safe_attributes=, {'start_date' => '2012-07-12',
756 777 'due_date' => '2012-07-14'},
757 778 user
758 779 assert_equal Date.parse('2012-07-12'), issue.start_date
759 780 assert_nil issue.due_date
760 781
761 782 issue.send :safe_attributes=, {'start_date' => '2012-07-15',
762 783 'due_date' => '2012-07-16',
763 784 'status_id' => 2},
764 785 user
765 786 assert_equal Date.parse('2012-07-12'), issue.start_date
766 787 assert_equal Date.parse('2012-07-16'), issue.due_date
767 788 end
768 789
769 790 def test_required_attributes_should_be_validated
770 791 cf = IssueCustomField.create!(:name => 'Foo', :field_format => 'string',
771 792 :is_for_all => true, :tracker_ids => [1, 2])
772 793
773 794 WorkflowPermission.delete_all
774 795 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
775 796 :role_id => 1, :field_name => 'due_date',
776 797 :rule => 'required')
777 798 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
778 799 :role_id => 1, :field_name => 'category_id',
779 800 :rule => 'required')
780 801 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
781 802 :role_id => 1, :field_name => cf.id.to_s,
782 803 :rule => 'required')
783 804
784 805 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
785 806 :role_id => 1, :field_name => 'start_date',
786 807 :rule => 'required')
787 808 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
788 809 :role_id => 1, :field_name => cf.id.to_s,
789 810 :rule => 'required')
790 811 user = User.find(2)
791 812
792 813 issue = Issue.new(:project_id => 1, :tracker_id => 1,
793 814 :status_id => 1, :subject => 'Required fields',
794 815 :author => user)
795 816 assert_equal [cf.id.to_s, "category_id", "due_date"],
796 817 issue.required_attribute_names(user).sort
797 818 assert !issue.save, "Issue was saved"
798 819 assert_equal ["Category can't be blank", "Due date can't be blank", "Foo can't be blank"],
799 820 issue.errors.full_messages.sort
800 821
801 822 issue.tracker_id = 2
802 823 assert_equal [cf.id.to_s, "start_date"], issue.required_attribute_names(user).sort
803 824 assert !issue.save, "Issue was saved"
804 825 assert_equal ["Foo can't be blank", "Start date can't be blank"],
805 826 issue.errors.full_messages.sort
806 827
807 828 issue.start_date = Date.today
808 829 issue.custom_field_values = {cf.id.to_s => 'bar'}
809 830 assert issue.save
810 831 end
811 832
812 833 def test_required_attribute_names_for_multiple_roles_should_intersect_rules
813 834 WorkflowPermission.delete_all
814 835 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
815 836 :role_id => 1, :field_name => 'due_date',
816 837 :rule => 'required')
817 838 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
818 839 :role_id => 1, :field_name => 'start_date',
819 840 :rule => 'required')
820 841 user = User.find(2)
821 842 member = Member.find(1)
822 843 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
823 844
824 845 assert_equal %w(due_date start_date), issue.required_attribute_names(user).sort
825 846
826 847 member.role_ids = [1, 2]
827 848 member.save!
828 849 assert_equal [], issue.required_attribute_names(user.reload)
829 850
830 851 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
831 852 :role_id => 2, :field_name => 'due_date',
832 853 :rule => 'required')
833 854 assert_equal %w(due_date), issue.required_attribute_names(user)
834 855
835 856 member.role_ids = [1, 2, 3]
836 857 member.save!
837 858 assert_equal [], issue.required_attribute_names(user.reload)
838 859
839 860 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
840 861 :role_id => 2, :field_name => 'due_date',
841 862 :rule => 'readonly')
842 863 # required + readonly => required
843 864 assert_equal %w(due_date), issue.required_attribute_names(user)
844 865 end
845 866
846 867 def test_read_only_attribute_names_for_multiple_roles_should_intersect_rules
847 868 WorkflowPermission.delete_all
848 869 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
849 870 :role_id => 1, :field_name => 'due_date',
850 871 :rule => 'readonly')
851 872 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
852 873 :role_id => 1, :field_name => 'start_date',
853 874 :rule => 'readonly')
854 875 user = User.find(2)
855 876 member = Member.find(1)
856 877 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
857 878
858 879 assert_equal %w(due_date start_date), issue.read_only_attribute_names(user).sort
859 880
860 881 member.role_ids = [1, 2]
861 882 member.save!
862 883 assert_equal [], issue.read_only_attribute_names(user.reload)
863 884
864 885 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
865 886 :role_id => 2, :field_name => 'due_date',
866 887 :rule => 'readonly')
867 888 assert_equal %w(due_date), issue.read_only_attribute_names(user)
868 889 end
869 890
870 891 def test_copy
871 892 issue = Issue.new.copy_from(1)
872 893 assert issue.copy?
873 894 assert issue.save
874 895 issue.reload
875 896 orig = Issue.find(1)
876 897 assert_equal orig.subject, issue.subject
877 898 assert_equal orig.tracker, issue.tracker
878 899 assert_equal "125", issue.custom_value_for(2).value
879 900 end
880 901
881 902 def test_copy_should_copy_status
882 903 orig = Issue.find(8)
883 904 assert orig.status != IssueStatus.default
884 905
885 906 issue = Issue.new.copy_from(orig)
886 907 assert issue.save
887 908 issue.reload
888 909 assert_equal orig.status, issue.status
889 910 end
890 911
891 912 def test_copy_should_add_relation_with_copied_issue
892 913 copied = Issue.find(1)
893 914 issue = Issue.new.copy_from(copied)
894 915 assert issue.save
895 916 issue.reload
896 917
897 918 assert_equal 1, issue.relations.size
898 919 relation = issue.relations.first
899 920 assert_equal 'copied_to', relation.relation_type
900 921 assert_equal copied, relation.issue_from
901 922 assert_equal issue, relation.issue_to
902 923 end
903 924
904 925 def test_copy_should_copy_subtasks
905 926 issue = Issue.generate_with_descendants!
906 927
907 928 copy = issue.reload.copy
908 929 copy.author = User.find(7)
909 930 assert_difference 'Issue.count', 1+issue.descendants.count do
910 931 assert copy.save
911 932 end
912 933 copy.reload
913 934 assert_equal %w(Child1 Child2), copy.children.map(&:subject).sort
914 935 child_copy = copy.children.detect {|c| c.subject == 'Child1'}
915 936 assert_equal %w(Child11), child_copy.children.map(&:subject).sort
916 937 assert_equal copy.author, child_copy.author
917 938 end
918 939
919 940 def test_copy_as_a_child_of_copied_issue_should_not_copy_itself
920 941 parent = Issue.generate!
921 942 child1 = Issue.generate!(:parent_issue_id => parent.id, :subject => 'Child 1')
922 943 child2 = Issue.generate!(:parent_issue_id => parent.id, :subject => 'Child 2')
923 944
924 945 copy = parent.reload.copy
925 946 copy.parent_issue_id = parent.id
926 947 copy.author = User.find(7)
927 948 assert_difference 'Issue.count', 3 do
928 949 assert copy.save
929 950 end
930 951 parent.reload
931 952 copy.reload
932 953 assert_equal parent, copy.parent
933 954 assert_equal 3, parent.children.count
934 955 assert_equal 5, parent.descendants.count
935 956 assert_equal 2, copy.children.count
936 957 assert_equal 2, copy.descendants.count
937 958 end
938 959
939 960 def test_copy_as_a_descendant_of_copied_issue_should_not_copy_itself
940 961 parent = Issue.generate!
941 962 child1 = Issue.generate!(:parent_issue_id => parent.id, :subject => 'Child 1')
942 963 child2 = Issue.generate!(:parent_issue_id => parent.id, :subject => 'Child 2')
943 964
944 965 copy = parent.reload.copy
945 966 copy.parent_issue_id = child1.id
946 967 copy.author = User.find(7)
947 968 assert_difference 'Issue.count', 3 do
948 969 assert copy.save
949 970 end
950 971 parent.reload
951 972 child1.reload
952 973 copy.reload
953 974 assert_equal child1, copy.parent
954 975 assert_equal 2, parent.children.count
955 976 assert_equal 5, parent.descendants.count
956 977 assert_equal 1, child1.children.count
957 978 assert_equal 3, child1.descendants.count
958 979 assert_equal 2, copy.children.count
959 980 assert_equal 2, copy.descendants.count
960 981 end
961 982
962 983 def test_copy_should_copy_subtasks_to_target_project
963 984 issue = Issue.generate_with_descendants!
964 985
965 986 copy = issue.copy(:project_id => 3)
966 987 assert_difference 'Issue.count', 1+issue.descendants.count do
967 988 assert copy.save
968 989 end
969 990 assert_equal [3], copy.reload.descendants.map(&:project_id).uniq
970 991 end
971 992
972 993 def test_copy_should_not_copy_subtasks_twice_when_saving_twice
973 994 issue = Issue.generate_with_descendants!
974 995
975 996 copy = issue.reload.copy
976 997 assert_difference 'Issue.count', 1+issue.descendants.count do
977 998 assert copy.save
978 999 assert copy.save
979 1000 end
980 1001 end
981 1002
982 1003 def test_should_not_call_after_project_change_on_creation
983 1004 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1,
984 1005 :subject => 'Test', :author_id => 1)
985 1006 issue.expects(:after_project_change).never
986 1007 issue.save!
987 1008 end
988 1009
989 1010 def test_should_not_call_after_project_change_on_update
990 1011 issue = Issue.find(1)
991 1012 issue.project = Project.find(1)
992 1013 issue.subject = 'No project change'
993 1014 issue.expects(:after_project_change).never
994 1015 issue.save!
995 1016 end
996 1017
997 1018 def test_should_call_after_project_change_on_project_change
998 1019 issue = Issue.find(1)
999 1020 issue.project = Project.find(2)
1000 1021 issue.expects(:after_project_change).once
1001 1022 issue.save!
1002 1023 end
1003 1024
1004 1025 def test_adding_journal_should_update_timestamp
1005 1026 issue = Issue.find(1)
1006 1027 updated_on_was = issue.updated_on
1007 1028
1008 1029 issue.init_journal(User.first, "Adding notes")
1009 1030 assert_difference 'Journal.count' do
1010 1031 assert issue.save
1011 1032 end
1012 1033 issue.reload
1013 1034
1014 1035 assert_not_equal updated_on_was, issue.updated_on
1015 1036 end
1016 1037
1017 1038 def test_should_close_duplicates
1018 1039 # Create 3 issues
1019 1040 issue1 = Issue.generate!
1020 1041 issue2 = Issue.generate!
1021 1042 issue3 = Issue.generate!
1022 1043
1023 1044 # 2 is a dupe of 1
1024 1045 IssueRelation.create!(:issue_from => issue2, :issue_to => issue1,
1025 1046 :relation_type => IssueRelation::TYPE_DUPLICATES)
1026 1047 # And 3 is a dupe of 2
1027 1048 IssueRelation.create!(:issue_from => issue3, :issue_to => issue2,
1028 1049 :relation_type => IssueRelation::TYPE_DUPLICATES)
1029 1050 # And 3 is a dupe of 1 (circular duplicates)
1030 1051 IssueRelation.create!(:issue_from => issue3, :issue_to => issue1,
1031 1052 :relation_type => IssueRelation::TYPE_DUPLICATES)
1032 1053
1033 1054 assert issue1.reload.duplicates.include?(issue2)
1034 1055
1035 1056 # Closing issue 1
1036 1057 issue1.init_journal(User.first, "Closing issue1")
1037 1058 issue1.status = IssueStatus.where(:is_closed => true).first
1038 1059 assert issue1.save
1039 1060 # 2 and 3 should be also closed
1040 1061 assert issue2.reload.closed?
1041 1062 assert issue3.reload.closed?
1042 1063 end
1043 1064
1044 1065 def test_should_not_close_duplicated_issue
1045 1066 issue1 = Issue.generate!
1046 1067 issue2 = Issue.generate!
1047 1068
1048 1069 # 2 is a dupe of 1
1049 1070 IssueRelation.create(:issue_from => issue2, :issue_to => issue1,
1050 1071 :relation_type => IssueRelation::TYPE_DUPLICATES)
1051 1072 # 2 is a dup of 1 but 1 is not a duplicate of 2
1052 1073 assert !issue2.reload.duplicates.include?(issue1)
1053 1074
1054 1075 # Closing issue 2
1055 1076 issue2.init_journal(User.first, "Closing issue2")
1056 1077 issue2.status = IssueStatus.where(:is_closed => true).first
1057 1078 assert issue2.save
1058 1079 # 1 should not be also closed
1059 1080 assert !issue1.reload.closed?
1060 1081 end
1061 1082
1062 1083 def test_assignable_versions
1063 1084 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
1064 1085 :status_id => 1, :fixed_version_id => 1,
1065 1086 :subject => 'New issue')
1066 1087 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
1067 1088 end
1068 1089
1069 1090 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
1070 1091 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
1071 1092 :status_id => 1, :fixed_version_id => 1,
1072 1093 :subject => 'New issue')
1073 1094 assert !issue.save
1074 1095 assert_not_equal [], issue.errors[:fixed_version_id]
1075 1096 end
1076 1097
1077 1098 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
1078 1099 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
1079 1100 :status_id => 1, :fixed_version_id => 2,
1080 1101 :subject => 'New issue')
1081 1102 assert !issue.save
1082 1103 assert_not_equal [], issue.errors[:fixed_version_id]
1083 1104 end
1084 1105
1085 1106 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
1086 1107 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
1087 1108 :status_id => 1, :fixed_version_id => 3,
1088 1109 :subject => 'New issue')
1089 1110 assert issue.save
1090 1111 end
1091 1112
1092 1113 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
1093 1114 issue = Issue.find(11)
1094 1115 assert_equal 'closed', issue.fixed_version.status
1095 1116 issue.subject = 'Subject changed'
1096 1117 assert issue.save
1097 1118 end
1098 1119
1099 1120 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
1100 1121 issue = Issue.find(11)
1101 1122 issue.status_id = 1
1102 1123 assert !issue.save
1103 1124 assert_not_equal [], issue.errors[:base]
1104 1125 end
1105 1126
1106 1127 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
1107 1128 issue = Issue.find(11)
1108 1129 issue.status_id = 1
1109 1130 issue.fixed_version_id = 3
1110 1131 assert issue.save
1111 1132 end
1112 1133
1113 1134 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
1114 1135 issue = Issue.find(12)
1115 1136 assert_equal 'locked', issue.fixed_version.status
1116 1137 issue.status_id = 1
1117 1138 assert issue.save
1118 1139 end
1119 1140
1120 1141 def test_should_not_be_able_to_keep_unshared_version_when_changing_project
1121 1142 issue = Issue.find(2)
1122 1143 assert_equal 2, issue.fixed_version_id
1123 1144 issue.project_id = 3
1124 1145 assert_nil issue.fixed_version_id
1125 1146 issue.fixed_version_id = 2
1126 1147 assert !issue.save
1127 1148 assert_include 'Target version is not included in the list', issue.errors.full_messages
1128 1149 end
1129 1150
1130 1151 def test_should_keep_shared_version_when_changing_project
1131 1152 Version.find(2).update_attribute :sharing, 'tree'
1132 1153
1133 1154 issue = Issue.find(2)
1134 1155 assert_equal 2, issue.fixed_version_id
1135 1156 issue.project_id = 3
1136 1157 assert_equal 2, issue.fixed_version_id
1137 1158 assert issue.save
1138 1159 end
1139 1160
1140 1161 def test_allowed_target_projects_on_move_should_include_projects_with_issue_tracking_enabled
1141 1162 assert_include Project.find(2), Issue.allowed_target_projects_on_move(User.find(2))
1142 1163 end
1143 1164
1144 1165 def test_allowed_target_projects_on_move_should_not_include_projects_with_issue_tracking_disabled
1145 1166 Project.find(2).disable_module! :issue_tracking
1146 1167 assert_not_include Project.find(2), Issue.allowed_target_projects_on_move(User.find(2))
1147 1168 end
1148 1169
1149 1170 def test_move_to_another_project_with_same_category
1150 1171 issue = Issue.find(1)
1151 1172 issue.project = Project.find(2)
1152 1173 assert issue.save
1153 1174 issue.reload
1154 1175 assert_equal 2, issue.project_id
1155 1176 # Category changes
1156 1177 assert_equal 4, issue.category_id
1157 1178 # Make sure time entries were move to the target project
1158 1179 assert_equal 2, issue.time_entries.first.project_id
1159 1180 end
1160 1181
1161 1182 def test_move_to_another_project_without_same_category
1162 1183 issue = Issue.find(2)
1163 1184 issue.project = Project.find(2)
1164 1185 assert issue.save
1165 1186 issue.reload
1166 1187 assert_equal 2, issue.project_id
1167 1188 # Category cleared
1168 1189 assert_nil issue.category_id
1169 1190 end
1170 1191
1171 1192 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
1172 1193 issue = Issue.find(1)
1173 1194 issue.update_attribute(:fixed_version_id, 1)
1174 1195 issue.project = Project.find(2)
1175 1196 assert issue.save
1176 1197 issue.reload
1177 1198 assert_equal 2, issue.project_id
1178 1199 # Cleared fixed_version
1179 1200 assert_equal nil, issue.fixed_version
1180 1201 end
1181 1202
1182 1203 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
1183 1204 issue = Issue.find(1)
1184 1205 issue.update_attribute(:fixed_version_id, 4)
1185 1206 issue.project = Project.find(5)
1186 1207 assert issue.save
1187 1208 issue.reload
1188 1209 assert_equal 5, issue.project_id
1189 1210 # Keep fixed_version
1190 1211 assert_equal 4, issue.fixed_version_id
1191 1212 end
1192 1213
1193 1214 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
1194 1215 issue = Issue.find(1)
1195 1216 issue.update_attribute(:fixed_version_id, 1)
1196 1217 issue.project = Project.find(5)
1197 1218 assert issue.save
1198 1219 issue.reload
1199 1220 assert_equal 5, issue.project_id
1200 1221 # Cleared fixed_version
1201 1222 assert_equal nil, issue.fixed_version
1202 1223 end
1203 1224
1204 1225 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
1205 1226 issue = Issue.find(1)
1206 1227 issue.update_attribute(:fixed_version_id, 7)
1207 1228 issue.project = Project.find(2)
1208 1229 assert issue.save
1209 1230 issue.reload
1210 1231 assert_equal 2, issue.project_id
1211 1232 # Keep fixed_version
1212 1233 assert_equal 7, issue.fixed_version_id
1213 1234 end
1214 1235
1215 1236 def test_move_to_another_project_should_keep_parent_if_valid
1216 1237 issue = Issue.find(1)
1217 1238 issue.update_attribute(:parent_issue_id, 2)
1218 1239 issue.project = Project.find(3)
1219 1240 assert issue.save
1220 1241 issue.reload
1221 1242 assert_equal 2, issue.parent_id
1222 1243 end
1223 1244
1224 1245 def test_move_to_another_project_should_clear_parent_if_not_valid
1225 1246 issue = Issue.find(1)
1226 1247 issue.update_attribute(:parent_issue_id, 2)
1227 1248 issue.project = Project.find(2)
1228 1249 assert issue.save
1229 1250 issue.reload
1230 1251 assert_nil issue.parent_id
1231 1252 end
1232 1253
1233 1254 def test_move_to_another_project_with_disabled_tracker
1234 1255 issue = Issue.find(1)
1235 1256 target = Project.find(2)
1236 1257 target.tracker_ids = [3]
1237 1258 target.save
1238 1259 issue.project = target
1239 1260 assert issue.save
1240 1261 issue.reload
1241 1262 assert_equal 2, issue.project_id
1242 1263 assert_equal 3, issue.tracker_id
1243 1264 end
1244 1265
1245 1266 def test_copy_to_the_same_project
1246 1267 issue = Issue.find(1)
1247 1268 copy = issue.copy
1248 1269 assert_difference 'Issue.count' do
1249 1270 copy.save!
1250 1271 end
1251 1272 assert_kind_of Issue, copy
1252 1273 assert_equal issue.project, copy.project
1253 1274 assert_equal "125", copy.custom_value_for(2).value
1254 1275 end
1255 1276
1256 1277 def test_copy_to_another_project_and_tracker
1257 1278 issue = Issue.find(1)
1258 1279 copy = issue.copy(:project_id => 3, :tracker_id => 2)
1259 1280 assert_difference 'Issue.count' do
1260 1281 copy.save!
1261 1282 end
1262 1283 copy.reload
1263 1284 assert_kind_of Issue, copy
1264 1285 assert_equal Project.find(3), copy.project
1265 1286 assert_equal Tracker.find(2), copy.tracker
1266 1287 # Custom field #2 is not associated with target tracker
1267 1288 assert_nil copy.custom_value_for(2)
1268 1289 end
1269 1290
1270 1291 test "#copy should not create a journal" do
1271 1292 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
1272 1293 copy.save!
1273 1294 assert_equal 0, copy.reload.journals.size
1274 1295 end
1275 1296
1276 1297 test "#copy should allow assigned_to changes" do
1277 1298 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
1278 1299 assert_equal 3, copy.assigned_to_id
1279 1300 end
1280 1301
1281 1302 test "#copy should allow status changes" do
1282 1303 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :status_id => 2)
1283 1304 assert_equal 2, copy.status_id
1284 1305 end
1285 1306
1286 1307 test "#copy should allow start date changes" do
1287 1308 date = Date.today
1288 1309 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :start_date => date)
1289 1310 assert_equal date, copy.start_date
1290 1311 end
1291 1312
1292 1313 test "#copy should allow due date changes" do
1293 1314 date = Date.today
1294 1315 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :due_date => date)
1295 1316 assert_equal date, copy.due_date
1296 1317 end
1297 1318
1298 1319 test "#copy should set current user as author" do
1299 1320 User.current = User.find(9)
1300 1321 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2)
1301 1322 assert_equal User.current, copy.author
1302 1323 end
1303 1324
1304 1325 test "#copy should create a journal with notes" do
1305 1326 date = Date.today
1306 1327 notes = "Notes added when copying"
1307 1328 copy = Issue.find(1).copy(:project_id => 3, :tracker_id => 2, :start_date => date)
1308 1329 copy.init_journal(User.current, notes)
1309 1330 copy.save!
1310 1331
1311 1332 assert_equal 1, copy.journals.size
1312 1333 journal = copy.journals.first
1313 1334 assert_equal 0, journal.details.size
1314 1335 assert_equal notes, journal.notes
1315 1336 end
1316 1337
1317 1338 def test_valid_parent_project
1318 1339 issue = Issue.find(1)
1319 1340 issue_in_same_project = Issue.find(2)
1320 1341 issue_in_child_project = Issue.find(5)
1321 1342 issue_in_grandchild_project = Issue.generate!(:project_id => 6, :tracker_id => 1)
1322 1343 issue_in_other_child_project = Issue.find(6)
1323 1344 issue_in_different_tree = Issue.find(4)
1324 1345
1325 1346 with_settings :cross_project_subtasks => '' do
1326 1347 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1327 1348 assert_equal false, issue.valid_parent_project?(issue_in_child_project)
1328 1349 assert_equal false, issue.valid_parent_project?(issue_in_grandchild_project)
1329 1350 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1330 1351 end
1331 1352
1332 1353 with_settings :cross_project_subtasks => 'system' do
1333 1354 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1334 1355 assert_equal true, issue.valid_parent_project?(issue_in_child_project)
1335 1356 assert_equal true, issue.valid_parent_project?(issue_in_different_tree)
1336 1357 end
1337 1358
1338 1359 with_settings :cross_project_subtasks => 'tree' do
1339 1360 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1340 1361 assert_equal true, issue.valid_parent_project?(issue_in_child_project)
1341 1362 assert_equal true, issue.valid_parent_project?(issue_in_grandchild_project)
1342 1363 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1343 1364
1344 1365 assert_equal true, issue_in_child_project.valid_parent_project?(issue_in_same_project)
1345 1366 assert_equal true, issue_in_child_project.valid_parent_project?(issue_in_other_child_project)
1346 1367 end
1347 1368
1348 1369 with_settings :cross_project_subtasks => 'descendants' do
1349 1370 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1350 1371 assert_equal false, issue.valid_parent_project?(issue_in_child_project)
1351 1372 assert_equal false, issue.valid_parent_project?(issue_in_grandchild_project)
1352 1373 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1353 1374
1354 1375 assert_equal true, issue_in_child_project.valid_parent_project?(issue)
1355 1376 assert_equal false, issue_in_child_project.valid_parent_project?(issue_in_other_child_project)
1356 1377 end
1357 1378 end
1358 1379
1359 1380 def test_recipients_should_include_previous_assignee
1360 1381 user = User.find(3)
1361 1382 user.members.update_all ["mail_notification = ?", false]
1362 1383 user.update_attribute :mail_notification, 'only_assigned'
1363 1384
1364 1385 issue = Issue.find(2)
1365 1386 issue.assigned_to = nil
1366 1387 assert_include user.mail, issue.recipients
1367 1388 issue.save!
1368 1389 assert !issue.recipients.include?(user.mail)
1369 1390 end
1370 1391
1371 1392 def test_recipients_should_not_include_users_that_cannot_view_the_issue
1372 1393 issue = Issue.find(12)
1373 1394 assert issue.recipients.include?(issue.author.mail)
1374 1395 # copy the issue to a private project
1375 1396 copy = issue.copy(:project_id => 5, :tracker_id => 2)
1376 1397 # author is not a member of project anymore
1377 1398 assert !copy.recipients.include?(copy.author.mail)
1378 1399 end
1379 1400
1380 1401 def test_recipients_should_include_the_assigned_group_members
1381 1402 group_member = User.generate!
1382 1403 group = Group.generate!
1383 1404 group.users << group_member
1384 1405
1385 1406 issue = Issue.find(12)
1386 1407 issue.assigned_to = group
1387 1408 assert issue.recipients.include?(group_member.mail)
1388 1409 end
1389 1410
1390 1411 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
1391 1412 user = User.find(3)
1392 1413 issue = Issue.find(9)
1393 1414 Watcher.create!(:user => user, :watchable => issue)
1394 1415 assert issue.watched_by?(user)
1395 1416 assert !issue.watcher_recipients.include?(user.mail)
1396 1417 end
1397 1418
1398 1419 def test_issue_destroy
1399 1420 Issue.find(1).destroy
1400 1421 assert_nil Issue.find_by_id(1)
1401 1422 assert_nil TimeEntry.find_by_issue_id(1)
1402 1423 end
1403 1424
1404 1425 def test_destroy_should_delete_time_entries_custom_values
1405 1426 issue = Issue.generate!
1406 1427 time_entry = TimeEntry.generate!(:issue => issue, :custom_field_values => {10 => '1'})
1407 1428
1408 1429 assert_difference 'CustomValue.where(:customized_type => "TimeEntry").count', -1 do
1409 1430 assert issue.destroy
1410 1431 end
1411 1432 end
1412 1433
1413 1434 def test_destroying_a_deleted_issue_should_not_raise_an_error
1414 1435 issue = Issue.find(1)
1415 1436 Issue.find(1).destroy
1416 1437
1417 1438 assert_nothing_raised do
1418 1439 assert_no_difference 'Issue.count' do
1419 1440 issue.destroy
1420 1441 end
1421 1442 assert issue.destroyed?
1422 1443 end
1423 1444 end
1424 1445
1425 1446 def test_destroying_a_stale_issue_should_not_raise_an_error
1426 1447 issue = Issue.find(1)
1427 1448 Issue.find(1).update_attribute :subject, "Updated"
1428 1449
1429 1450 assert_nothing_raised do
1430 1451 assert_difference 'Issue.count', -1 do
1431 1452 issue.destroy
1432 1453 end
1433 1454 assert issue.destroyed?
1434 1455 end
1435 1456 end
1436 1457
1437 1458 def test_blocked
1438 1459 blocked_issue = Issue.find(9)
1439 1460 blocking_issue = Issue.find(10)
1440 1461
1441 1462 assert blocked_issue.blocked?
1442 1463 assert !blocking_issue.blocked?
1443 1464 end
1444 1465
1445 1466 def test_blocked_issues_dont_allow_closed_statuses
1446 1467 blocked_issue = Issue.find(9)
1447 1468
1448 1469 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
1449 1470 assert !allowed_statuses.empty?
1450 1471 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
1451 1472 assert closed_statuses.empty?
1452 1473 end
1453 1474
1454 1475 def test_unblocked_issues_allow_closed_statuses
1455 1476 blocking_issue = Issue.find(10)
1456 1477
1457 1478 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
1458 1479 assert !allowed_statuses.empty?
1459 1480 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
1460 1481 assert !closed_statuses.empty?
1461 1482 end
1462 1483
1463 1484 def test_reschedule_an_issue_without_dates
1464 1485 with_settings :non_working_week_days => [] do
1465 1486 issue = Issue.new(:start_date => nil, :due_date => nil)
1466 1487 issue.reschedule_on '2012-10-09'.to_date
1467 1488 assert_equal '2012-10-09'.to_date, issue.start_date
1468 1489 assert_equal '2012-10-09'.to_date, issue.due_date
1469 1490 end
1470 1491
1471 1492 with_settings :non_working_week_days => %w(6 7) do
1472 1493 issue = Issue.new(:start_date => nil, :due_date => nil)
1473 1494 issue.reschedule_on '2012-10-09'.to_date
1474 1495 assert_equal '2012-10-09'.to_date, issue.start_date
1475 1496 assert_equal '2012-10-09'.to_date, issue.due_date
1476 1497
1477 1498 issue = Issue.new(:start_date => nil, :due_date => nil)
1478 1499 issue.reschedule_on '2012-10-13'.to_date
1479 1500 assert_equal '2012-10-15'.to_date, issue.start_date
1480 1501 assert_equal '2012-10-15'.to_date, issue.due_date
1481 1502 end
1482 1503 end
1483 1504
1484 1505 def test_reschedule_an_issue_with_start_date
1485 1506 with_settings :non_working_week_days => [] do
1486 1507 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1487 1508 issue.reschedule_on '2012-10-13'.to_date
1488 1509 assert_equal '2012-10-13'.to_date, issue.start_date
1489 1510 assert_equal '2012-10-13'.to_date, issue.due_date
1490 1511 end
1491 1512
1492 1513 with_settings :non_working_week_days => %w(6 7) do
1493 1514 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1494 1515 issue.reschedule_on '2012-10-11'.to_date
1495 1516 assert_equal '2012-10-11'.to_date, issue.start_date
1496 1517 assert_equal '2012-10-11'.to_date, issue.due_date
1497 1518
1498 1519 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1499 1520 issue.reschedule_on '2012-10-13'.to_date
1500 1521 assert_equal '2012-10-15'.to_date, issue.start_date
1501 1522 assert_equal '2012-10-15'.to_date, issue.due_date
1502 1523 end
1503 1524 end
1504 1525
1505 1526 def test_reschedule_an_issue_with_start_and_due_dates
1506 1527 with_settings :non_working_week_days => [] do
1507 1528 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-15')
1508 1529 issue.reschedule_on '2012-10-13'.to_date
1509 1530 assert_equal '2012-10-13'.to_date, issue.start_date
1510 1531 assert_equal '2012-10-19'.to_date, issue.due_date
1511 1532 end
1512 1533
1513 1534 with_settings :non_working_week_days => %w(6 7) do
1514 1535 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19') # 8 working days
1515 1536 issue.reschedule_on '2012-10-11'.to_date
1516 1537 assert_equal '2012-10-11'.to_date, issue.start_date
1517 1538 assert_equal '2012-10-23'.to_date, issue.due_date
1518 1539
1519 1540 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19')
1520 1541 issue.reschedule_on '2012-10-13'.to_date
1521 1542 assert_equal '2012-10-15'.to_date, issue.start_date
1522 1543 assert_equal '2012-10-25'.to_date, issue.due_date
1523 1544 end
1524 1545 end
1525 1546
1526 1547 def test_rescheduling_an_issue_to_a_later_due_date_should_reschedule_following_issue
1527 1548 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1528 1549 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1529 1550 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1530 1551 :relation_type => IssueRelation::TYPE_PRECEDES)
1531 1552 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1532 1553
1533 1554 issue1.reload
1534 1555 issue1.due_date = '2012-10-23'
1535 1556 issue1.save!
1536 1557 issue2.reload
1537 1558 assert_equal Date.parse('2012-10-24'), issue2.start_date
1538 1559 assert_equal Date.parse('2012-10-26'), issue2.due_date
1539 1560 end
1540 1561
1541 1562 def test_rescheduling_an_issue_to_an_earlier_due_date_should_reschedule_following_issue
1542 1563 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1543 1564 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1544 1565 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1545 1566 :relation_type => IssueRelation::TYPE_PRECEDES)
1546 1567 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1547 1568
1548 1569 issue1.reload
1549 1570 issue1.start_date = '2012-09-17'
1550 1571 issue1.due_date = '2012-09-18'
1551 1572 issue1.save!
1552 1573 issue2.reload
1553 1574 assert_equal Date.parse('2012-09-19'), issue2.start_date
1554 1575 assert_equal Date.parse('2012-09-21'), issue2.due_date
1555 1576 end
1556 1577
1557 1578 def test_rescheduling_reschedule_following_issue_earlier_should_consider_other_preceding_issues
1558 1579 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1559 1580 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1560 1581 issue3 = Issue.generate!(:start_date => '2012-10-01', :due_date => '2012-10-02')
1561 1582 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1562 1583 :relation_type => IssueRelation::TYPE_PRECEDES)
1563 1584 IssueRelation.create!(:issue_from => issue3, :issue_to => issue2,
1564 1585 :relation_type => IssueRelation::TYPE_PRECEDES)
1565 1586 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1566 1587
1567 1588 issue1.reload
1568 1589 issue1.start_date = '2012-09-17'
1569 1590 issue1.due_date = '2012-09-18'
1570 1591 issue1.save!
1571 1592 issue2.reload
1572 1593 # Issue 2 must start after Issue 3
1573 1594 assert_equal Date.parse('2012-10-03'), issue2.start_date
1574 1595 assert_equal Date.parse('2012-10-05'), issue2.due_date
1575 1596 end
1576 1597
1577 1598 def test_rescheduling_a_stale_issue_should_not_raise_an_error
1578 1599 with_settings :non_working_week_days => [] do
1579 1600 stale = Issue.find(1)
1580 1601 issue = Issue.find(1)
1581 1602 issue.subject = "Updated"
1582 1603 issue.save!
1583 1604 date = 10.days.from_now.to_date
1584 1605 assert_nothing_raised do
1585 1606 stale.reschedule_on!(date)
1586 1607 end
1587 1608 assert_equal date, stale.reload.start_date
1588 1609 end
1589 1610 end
1590 1611
1591 1612 def test_child_issue_should_consider_parent_soonest_start_on_create
1592 1613 set_language_if_valid 'en'
1593 1614 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1594 1615 issue2 = Issue.generate!(:start_date => '2012-10-18', :due_date => '2012-10-20')
1595 1616 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1596 1617 :relation_type => IssueRelation::TYPE_PRECEDES)
1597 1618 issue1.reload
1598 1619 issue2.reload
1599 1620 assert_equal Date.parse('2012-10-18'), issue2.start_date
1600 1621
1601 1622 child = Issue.new(:parent_issue_id => issue2.id, :start_date => '2012-10-16',
1602 1623 :project_id => 1, :tracker_id => 1, :status_id => 1, :subject => 'Child', :author_id => 1)
1603 1624 assert !child.valid?
1604 1625 assert_include 'Start date cannot be earlier than 10/18/2012 because of preceding issues', child.errors.full_messages
1605 1626 assert_equal Date.parse('2012-10-18'), child.soonest_start
1606 1627 child.start_date = '2012-10-18'
1607 1628 assert child.save
1608 1629 end
1609 1630
1610 1631 def test_setting_parent_to_a_dependent_issue_should_not_validate
1611 1632 set_language_if_valid 'en'
1612 1633 issue1 = Issue.generate!
1613 1634 issue2 = Issue.generate!
1614 1635 issue3 = Issue.generate!
1615 1636 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
1616 1637 IssueRelation.create!(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_PRECEDES)
1617 1638 issue3.reload
1618 1639 issue3.parent_issue_id = issue2.id
1619 1640 assert !issue3.valid?
1620 1641 assert_include 'Parent task is invalid', issue3.errors.full_messages
1621 1642 end
1622 1643
1623 1644 def test_setting_parent_should_not_allow_circular_dependency
1624 1645 set_language_if_valid 'en'
1625 1646 issue1 = Issue.generate!
1626 1647 issue2 = Issue.generate!
1627 1648 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
1628 1649 issue3 = Issue.generate!
1629 1650 issue2.reload
1630 1651 issue2.parent_issue_id = issue3.id
1631 1652 issue2.save!
1632 1653 issue4 = Issue.generate!
1633 1654 IssueRelation.create!(:issue_from => issue3, :issue_to => issue4, :relation_type => IssueRelation::TYPE_PRECEDES)
1634 1655 issue4.reload
1635 1656 issue4.parent_issue_id = issue1.id
1636 1657 assert !issue4.valid?
1637 1658 assert_include 'Parent task is invalid', issue4.errors.full_messages
1638 1659 end
1639 1660
1640 1661 def test_overdue
1641 1662 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
1642 1663 assert !Issue.new(:due_date => Date.today).overdue?
1643 1664 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
1644 1665 assert !Issue.new(:due_date => nil).overdue?
1645 1666 assert !Issue.new(:due_date => 1.day.ago.to_date,
1646 1667 :status => IssueStatus.where(:is_closed => true).first
1647 1668 ).overdue?
1648 1669 end
1649 1670
1650 1671 test "#behind_schedule? should be false if the issue has no start_date" do
1651 1672 assert !Issue.new(:start_date => nil,
1652 1673 :due_date => 1.day.from_now.to_date,
1653 1674 :done_ratio => 0).behind_schedule?
1654 1675 end
1655 1676
1656 1677 test "#behind_schedule? should be false if the issue has no end_date" do
1657 1678 assert !Issue.new(:start_date => 1.day.from_now.to_date,
1658 1679 :due_date => nil,
1659 1680 :done_ratio => 0).behind_schedule?
1660 1681 end
1661 1682
1662 1683 test "#behind_schedule? should be false if the issue has more done than it's calendar time" do
1663 1684 assert !Issue.new(:start_date => 50.days.ago.to_date,
1664 1685 :due_date => 50.days.from_now.to_date,
1665 1686 :done_ratio => 90).behind_schedule?
1666 1687 end
1667 1688
1668 1689 test "#behind_schedule? should be true if the issue hasn't been started at all" do
1669 1690 assert Issue.new(:start_date => 1.day.ago.to_date,
1670 1691 :due_date => 1.day.from_now.to_date,
1671 1692 :done_ratio => 0).behind_schedule?
1672 1693 end
1673 1694
1674 1695 test "#behind_schedule? should be true if the issue has used more calendar time than it's done ratio" do
1675 1696 assert Issue.new(:start_date => 100.days.ago.to_date,
1676 1697 :due_date => Date.today,
1677 1698 :done_ratio => 90).behind_schedule?
1678 1699 end
1679 1700
1680 1701 test "#assignable_users should be Users" do
1681 1702 assert_kind_of User, Issue.find(1).assignable_users.first
1682 1703 end
1683 1704
1684 1705 test "#assignable_users should include the issue author" do
1685 1706 non_project_member = User.generate!
1686 1707 issue = Issue.generate!(:author => non_project_member)
1687 1708
1688 1709 assert issue.assignable_users.include?(non_project_member)
1689 1710 end
1690 1711
1691 1712 test "#assignable_users should include the current assignee" do
1692 1713 user = User.generate!
1693 1714 issue = Issue.generate!(:assigned_to => user)
1694 1715 user.lock!
1695 1716
1696 1717 assert Issue.find(issue.id).assignable_users.include?(user)
1697 1718 end
1698 1719
1699 1720 test "#assignable_users should not show the issue author twice" do
1700 1721 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
1701 1722 assert_equal 2, assignable_user_ids.length
1702 1723
1703 1724 assignable_user_ids.each do |user_id|
1704 1725 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length,
1705 1726 "User #{user_id} appears more or less than once"
1706 1727 end
1707 1728 end
1708 1729
1709 1730 test "#assignable_users with issue_group_assignment should include groups" do
1710 1731 issue = Issue.new(:project => Project.find(2))
1711 1732
1712 1733 with_settings :issue_group_assignment => '1' do
1713 1734 assert_equal %w(Group User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
1714 1735 assert issue.assignable_users.include?(Group.find(11))
1715 1736 end
1716 1737 end
1717 1738
1718 1739 test "#assignable_users without issue_group_assignment should not include groups" do
1719 1740 issue = Issue.new(:project => Project.find(2))
1720 1741
1721 1742 with_settings :issue_group_assignment => '0' do
1722 1743 assert_equal %w(User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
1723 1744 assert !issue.assignable_users.include?(Group.find(11))
1724 1745 end
1725 1746 end
1726 1747
1748 def test_assignable_users_should_not_include_builtin_groups
1749 Member.create!(:project_id => 1, :principal => Group.non_member, :role_ids => [1])
1750 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [1])
1751 issue = Issue.new(:project => Project.find(1))
1752
1753 with_settings :issue_group_assignment => '1' do
1754 assert_nil issue.assignable_users.detect {|u| u.is_a?(GroupBuiltin)}
1755 end
1756 end
1757
1727 1758 def test_create_should_send_email_notification
1728 1759 ActionMailer::Base.deliveries.clear
1729 1760 issue = Issue.new(:project_id => 1, :tracker_id => 1,
1730 1761 :author_id => 3, :status_id => 1,
1731 1762 :priority => IssuePriority.all.first,
1732 1763 :subject => 'test_create', :estimated_hours => '1:30')
1733 1764 with_settings :notified_events => %w(issue_added) do
1734 1765 assert issue.save
1735 1766 assert_equal 1, ActionMailer::Base.deliveries.size
1736 1767 end
1737 1768 end
1738 1769
1739 1770 def test_create_should_send_one_email_notification_with_both_settings
1740 1771 ActionMailer::Base.deliveries.clear
1741 1772 issue = Issue.new(:project_id => 1, :tracker_id => 1,
1742 1773 :author_id => 3, :status_id => 1,
1743 1774 :priority => IssuePriority.all.first,
1744 1775 :subject => 'test_create', :estimated_hours => '1:30')
1745 1776 with_settings :notified_events => %w(issue_added issue_updated) do
1746 1777 assert issue.save
1747 1778 assert_equal 1, ActionMailer::Base.deliveries.size
1748 1779 end
1749 1780 end
1750 1781
1751 1782 def test_create_should_not_send_email_notification_with_no_setting
1752 1783 ActionMailer::Base.deliveries.clear
1753 1784 issue = Issue.new(:project_id => 1, :tracker_id => 1,
1754 1785 :author_id => 3, :status_id => 1,
1755 1786 :priority => IssuePriority.all.first,
1756 1787 :subject => 'test_create', :estimated_hours => '1:30')
1757 1788 with_settings :notified_events => [] do
1758 1789 assert issue.save
1759 1790 assert_equal 0, ActionMailer::Base.deliveries.size
1760 1791 end
1761 1792 end
1762 1793
1763 1794 def test_update_should_notify_previous_assignee
1764 1795 ActionMailer::Base.deliveries.clear
1765 1796 user = User.find(3)
1766 1797 user.members.update_all ["mail_notification = ?", false]
1767 1798 user.update_attribute :mail_notification, 'only_assigned'
1768 1799
1769 1800 issue = Issue.find(2)
1770 1801 issue.init_journal User.find(1)
1771 1802 issue.assigned_to = nil
1772 1803 issue.save!
1773 1804 assert_include user.mail, ActionMailer::Base.deliveries.last.bcc
1774 1805 end
1775 1806
1776 1807 def test_stale_issue_should_not_send_email_notification
1777 1808 ActionMailer::Base.deliveries.clear
1778 1809 issue = Issue.find(1)
1779 1810 stale = Issue.find(1)
1780 1811
1781 1812 issue.init_journal(User.find(1))
1782 1813 issue.subject = 'Subjet update'
1783 1814 with_settings :notified_events => %w(issue_updated) do
1784 1815 assert issue.save
1785 1816 assert_equal 1, ActionMailer::Base.deliveries.size
1786 1817 ActionMailer::Base.deliveries.clear
1787 1818
1788 1819 stale.init_journal(User.find(1))
1789 1820 stale.subject = 'Another subjet update'
1790 1821 assert_raise ActiveRecord::StaleObjectError do
1791 1822 stale.save
1792 1823 end
1793 1824 assert ActionMailer::Base.deliveries.empty?
1794 1825 end
1795 1826 end
1796 1827
1797 1828 def test_journalized_description
1798 1829 IssueCustomField.delete_all
1799 1830
1800 1831 i = Issue.first
1801 1832 old_description = i.description
1802 1833 new_description = "This is the new description"
1803 1834
1804 1835 i.init_journal(User.find(2))
1805 1836 i.description = new_description
1806 1837 assert_difference 'Journal.count', 1 do
1807 1838 assert_difference 'JournalDetail.count', 1 do
1808 1839 i.save!
1809 1840 end
1810 1841 end
1811 1842
1812 1843 detail = JournalDetail.order('id DESC').first
1813 1844 assert_equal i, detail.journal.journalized
1814 1845 assert_equal 'attr', detail.property
1815 1846 assert_equal 'description', detail.prop_key
1816 1847 assert_equal old_description, detail.old_value
1817 1848 assert_equal new_description, detail.value
1818 1849 end
1819 1850
1820 1851 def test_blank_descriptions_should_not_be_journalized
1821 1852 IssueCustomField.delete_all
1822 1853 Issue.where(:id => 1).update_all("description = NULL")
1823 1854
1824 1855 i = Issue.find(1)
1825 1856 i.init_journal(User.find(2))
1826 1857 i.subject = "blank description"
1827 1858 i.description = "\r\n"
1828 1859
1829 1860 assert_difference 'Journal.count', 1 do
1830 1861 assert_difference 'JournalDetail.count', 1 do
1831 1862 i.save!
1832 1863 end
1833 1864 end
1834 1865 end
1835 1866
1836 1867 def test_journalized_multi_custom_field
1837 1868 field = IssueCustomField.create!(:name => 'filter', :field_format => 'list',
1838 1869 :is_filter => true, :is_for_all => true,
1839 1870 :tracker_ids => [1],
1840 1871 :possible_values => ['value1', 'value2', 'value3'],
1841 1872 :multiple => true)
1842 1873
1843 1874 issue = Issue.create!(:project_id => 1, :tracker_id => 1,
1844 1875 :subject => 'Test', :author_id => 1)
1845 1876
1846 1877 assert_difference 'Journal.count' do
1847 1878 assert_difference 'JournalDetail.count' do
1848 1879 issue.init_journal(User.first)
1849 1880 issue.custom_field_values = {field.id => ['value1']}
1850 1881 issue.save!
1851 1882 end
1852 1883 assert_difference 'JournalDetail.count' do
1853 1884 issue.init_journal(User.first)
1854 1885 issue.custom_field_values = {field.id => ['value1', 'value2']}
1855 1886 issue.save!
1856 1887 end
1857 1888 assert_difference 'JournalDetail.count', 2 do
1858 1889 issue.init_journal(User.first)
1859 1890 issue.custom_field_values = {field.id => ['value3', 'value2']}
1860 1891 issue.save!
1861 1892 end
1862 1893 assert_difference 'JournalDetail.count', 2 do
1863 1894 issue.init_journal(User.first)
1864 1895 issue.custom_field_values = {field.id => nil}
1865 1896 issue.save!
1866 1897 end
1867 1898 end
1868 1899 end
1869 1900
1870 1901 def test_description_eol_should_be_normalized
1871 1902 i = Issue.new(:description => "CR \r LF \n CRLF \r\n")
1872 1903 assert_equal "CR \r\n LF \r\n CRLF \r\n", i.description
1873 1904 end
1874 1905
1875 1906 def test_saving_twice_should_not_duplicate_journal_details
1876 1907 i = Issue.first
1877 1908 i.init_journal(User.find(2), 'Some notes')
1878 1909 # initial changes
1879 1910 i.subject = 'New subject'
1880 1911 i.done_ratio = i.done_ratio + 10
1881 1912 assert_difference 'Journal.count' do
1882 1913 assert i.save
1883 1914 end
1884 1915 # 1 more change
1885 1916 i.priority = IssuePriority.where("id <> ?", i.priority_id).first
1886 1917 assert_no_difference 'Journal.count' do
1887 1918 assert_difference 'JournalDetail.count', 1 do
1888 1919 i.save
1889 1920 end
1890 1921 end
1891 1922 # no more change
1892 1923 assert_no_difference 'Journal.count' do
1893 1924 assert_no_difference 'JournalDetail.count' do
1894 1925 i.save
1895 1926 end
1896 1927 end
1897 1928 end
1898 1929
1899 1930 def test_all_dependent_issues
1900 1931 IssueRelation.delete_all
1901 1932 assert IssueRelation.create!(:issue_from => Issue.find(1),
1902 1933 :issue_to => Issue.find(2),
1903 1934 :relation_type => IssueRelation::TYPE_PRECEDES)
1904 1935 assert IssueRelation.create!(:issue_from => Issue.find(2),
1905 1936 :issue_to => Issue.find(3),
1906 1937 :relation_type => IssueRelation::TYPE_PRECEDES)
1907 1938 assert IssueRelation.create!(:issue_from => Issue.find(3),
1908 1939 :issue_to => Issue.find(8),
1909 1940 :relation_type => IssueRelation::TYPE_PRECEDES)
1910 1941
1911 1942 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
1912 1943 end
1913 1944
1914 1945 def test_all_dependent_issues_with_subtask
1915 1946 IssueRelation.delete_all
1916 1947
1917 1948 project = Project.generate!(:name => "testproject")
1918 1949
1919 1950 parentIssue = Issue.generate!(:project => project)
1920 1951 childIssue1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue.id)
1921 1952 childIssue2 = Issue.generate!(:project => project, :parent_issue_id => parentIssue.id)
1922 1953
1923 1954 assert_equal [childIssue1.id, childIssue2.id].sort, parentIssue.all_dependent_issues.collect(&:id).uniq.sort
1924 1955 end
1925 1956
1926 1957 def test_all_dependent_issues_does_not_include_self
1927 1958 IssueRelation.delete_all
1928 1959
1929 1960 project = Project.generate!(:name => "testproject")
1930 1961
1931 1962 parentIssue = Issue.generate!(:project => project)
1932 1963 childIssue = Issue.generate!(:project => project, :parent_issue_id => parentIssue.id)
1933 1964
1934 1965 assert_equal [childIssue.id], parentIssue.all_dependent_issues.collect(&:id)
1935 1966 end
1936 1967
1937 1968 def test_all_dependent_issues_with_parenttask_and_sibling
1938 1969 IssueRelation.delete_all
1939 1970
1940 1971 project = Project.generate!(:name => "testproject")
1941 1972
1942 1973 parentIssue = Issue.generate!(:project => project)
1943 1974 childIssue1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue.id)
1944 1975 childIssue2 = Issue.generate!(:project => project, :parent_issue_id => parentIssue.id)
1945 1976
1946 1977 assert_equal [parentIssue.id].sort, childIssue1.all_dependent_issues.collect(&:id)
1947 1978 end
1948 1979
1949 1980 def test_all_dependent_issues_with_relation_to_leaf_in_other_tree
1950 1981 IssueRelation.delete_all
1951 1982
1952 1983 project = Project.generate!(:name => "testproject")
1953 1984
1954 1985 parentIssue1 = Issue.generate!(:project => project)
1955 1986 childIssue1_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue1.id)
1956 1987 childIssue1_2 = Issue.generate!(:project => project, :parent_issue_id => parentIssue1.id)
1957 1988
1958 1989 parentIssue2 = Issue.generate!(:project => project)
1959 1990 childIssue2_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue2.id)
1960 1991 childIssue2_2 = Issue.generate!(:project => project, :parent_issue_id => parentIssue2.id)
1961 1992
1962 1993
1963 1994 assert IssueRelation.create(:issue_from => parentIssue1,
1964 1995 :issue_to => childIssue2_2,
1965 1996 :relation_type => IssueRelation::TYPE_BLOCKS)
1966 1997
1967 1998 assert_equal [childIssue1_1.id, childIssue1_2.id, parentIssue2.id, childIssue2_2.id].sort,
1968 1999 parentIssue1.all_dependent_issues.collect(&:id).uniq.sort
1969 2000 end
1970 2001
1971 2002 def test_all_dependent_issues_with_relation_to_parent_in_other_tree
1972 2003 IssueRelation.delete_all
1973 2004
1974 2005 project = Project.generate!(:name => "testproject")
1975 2006
1976 2007 parentIssue1 = Issue.generate!(:project => project)
1977 2008 childIssue1_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue1.id)
1978 2009 childIssue1_2 = Issue.generate!(:project => project, :parent_issue_id => parentIssue1.id)
1979 2010
1980 2011 parentIssue2 = Issue.generate!(:project => project)
1981 2012 childIssue2_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue2.id)
1982 2013 childIssue2_2 = Issue.generate!(:project => project, :parent_issue_id => parentIssue2.id)
1983 2014
1984 2015
1985 2016 assert IssueRelation.create(:issue_from => parentIssue1,
1986 2017 :issue_to => parentIssue2,
1987 2018 :relation_type => IssueRelation::TYPE_BLOCKS)
1988 2019
1989 2020 assert_equal [childIssue1_1.id, childIssue1_2.id, parentIssue2.id, childIssue2_1.id, childIssue2_2.id].sort,
1990 2021 parentIssue1.all_dependent_issues.collect(&:id).uniq.sort
1991 2022 end
1992 2023
1993 2024 def test_all_dependent_issues_with_transitive_relation
1994 2025 IssueRelation.delete_all
1995 2026
1996 2027 project = Project.generate!(:name => "testproject")
1997 2028
1998 2029 parentIssue1 = Issue.generate!(:project => project)
1999 2030 childIssue1_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue1.id)
2000 2031
2001 2032 parentIssue2 = Issue.generate!(:project => project)
2002 2033 childIssue2_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue2.id)
2003 2034
2004 2035 independentIssue = Issue.generate!(:project => project)
2005 2036
2006 2037 assert IssueRelation.create(:issue_from => parentIssue1,
2007 2038 :issue_to => childIssue2_1,
2008 2039 :relation_type => IssueRelation::TYPE_RELATES)
2009 2040
2010 2041 assert IssueRelation.create(:issue_from => childIssue2_1,
2011 2042 :issue_to => independentIssue,
2012 2043 :relation_type => IssueRelation::TYPE_RELATES)
2013 2044
2014 2045 assert_equal [childIssue1_1.id, parentIssue2.id, childIssue2_1.id, independentIssue.id].sort,
2015 2046 parentIssue1.all_dependent_issues.collect(&:id).uniq.sort
2016 2047 end
2017 2048
2018 2049 def test_all_dependent_issues_with_transitive_relation2
2019 2050 IssueRelation.delete_all
2020 2051
2021 2052 project = Project.generate!(:name => "testproject")
2022 2053
2023 2054 parentIssue1 = Issue.generate!(:project => project)
2024 2055 childIssue1_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue1.id)
2025 2056
2026 2057 parentIssue2 = Issue.generate!(:project => project)
2027 2058 childIssue2_1 = Issue.generate!(:project => project, :parent_issue_id => parentIssue2.id)
2028 2059
2029 2060 independentIssue = Issue.generate!(:project => project)
2030 2061
2031 2062 assert IssueRelation.create(:issue_from => parentIssue1,
2032 2063 :issue_to => independentIssue,
2033 2064 :relation_type => IssueRelation::TYPE_RELATES)
2034 2065
2035 2066 assert IssueRelation.create(:issue_from => independentIssue,
2036 2067 :issue_to => childIssue2_1,
2037 2068 :relation_type => IssueRelation::TYPE_RELATES)
2038 2069
2039 2070 assert_equal [childIssue1_1.id, parentIssue2.id, childIssue2_1.id, independentIssue.id].sort,
2040 2071 parentIssue1.all_dependent_issues.collect(&:id).uniq.sort
2041 2072
2042 2073 end
2043 2074
2044 2075 def test_all_dependent_issues_with_persistent_circular_dependency
2045 2076 IssueRelation.delete_all
2046 2077 assert IssueRelation.create!(:issue_from => Issue.find(1),
2047 2078 :issue_to => Issue.find(2),
2048 2079 :relation_type => IssueRelation::TYPE_PRECEDES)
2049 2080 assert IssueRelation.create!(:issue_from => Issue.find(2),
2050 2081 :issue_to => Issue.find(3),
2051 2082 :relation_type => IssueRelation::TYPE_PRECEDES)
2052 2083
2053 2084 r = IssueRelation.create!(:issue_from => Issue.find(3),
2054 2085 :issue_to => Issue.find(7),
2055 2086 :relation_type => IssueRelation::TYPE_PRECEDES)
2056 2087 IssueRelation.where(["id = ?", r.id]).update_all("issue_to_id = 1")
2057 2088
2058 2089 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
2059 2090 end
2060 2091
2061 2092 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
2062 2093 IssueRelation.delete_all
2063 2094 assert IssueRelation.create!(:issue_from => Issue.find(1),
2064 2095 :issue_to => Issue.find(2),
2065 2096 :relation_type => IssueRelation::TYPE_RELATES)
2066 2097 assert IssueRelation.create!(:issue_from => Issue.find(2),
2067 2098 :issue_to => Issue.find(3),
2068 2099 :relation_type => IssueRelation::TYPE_RELATES)
2069 2100 assert IssueRelation.create!(:issue_from => Issue.find(3),
2070 2101 :issue_to => Issue.find(8),
2071 2102 :relation_type => IssueRelation::TYPE_RELATES)
2072 2103
2073 2104 r = IssueRelation.create!(:issue_from => Issue.find(8),
2074 2105 :issue_to => Issue.find(7),
2075 2106 :relation_type => IssueRelation::TYPE_RELATES)
2076 2107 IssueRelation.where(["id = ?", r.id]).update_all("issue_to_id = 2")
2077 2108
2078 2109 r = IssueRelation.create!(:issue_from => Issue.find(3),
2079 2110 :issue_to => Issue.find(7),
2080 2111 :relation_type => IssueRelation::TYPE_RELATES)
2081 2112 IssueRelation.where(["id = ?", r.id]).update_all("issue_to_id = 1")
2082 2113
2083 2114 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
2084 2115 end
2085 2116
2086 2117 test "#done_ratio should use the issue_status according to Setting.issue_done_ratio" do
2087 2118 @issue = Issue.find(1)
2088 2119 @issue_status = IssueStatus.find(1)
2089 2120 @issue_status.update_attribute(:default_done_ratio, 50)
2090 2121 @issue2 = Issue.find(2)
2091 2122 @issue_status2 = IssueStatus.find(2)
2092 2123 @issue_status2.update_attribute(:default_done_ratio, 0)
2093 2124
2094 2125 with_settings :issue_done_ratio => 'issue_field' do
2095 2126 assert_equal 0, @issue.done_ratio
2096 2127 assert_equal 30, @issue2.done_ratio
2097 2128 end
2098 2129
2099 2130 with_settings :issue_done_ratio => 'issue_status' do
2100 2131 assert_equal 50, @issue.done_ratio
2101 2132 assert_equal 0, @issue2.done_ratio
2102 2133 end
2103 2134 end
2104 2135
2105 2136 test "#update_done_ratio_from_issue_status should update done_ratio according to Setting.issue_done_ratio" do
2106 2137 @issue = Issue.find(1)
2107 2138 @issue_status = IssueStatus.find(1)
2108 2139 @issue_status.update_attribute(:default_done_ratio, 50)
2109 2140 @issue2 = Issue.find(2)
2110 2141 @issue_status2 = IssueStatus.find(2)
2111 2142 @issue_status2.update_attribute(:default_done_ratio, 0)
2112 2143
2113 2144 with_settings :issue_done_ratio => 'issue_field' do
2114 2145 @issue.update_done_ratio_from_issue_status
2115 2146 @issue2.update_done_ratio_from_issue_status
2116 2147
2117 2148 assert_equal 0, @issue.read_attribute(:done_ratio)
2118 2149 assert_equal 30, @issue2.read_attribute(:done_ratio)
2119 2150 end
2120 2151
2121 2152 with_settings :issue_done_ratio => 'issue_status' do
2122 2153 @issue.update_done_ratio_from_issue_status
2123 2154 @issue2.update_done_ratio_from_issue_status
2124 2155
2125 2156 assert_equal 50, @issue.read_attribute(:done_ratio)
2126 2157 assert_equal 0, @issue2.read_attribute(:done_ratio)
2127 2158 end
2128 2159 end
2129 2160
2130 2161 test "#by_tracker" do
2131 2162 User.current = User.anonymous
2132 2163 groups = Issue.by_tracker(Project.find(1))
2133 2164 assert_equal 3, groups.count
2134 2165 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
2135 2166 end
2136 2167
2137 2168 test "#by_version" do
2138 2169 User.current = User.anonymous
2139 2170 groups = Issue.by_version(Project.find(1))
2140 2171 assert_equal 3, groups.count
2141 2172 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
2142 2173 end
2143 2174
2144 2175 test "#by_priority" do
2145 2176 User.current = User.anonymous
2146 2177 groups = Issue.by_priority(Project.find(1))
2147 2178 assert_equal 4, groups.count
2148 2179 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
2149 2180 end
2150 2181
2151 2182 test "#by_category" do
2152 2183 User.current = User.anonymous
2153 2184 groups = Issue.by_category(Project.find(1))
2154 2185 assert_equal 2, groups.count
2155 2186 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
2156 2187 end
2157 2188
2158 2189 test "#by_assigned_to" do
2159 2190 User.current = User.anonymous
2160 2191 groups = Issue.by_assigned_to(Project.find(1))
2161 2192 assert_equal 2, groups.count
2162 2193 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
2163 2194 end
2164 2195
2165 2196 test "#by_author" do
2166 2197 User.current = User.anonymous
2167 2198 groups = Issue.by_author(Project.find(1))
2168 2199 assert_equal 4, groups.count
2169 2200 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
2170 2201 end
2171 2202
2172 2203 test "#by_subproject" do
2173 2204 User.current = User.anonymous
2174 2205 groups = Issue.by_subproject(Project.find(1))
2175 2206 # Private descendant not visible
2176 2207 assert_equal 1, groups.count
2177 2208 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
2178 2209 end
2179 2210
2180 2211 def test_recently_updated_scope
2181 2212 #should return the last updated issue
2182 2213 assert_equal Issue.reorder("updated_on DESC").first, Issue.recently_updated.limit(1).first
2183 2214 end
2184 2215
2185 2216 def test_on_active_projects_scope
2186 2217 assert Project.find(2).archive
2187 2218
2188 2219 before = Issue.on_active_project.length
2189 2220 # test inclusion to results
2190 2221 issue = Issue.generate!(:tracker => Project.find(2).trackers.first)
2191 2222 assert_equal before + 1, Issue.on_active_project.length
2192 2223
2193 2224 # Move to an archived project
2194 2225 issue.project = Project.find(2)
2195 2226 assert issue.save
2196 2227 assert_equal before, Issue.on_active_project.length
2197 2228 end
2198 2229
2199 2230 test "Issue#recipients should include project recipients" do
2200 2231 issue = Issue.generate!
2201 2232 assert issue.project.recipients.present?
2202 2233 issue.project.recipients.each do |project_recipient|
2203 2234 assert issue.recipients.include?(project_recipient)
2204 2235 end
2205 2236 end
2206 2237
2207 2238 test "Issue#recipients should include the author if the author is active" do
2208 2239 issue = Issue.generate!(:author => User.generate!)
2209 2240 assert issue.author, "No author set for Issue"
2210 2241 assert issue.recipients.include?(issue.author.mail)
2211 2242 end
2212 2243
2213 2244 test "Issue#recipients should include the assigned to user if the assigned to user is active" do
2214 2245 issue = Issue.generate!(:assigned_to => User.generate!)
2215 2246 assert issue.assigned_to, "No assigned_to set for Issue"
2216 2247 assert issue.recipients.include?(issue.assigned_to.mail)
2217 2248 end
2218 2249
2219 2250 test "Issue#recipients should not include users who opt out of all email" do
2220 2251 issue = Issue.generate!(:author => User.generate!)
2221 2252 issue.author.update_attribute(:mail_notification, :none)
2222 2253 assert !issue.recipients.include?(issue.author.mail)
2223 2254 end
2224 2255
2225 2256 test "Issue#recipients should not include the issue author if they are only notified of assigned issues" do
2226 2257 issue = Issue.generate!(:author => User.generate!)
2227 2258 issue.author.update_attribute(:mail_notification, :only_assigned)
2228 2259 assert !issue.recipients.include?(issue.author.mail)
2229 2260 end
2230 2261
2231 2262 test "Issue#recipients should not include the assigned user if they are only notified of owned issues" do
2232 2263 issue = Issue.generate!(:assigned_to => User.generate!)
2233 2264 issue.assigned_to.update_attribute(:mail_notification, :only_owner)
2234 2265 assert !issue.recipients.include?(issue.assigned_to.mail)
2235 2266 end
2236 2267
2237 2268 def test_last_journal_id_with_journals_should_return_the_journal_id
2238 2269 assert_equal 2, Issue.find(1).last_journal_id
2239 2270 end
2240 2271
2241 2272 def test_last_journal_id_without_journals_should_return_nil
2242 2273 assert_nil Issue.find(3).last_journal_id
2243 2274 end
2244 2275
2245 2276 def test_journals_after_should_return_journals_with_greater_id
2246 2277 assert_equal [Journal.find(2)], Issue.find(1).journals_after('1')
2247 2278 assert_equal [], Issue.find(1).journals_after('2')
2248 2279 end
2249 2280
2250 2281 def test_journals_after_with_blank_arg_should_return_all_journals
2251 2282 assert_equal [Journal.find(1), Journal.find(2)], Issue.find(1).journals_after('')
2252 2283 end
2253 2284
2254 2285 def test_css_classes_should_include_tracker
2255 2286 issue = Issue.new(:tracker => Tracker.find(2))
2256 2287 classes = issue.css_classes.split(' ')
2257 2288 assert_include 'tracker-2', classes
2258 2289 end
2259 2290
2260 2291 def test_css_classes_should_include_priority
2261 2292 issue = Issue.new(:priority => IssuePriority.find(8))
2262 2293 classes = issue.css_classes.split(' ')
2263 2294 assert_include 'priority-8', classes
2264 2295 assert_include 'priority-highest', classes
2265 2296 end
2266 2297
2267 2298 def test_css_classes_should_include_user_and_group_assignment
2268 2299 project = Project.first
2269 2300 user = User.generate!
2270 2301 group = Group.generate!
2271 2302 Member.create!(:principal => group, :project => project, :role_ids => [1, 2])
2272 2303 group.users << user
2273 2304 assert user.member_of?(project)
2274 2305 issue1 = Issue.generate(:assigned_to_id => group.id)
2275 2306 assert_include 'assigned-to-my-group', issue1.css_classes(user)
2276 2307 assert_not_include 'assigned-to-me', issue1.css_classes(user)
2277 2308 issue2 = Issue.generate(:assigned_to_id => user.id)
2278 2309 assert_not_include 'assigned-to-my-group', issue2.css_classes(user)
2279 2310 assert_include 'assigned-to-me', issue2.css_classes(user)
2280 2311 end
2281 2312
2282 2313 def test_save_attachments_with_hash_should_save_attachments_in_keys_order
2283 2314 set_tmp_attachments_directory
2284 2315 issue = Issue.generate!
2285 2316 issue.save_attachments({
2286 2317 'p0' => {'file' => mock_file_with_options(:original_filename => 'upload')},
2287 2318 '3' => {'file' => mock_file_with_options(:original_filename => 'bar')},
2288 2319 '1' => {'file' => mock_file_with_options(:original_filename => 'foo')}
2289 2320 })
2290 2321 issue.attach_saved_attachments
2291 2322
2292 2323 assert_equal 3, issue.reload.attachments.count
2293 2324 assert_equal %w(upload foo bar), issue.attachments.map(&:filename)
2294 2325 end
2295 2326
2296 2327 def test_closed_on_should_be_nil_when_creating_an_open_issue
2297 2328 issue = Issue.generate!(:status_id => 1).reload
2298 2329 assert !issue.closed?
2299 2330 assert_nil issue.closed_on
2300 2331 end
2301 2332
2302 2333 def test_closed_on_should_be_set_when_creating_a_closed_issue
2303 2334 issue = Issue.generate!(:status_id => 5).reload
2304 2335 assert issue.closed?
2305 2336 assert_not_nil issue.closed_on
2306 2337 assert_equal issue.updated_on, issue.closed_on
2307 2338 assert_equal issue.created_on, issue.closed_on
2308 2339 end
2309 2340
2310 2341 def test_closed_on_should_be_nil_when_updating_an_open_issue
2311 2342 issue = Issue.find(1)
2312 2343 issue.subject = 'Not closed yet'
2313 2344 issue.save!
2314 2345 issue.reload
2315 2346 assert_nil issue.closed_on
2316 2347 end
2317 2348
2318 2349 def test_closed_on_should_be_set_when_closing_an_open_issue
2319 2350 issue = Issue.find(1)
2320 2351 issue.subject = 'Now closed'
2321 2352 issue.status_id = 5
2322 2353 issue.save!
2323 2354 issue.reload
2324 2355 assert_not_nil issue.closed_on
2325 2356 assert_equal issue.updated_on, issue.closed_on
2326 2357 end
2327 2358
2328 2359 def test_closed_on_should_not_be_updated_when_updating_a_closed_issue
2329 2360 issue = Issue.open(false).first
2330 2361 was_closed_on = issue.closed_on
2331 2362 assert_not_nil was_closed_on
2332 2363 issue.subject = 'Updating a closed issue'
2333 2364 issue.save!
2334 2365 issue.reload
2335 2366 assert_equal was_closed_on, issue.closed_on
2336 2367 end
2337 2368
2338 2369 def test_closed_on_should_be_preserved_when_reopening_a_closed_issue
2339 2370 issue = Issue.open(false).first
2340 2371 was_closed_on = issue.closed_on
2341 2372 assert_not_nil was_closed_on
2342 2373 issue.subject = 'Reopening a closed issue'
2343 2374 issue.status_id = 1
2344 2375 issue.save!
2345 2376 issue.reload
2346 2377 assert !issue.closed?
2347 2378 assert_equal was_closed_on, issue.closed_on
2348 2379 end
2349 2380
2350 2381 def test_status_was_should_return_nil_for_new_issue
2351 2382 issue = Issue.new
2352 2383 assert_nil issue.status_was
2353 2384 end
2354 2385
2355 2386 def test_status_was_should_return_status_before_change
2356 2387 issue = Issue.find(1)
2357 2388 issue.status = IssueStatus.find(2)
2358 2389 assert_equal IssueStatus.find(1), issue.status_was
2359 2390 end
2360 2391
2361 2392 def test_status_was_should_be_reset_on_save
2362 2393 issue = Issue.find(1)
2363 2394 issue.status = IssueStatus.find(2)
2364 2395 assert_equal IssueStatus.find(1), issue.status_was
2365 2396 assert issue.save!
2366 2397 assert_equal IssueStatus.find(2), issue.status_was
2367 2398 end
2368 2399 end
@@ -1,120 +1,114
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2014 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../test_helper', __FILE__)
21 21
22 22 class PrincipalTest < ActiveSupport::TestCase
23 23 fixtures :users, :projects, :members, :member_roles
24 24
25 25 def test_active_scope_should_return_groups_and_active_users
26 26 result = Principal.active.all
27 27 assert_include Group.first, result
28 28 assert_not_nil result.detect {|p| p.is_a?(User)}
29 29 assert_nil result.detect {|p| p.is_a?(User) && !p.active?}
30 30 assert_nil result.detect {|p| p.is_a?(AnonymousUser)}
31 31 end
32 32
33 33 def test_member_of_scope_should_return_the_union_of_all_members
34 34 projects = Project.find([1])
35 35 assert_equal [3, 2], Principal.member_of(projects).sort.map(&:id)
36 36 projects = Project.find([1, 2])
37 37 assert_equal [3, 2, 8, 11], Principal.member_of(projects).sort.map(&:id)
38 38 end
39 39
40 40 def test_member_of_scope_should_be_empty_for_no_projects
41 41 assert_equal [], Principal.member_of([]).sort
42 42 end
43 43
44 44 def test_not_member_of_scope_should_return_users_that_have_no_memberships
45 45 [[1], [1, 2]].each do |ids|
46 46 projects = Project.find(ids)
47 47 assert_equal ids.size, projects.count
48 48 expected = (Principal.all - projects.map(&:memberships).flatten.map(&:principal)).sort
49 49 assert_equal expected, Principal.not_member_of(projects).sort
50 50 end
51 51 end
52 52
53 53 def test_not_member_of_scope_should_be_empty_for_no_projects
54 54 assert_equal [], Principal.not_member_of([]).sort
55 55 end
56 56
57 57 def test_sorted_scope_should_sort_users_before_groups
58 scope = Principal.where("type <> ?", 'AnonymousUser')
59 expected_order = scope.all.sort do |a, b|
60 if a.is_a?(User) && b.is_a?(Group)
61 -1
62 elsif a.is_a?(Group) && b.is_a?(User)
63 1
64 else
65 a.name.downcase <=> b.name.downcase
66 end
67 end
68 assert_equal expected_order.map(&:name).map(&:downcase),
58 scope = Principal.where(:type => ['User', 'Group'])
59 users = scope.select {|p| p.is_a?(User)}.sort
60 groups = scope.select {|p| p.is_a?(Group)}.sort
61
62 assert_equal (users + groups).map(&:name).map(&:downcase),
69 63 scope.sorted.map(&:name).map(&:downcase)
70 64 end
71 65
72 66 test "like scope should search login" do
73 67 results = Principal.like('jsmi')
74 68
75 69 assert results.any?
76 70 assert results.all? {|u| u.login.match(/jsmi/i) }
77 71 end
78 72
79 73 test "like scope should search firstname" do
80 74 results = Principal.like('john')
81 75
82 76 assert results.any?
83 77 assert results.all? {|u| u.firstname.match(/john/i) }
84 78 end
85 79
86 80 test "like scope should search lastname" do
87 81 results = Principal.like('smi')
88 82
89 83 assert results.any?
90 84 assert results.all? {|u| u.lastname.match(/smi/i) }
91 85 end
92 86
93 87 test "like scope should search mail" do
94 88 results = Principal.like('somenet')
95 89
96 90 assert results.any?
97 91 assert results.all? {|u| u.mail.match(/somenet/i) }
98 92 end
99 93
100 94 test "like scope should search firstname and lastname" do
101 95 results = Principal.like('john smi')
102 96
103 97 assert_equal 1, results.count
104 98 assert_equal User.find(2), results.first
105 99 end
106 100
107 101 test "like scope should search lastname and firstname" do
108 102 results = Principal.like('smith joh')
109 103
110 104 assert_equal 1, results.count
111 105 assert_equal User.find(2), results.first
112 106 end
113 107
114 108 def test_like_scope_with_cyrillic_name
115 109 user = User.generate!(:firstname => 'Соболев', :lastname => 'Денис')
116 110 results = Principal.like('Собо')
117 111 assert_equal 1, results.count
118 112 assert_equal user, results.first
119 113 end
120 114 end
@@ -1,1419 +1,1419
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class QueryTest < ActiveSupport::TestCase
21 21 include Redmine::I18n
22 22
23 23 fixtures :projects, :enabled_modules, :users, :members,
24 24 :member_roles, :roles, :trackers, :issue_statuses,
25 25 :issue_categories, :enumerations, :issues,
26 26 :watchers, :custom_fields, :custom_values, :versions,
27 27 :queries,
28 28 :projects_trackers,
29 29 :custom_fields_trackers
30 30
31 31 def test_query_with_roles_visibility_should_validate_roles
32 32 set_language_if_valid 'en'
33 33 query = IssueQuery.new(:name => 'Query', :visibility => IssueQuery::VISIBILITY_ROLES)
34 34 assert !query.save
35 35 assert_include "Roles can't be blank", query.errors.full_messages
36 36 query.role_ids = [1, 2]
37 37 assert query.save
38 38 end
39 39
40 40 def test_changing_roles_visibility_should_clear_roles
41 41 query = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_ROLES, :role_ids => [1, 2])
42 42 assert_equal 2, query.roles.count
43 43
44 44 query.visibility = IssueQuery::VISIBILITY_PUBLIC
45 45 query.save!
46 46 assert_equal 0, query.roles.count
47 47 end
48 48
49 49 def test_available_filters_should_be_ordered
50 50 set_language_if_valid 'en'
51 51 query = IssueQuery.new
52 52 assert_equal 0, query.available_filters.keys.index('status_id')
53 53 expected_order = [
54 54 "Status",
55 55 "Project",
56 56 "Tracker",
57 57 "Priority"
58 58 ]
59 59 assert_equal expected_order,
60 60 (query.available_filters.values.map{|v| v[:name]} & expected_order)
61 61 end
62 62
63 63 def test_available_filters_with_custom_fields_should_be_ordered
64 64 set_language_if_valid 'en'
65 65 UserCustomField.create!(
66 66 :name => 'order test', :field_format => 'string',
67 67 :is_for_all => true, :is_filter => true
68 68 )
69 69 query = IssueQuery.new
70 70 expected_order = [
71 71 "Searchable field",
72 72 "Database",
73 73 "Project's Development status",
74 74 "Author's order test",
75 75 "Assignee's order test"
76 76 ]
77 77 assert_equal expected_order,
78 78 (query.available_filters.values.map{|v| v[:name]} & expected_order)
79 79 end
80 80
81 81 def test_custom_fields_for_all_projects_should_be_available_in_global_queries
82 82 query = IssueQuery.new(:project => nil, :name => '_')
83 83 assert query.available_filters.has_key?('cf_1')
84 84 assert !query.available_filters.has_key?('cf_3')
85 85 end
86 86
87 87 def test_system_shared_versions_should_be_available_in_global_queries
88 88 Version.find(2).update_attribute :sharing, 'system'
89 89 query = IssueQuery.new(:project => nil, :name => '_')
90 90 assert query.available_filters.has_key?('fixed_version_id')
91 91 assert query.available_filters['fixed_version_id'][:values].detect {|v| v.last == '2'}
92 92 end
93 93
94 94 def test_project_filter_in_global_queries
95 95 query = IssueQuery.new(:project => nil, :name => '_')
96 96 project_filter = query.available_filters["project_id"]
97 97 assert_not_nil project_filter
98 98 project_ids = project_filter[:values].map{|p| p[1]}
99 99 assert project_ids.include?("1") #public project
100 100 assert !project_ids.include?("2") #private project user cannot see
101 101 end
102 102
103 103 def find_issues_with_query(query)
104 104 Issue.includes([:assigned_to, :status, :tracker, :project, :priority]).where(
105 105 query.statement
106 106 ).all
107 107 end
108 108
109 109 def assert_find_issues_with_query_is_successful(query)
110 110 assert_nothing_raised do
111 111 find_issues_with_query(query)
112 112 end
113 113 end
114 114
115 115 def assert_query_statement_includes(query, condition)
116 116 assert_include condition, query.statement
117 117 end
118 118
119 119 def assert_query_result(expected, query)
120 120 assert_nothing_raised do
121 121 assert_equal expected.map(&:id).sort, query.issues.map(&:id).sort
122 122 assert_equal expected.size, query.issue_count
123 123 end
124 124 end
125 125
126 126 def test_query_should_allow_shared_versions_for_a_project_query
127 127 subproject_version = Version.find(4)
128 128 query = IssueQuery.new(:project => Project.find(1), :name => '_')
129 129 query.add_filter('fixed_version_id', '=', [subproject_version.id.to_s])
130 130
131 131 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IN ('4')")
132 132 end
133 133
134 134 def test_query_with_multiple_custom_fields
135 135 query = IssueQuery.find(1)
136 136 assert query.valid?
137 137 assert query.statement.include?("#{CustomValue.table_name}.value IN ('MySQL')")
138 138 issues = find_issues_with_query(query)
139 139 assert_equal 1, issues.length
140 140 assert_equal Issue.find(3), issues.first
141 141 end
142 142
143 143 def test_operator_none
144 144 query = IssueQuery.new(:project => Project.find(1), :name => '_')
145 145 query.add_filter('fixed_version_id', '!*', [''])
146 146 query.add_filter('cf_1', '!*', [''])
147 147 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NULL")
148 148 assert query.statement.include?("#{CustomValue.table_name}.value IS NULL OR #{CustomValue.table_name}.value = ''")
149 149 find_issues_with_query(query)
150 150 end
151 151
152 152 def test_operator_none_for_integer
153 153 query = IssueQuery.new(:project => Project.find(1), :name => '_')
154 154 query.add_filter('estimated_hours', '!*', [''])
155 155 issues = find_issues_with_query(query)
156 156 assert !issues.empty?
157 157 assert issues.all? {|i| !i.estimated_hours}
158 158 end
159 159
160 160 def test_operator_none_for_date
161 161 query = IssueQuery.new(:project => Project.find(1), :name => '_')
162 162 query.add_filter('start_date', '!*', [''])
163 163 issues = find_issues_with_query(query)
164 164 assert !issues.empty?
165 165 assert issues.all? {|i| i.start_date.nil?}
166 166 end
167 167
168 168 def test_operator_none_for_string_custom_field
169 169 query = IssueQuery.new(:project => Project.find(1), :name => '_')
170 170 query.add_filter('cf_2', '!*', [''])
171 171 assert query.has_filter?('cf_2')
172 172 issues = find_issues_with_query(query)
173 173 assert !issues.empty?
174 174 assert issues.all? {|i| i.custom_field_value(2).blank?}
175 175 end
176 176
177 177 def test_operator_all
178 178 query = IssueQuery.new(:project => Project.find(1), :name => '_')
179 179 query.add_filter('fixed_version_id', '*', [''])
180 180 query.add_filter('cf_1', '*', [''])
181 181 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NOT NULL")
182 182 assert query.statement.include?("#{CustomValue.table_name}.value IS NOT NULL AND #{CustomValue.table_name}.value <> ''")
183 183 find_issues_with_query(query)
184 184 end
185 185
186 186 def test_operator_all_for_date
187 187 query = IssueQuery.new(:project => Project.find(1), :name => '_')
188 188 query.add_filter('start_date', '*', [''])
189 189 issues = find_issues_with_query(query)
190 190 assert !issues.empty?
191 191 assert issues.all? {|i| i.start_date.present?}
192 192 end
193 193
194 194 def test_operator_all_for_string_custom_field
195 195 query = IssueQuery.new(:project => Project.find(1), :name => '_')
196 196 query.add_filter('cf_2', '*', [''])
197 197 assert query.has_filter?('cf_2')
198 198 issues = find_issues_with_query(query)
199 199 assert !issues.empty?
200 200 assert issues.all? {|i| i.custom_field_value(2).present?}
201 201 end
202 202
203 203 def test_numeric_filter_should_not_accept_non_numeric_values
204 204 query = IssueQuery.new(:name => '_')
205 205 query.add_filter('estimated_hours', '=', ['a'])
206 206
207 207 assert query.has_filter?('estimated_hours')
208 208 assert !query.valid?
209 209 end
210 210
211 211 def test_operator_is_on_float
212 212 Issue.where(:id => 2).update_all("estimated_hours = 171.2")
213 213 query = IssueQuery.new(:name => '_')
214 214 query.add_filter('estimated_hours', '=', ['171.20'])
215 215 issues = find_issues_with_query(query)
216 216 assert_equal 1, issues.size
217 217 assert_equal 2, issues.first.id
218 218 end
219 219
220 220 def test_operator_is_on_integer_custom_field
221 221 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_for_all => true, :is_filter => true, :trackers => Tracker.all)
222 222 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
223 223 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12')
224 224 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
225 225
226 226 query = IssueQuery.new(:name => '_')
227 227 query.add_filter("cf_#{f.id}", '=', ['12'])
228 228 issues = find_issues_with_query(query)
229 229 assert_equal 1, issues.size
230 230 assert_equal 2, issues.first.id
231 231 end
232 232
233 233 def test_operator_is_on_integer_custom_field_should_accept_negative_value
234 234 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_for_all => true, :is_filter => true, :trackers => Tracker.all)
235 235 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
236 236 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '-12')
237 237 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
238 238
239 239 query = IssueQuery.new(:name => '_')
240 240 query.add_filter("cf_#{f.id}", '=', ['-12'])
241 241 assert query.valid?
242 242 issues = find_issues_with_query(query)
243 243 assert_equal 1, issues.size
244 244 assert_equal 2, issues.first.id
245 245 end
246 246
247 247 def test_operator_is_on_float_custom_field
248 248 f = IssueCustomField.create!(:name => 'filter', :field_format => 'float', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
249 249 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7.3')
250 250 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12.7')
251 251 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
252 252
253 253 query = IssueQuery.new(:name => '_')
254 254 query.add_filter("cf_#{f.id}", '=', ['12.7'])
255 255 issues = find_issues_with_query(query)
256 256 assert_equal 1, issues.size
257 257 assert_equal 2, issues.first.id
258 258 end
259 259
260 260 def test_operator_is_on_float_custom_field_should_accept_negative_value
261 261 f = IssueCustomField.create!(:name => 'filter', :field_format => 'float', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
262 262 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7.3')
263 263 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '-12.7')
264 264 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
265 265
266 266 query = IssueQuery.new(:name => '_')
267 267 query.add_filter("cf_#{f.id}", '=', ['-12.7'])
268 268 assert query.valid?
269 269 issues = find_issues_with_query(query)
270 270 assert_equal 1, issues.size
271 271 assert_equal 2, issues.first.id
272 272 end
273 273
274 274 def test_operator_is_on_multi_list_custom_field
275 275 f = IssueCustomField.create!(:name => 'filter', :field_format => 'list', :is_filter => true, :is_for_all => true,
276 276 :possible_values => ['value1', 'value2', 'value3'], :multiple => true, :trackers => Tracker.all)
277 277 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value1')
278 278 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value2')
279 279 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => 'value1')
280 280
281 281 query = IssueQuery.new(:name => '_')
282 282 query.add_filter("cf_#{f.id}", '=', ['value1'])
283 283 issues = find_issues_with_query(query)
284 284 assert_equal [1, 3], issues.map(&:id).sort
285 285
286 286 query = IssueQuery.new(:name => '_')
287 287 query.add_filter("cf_#{f.id}", '=', ['value2'])
288 288 issues = find_issues_with_query(query)
289 289 assert_equal [1], issues.map(&:id).sort
290 290 end
291 291
292 292 def test_operator_is_not_on_multi_list_custom_field
293 293 f = IssueCustomField.create!(:name => 'filter', :field_format => 'list', :is_filter => true, :is_for_all => true,
294 294 :possible_values => ['value1', 'value2', 'value3'], :multiple => true, :trackers => Tracker.all)
295 295 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value1')
296 296 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value2')
297 297 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => 'value1')
298 298
299 299 query = IssueQuery.new(:name => '_')
300 300 query.add_filter("cf_#{f.id}", '!', ['value1'])
301 301 issues = find_issues_with_query(query)
302 302 assert !issues.map(&:id).include?(1)
303 303 assert !issues.map(&:id).include?(3)
304 304
305 305 query = IssueQuery.new(:name => '_')
306 306 query.add_filter("cf_#{f.id}", '!', ['value2'])
307 307 issues = find_issues_with_query(query)
308 308 assert !issues.map(&:id).include?(1)
309 309 assert issues.map(&:id).include?(3)
310 310 end
311 311
312 312 def test_operator_is_on_is_private_field
313 313 # is_private filter only available for those who can set issues private
314 314 User.current = User.find(2)
315 315
316 316 query = IssueQuery.new(:name => '_')
317 317 assert query.available_filters.key?('is_private')
318 318
319 319 query.add_filter("is_private", '=', ['1'])
320 320 issues = find_issues_with_query(query)
321 321 assert issues.any?
322 322 assert_nil issues.detect {|issue| !issue.is_private?}
323 323 ensure
324 324 User.current = nil
325 325 end
326 326
327 327 def test_operator_is_not_on_is_private_field
328 328 # is_private filter only available for those who can set issues private
329 329 User.current = User.find(2)
330 330
331 331 query = IssueQuery.new(:name => '_')
332 332 assert query.available_filters.key?('is_private')
333 333
334 334 query.add_filter("is_private", '!', ['1'])
335 335 issues = find_issues_with_query(query)
336 336 assert issues.any?
337 337 assert_nil issues.detect {|issue| issue.is_private?}
338 338 ensure
339 339 User.current = nil
340 340 end
341 341
342 342 def test_operator_greater_than
343 343 query = IssueQuery.new(:project => Project.find(1), :name => '_')
344 344 query.add_filter('done_ratio', '>=', ['40'])
345 345 assert query.statement.include?("#{Issue.table_name}.done_ratio >= 40.0")
346 346 find_issues_with_query(query)
347 347 end
348 348
349 349 def test_operator_greater_than_a_float
350 350 query = IssueQuery.new(:project => Project.find(1), :name => '_')
351 351 query.add_filter('estimated_hours', '>=', ['40.5'])
352 352 assert query.statement.include?("#{Issue.table_name}.estimated_hours >= 40.5")
353 353 find_issues_with_query(query)
354 354 end
355 355
356 356 def test_operator_greater_than_on_int_custom_field
357 357 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
358 358 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
359 359 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12')
360 360 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
361 361
362 362 query = IssueQuery.new(:project => Project.find(1), :name => '_')
363 363 query.add_filter("cf_#{f.id}", '>=', ['8'])
364 364 issues = find_issues_with_query(query)
365 365 assert_equal 1, issues.size
366 366 assert_equal 2, issues.first.id
367 367 end
368 368
369 369 def test_operator_lesser_than
370 370 query = IssueQuery.new(:project => Project.find(1), :name => '_')
371 371 query.add_filter('done_ratio', '<=', ['30'])
372 372 assert query.statement.include?("#{Issue.table_name}.done_ratio <= 30.0")
373 373 find_issues_with_query(query)
374 374 end
375 375
376 376 def test_operator_lesser_than_on_custom_field
377 377 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true)
378 378 query = IssueQuery.new(:project => Project.find(1), :name => '_')
379 379 query.add_filter("cf_#{f.id}", '<=', ['30'])
380 380 assert_match /CAST.+ <= 30\.0/, query.statement
381 381 find_issues_with_query(query)
382 382 end
383 383
384 384 def test_operator_lesser_than_on_date_custom_field
385 385 f = IssueCustomField.create!(:name => 'filter', :field_format => 'date', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
386 386 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '2013-04-11')
387 387 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '2013-05-14')
388 388 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
389 389
390 390 query = IssueQuery.new(:project => Project.find(1), :name => '_')
391 391 query.add_filter("cf_#{f.id}", '<=', ['2013-05-01'])
392 392 issue_ids = find_issues_with_query(query).map(&:id)
393 393 assert_include 1, issue_ids
394 394 assert_not_include 2, issue_ids
395 395 assert_not_include 3, issue_ids
396 396 end
397 397
398 398 def test_operator_between
399 399 query = IssueQuery.new(:project => Project.find(1), :name => '_')
400 400 query.add_filter('done_ratio', '><', ['30', '40'])
401 401 assert_include "#{Issue.table_name}.done_ratio BETWEEN 30.0 AND 40.0", query.statement
402 402 find_issues_with_query(query)
403 403 end
404 404
405 405 def test_operator_between_on_custom_field
406 406 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true)
407 407 query = IssueQuery.new(:project => Project.find(1), :name => '_')
408 408 query.add_filter("cf_#{f.id}", '><', ['30', '40'])
409 409 assert_match /CAST.+ BETWEEN 30.0 AND 40.0/, query.statement
410 410 find_issues_with_query(query)
411 411 end
412 412
413 413 def test_date_filter_should_not_accept_non_date_values
414 414 query = IssueQuery.new(:name => '_')
415 415 query.add_filter('created_on', '=', ['a'])
416 416
417 417 assert query.has_filter?('created_on')
418 418 assert !query.valid?
419 419 end
420 420
421 421 def test_date_filter_should_not_accept_invalid_date_values
422 422 query = IssueQuery.new(:name => '_')
423 423 query.add_filter('created_on', '=', ['2011-01-34'])
424 424
425 425 assert query.has_filter?('created_on')
426 426 assert !query.valid?
427 427 end
428 428
429 429 def test_relative_date_filter_should_not_accept_non_integer_values
430 430 query = IssueQuery.new(:name => '_')
431 431 query.add_filter('created_on', '>t-', ['a'])
432 432
433 433 assert query.has_filter?('created_on')
434 434 assert !query.valid?
435 435 end
436 436
437 437 def test_operator_date_equals
438 438 query = IssueQuery.new(:name => '_')
439 439 query.add_filter('due_date', '=', ['2011-07-10'])
440 440 assert_match /issues\.due_date > '2011-07-09 23:59:59(\.9+)?' AND issues\.due_date <= '2011-07-10 23:59:59(\.9+)?/, query.statement
441 441 find_issues_with_query(query)
442 442 end
443 443
444 444 def test_operator_date_lesser_than
445 445 query = IssueQuery.new(:name => '_')
446 446 query.add_filter('due_date', '<=', ['2011-07-10'])
447 447 assert_match /issues\.due_date <= '2011-07-10 23:59:59(\.9+)?/, query.statement
448 448 find_issues_with_query(query)
449 449 end
450 450
451 451 def test_operator_date_lesser_than_with_timestamp
452 452 query = IssueQuery.new(:name => '_')
453 453 query.add_filter('updated_on', '<=', ['2011-07-10T19:13:52'])
454 454 assert_match /issues\.updated_on <= '2011-07-10 19:13:52/, query.statement
455 455 find_issues_with_query(query)
456 456 end
457 457
458 458 def test_operator_date_greater_than
459 459 query = IssueQuery.new(:name => '_')
460 460 query.add_filter('due_date', '>=', ['2011-07-10'])
461 461 assert_match /issues\.due_date > '2011-07-09 23:59:59(\.9+)?'/, query.statement
462 462 find_issues_with_query(query)
463 463 end
464 464
465 465 def test_operator_date_greater_than_with_timestamp
466 466 query = IssueQuery.new(:name => '_')
467 467 query.add_filter('updated_on', '>=', ['2011-07-10T19:13:52'])
468 468 assert_match /issues\.updated_on > '2011-07-10 19:13:51(\.0+)?'/, query.statement
469 469 find_issues_with_query(query)
470 470 end
471 471
472 472 def test_operator_date_between
473 473 query = IssueQuery.new(:name => '_')
474 474 query.add_filter('due_date', '><', ['2011-06-23', '2011-07-10'])
475 475 assert_match /issues\.due_date > '2011-06-22 23:59:59(\.9+)?' AND issues\.due_date <= '2011-07-10 23:59:59(\.9+)?'/, query.statement
476 476 find_issues_with_query(query)
477 477 end
478 478
479 479 def test_operator_in_more_than
480 480 Issue.find(7).update_attribute(:due_date, (Date.today + 15))
481 481 query = IssueQuery.new(:project => Project.find(1), :name => '_')
482 482 query.add_filter('due_date', '>t+', ['15'])
483 483 issues = find_issues_with_query(query)
484 484 assert !issues.empty?
485 485 issues.each {|issue| assert(issue.due_date >= (Date.today + 15))}
486 486 end
487 487
488 488 def test_operator_in_less_than
489 489 query = IssueQuery.new(:project => Project.find(1), :name => '_')
490 490 query.add_filter('due_date', '<t+', ['15'])
491 491 issues = find_issues_with_query(query)
492 492 assert !issues.empty?
493 493 issues.each {|issue| assert(issue.due_date <= (Date.today + 15))}
494 494 end
495 495
496 496 def test_operator_in_the_next_days
497 497 query = IssueQuery.new(:project => Project.find(1), :name => '_')
498 498 query.add_filter('due_date', '><t+', ['15'])
499 499 issues = find_issues_with_query(query)
500 500 assert !issues.empty?
501 501 issues.each {|issue| assert(issue.due_date >= Date.today && issue.due_date <= (Date.today + 15))}
502 502 end
503 503
504 504 def test_operator_less_than_ago
505 505 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
506 506 query = IssueQuery.new(:project => Project.find(1), :name => '_')
507 507 query.add_filter('due_date', '>t-', ['3'])
508 508 issues = find_issues_with_query(query)
509 509 assert !issues.empty?
510 510 issues.each {|issue| assert(issue.due_date >= (Date.today - 3))}
511 511 end
512 512
513 513 def test_operator_in_the_past_days
514 514 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
515 515 query = IssueQuery.new(:project => Project.find(1), :name => '_')
516 516 query.add_filter('due_date', '><t-', ['3'])
517 517 issues = find_issues_with_query(query)
518 518 assert !issues.empty?
519 519 issues.each {|issue| assert(issue.due_date >= (Date.today - 3) && issue.due_date <= Date.today)}
520 520 end
521 521
522 522 def test_operator_more_than_ago
523 523 Issue.find(7).update_attribute(:due_date, (Date.today - 10))
524 524 query = IssueQuery.new(:project => Project.find(1), :name => '_')
525 525 query.add_filter('due_date', '<t-', ['10'])
526 526 assert query.statement.include?("#{Issue.table_name}.due_date <=")
527 527 issues = find_issues_with_query(query)
528 528 assert !issues.empty?
529 529 issues.each {|issue| assert(issue.due_date <= (Date.today - 10))}
530 530 end
531 531
532 532 def test_operator_in
533 533 Issue.find(7).update_attribute(:due_date, (Date.today + 2))
534 534 query = IssueQuery.new(:project => Project.find(1), :name => '_')
535 535 query.add_filter('due_date', 't+', ['2'])
536 536 issues = find_issues_with_query(query)
537 537 assert !issues.empty?
538 538 issues.each {|issue| assert_equal((Date.today + 2), issue.due_date)}
539 539 end
540 540
541 541 def test_operator_ago
542 542 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
543 543 query = IssueQuery.new(:project => Project.find(1), :name => '_')
544 544 query.add_filter('due_date', 't-', ['3'])
545 545 issues = find_issues_with_query(query)
546 546 assert !issues.empty?
547 547 issues.each {|issue| assert_equal((Date.today - 3), issue.due_date)}
548 548 end
549 549
550 550 def test_operator_today
551 551 query = IssueQuery.new(:project => Project.find(1), :name => '_')
552 552 query.add_filter('due_date', 't', [''])
553 553 issues = find_issues_with_query(query)
554 554 assert !issues.empty?
555 555 issues.each {|issue| assert_equal Date.today, issue.due_date}
556 556 end
557 557
558 558 def test_operator_this_week_on_date
559 559 query = IssueQuery.new(:project => Project.find(1), :name => '_')
560 560 query.add_filter('due_date', 'w', [''])
561 561 find_issues_with_query(query)
562 562 end
563 563
564 564 def test_operator_this_week_on_datetime
565 565 query = IssueQuery.new(:project => Project.find(1), :name => '_')
566 566 query.add_filter('created_on', 'w', [''])
567 567 find_issues_with_query(query)
568 568 end
569 569
570 570 def test_operator_contains
571 571 query = IssueQuery.new(:project => Project.find(1), :name => '_')
572 572 query.add_filter('subject', '~', ['uNable'])
573 573 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) LIKE '%unable%'")
574 574 result = find_issues_with_query(query)
575 575 assert result.empty?
576 576 result.each {|issue| assert issue.subject.downcase.include?('unable') }
577 577 end
578 578
579 579 def test_range_for_this_week_with_week_starting_on_monday
580 580 I18n.locale = :fr
581 581 assert_equal '1', I18n.t(:general_first_day_of_week)
582 582
583 583 Date.stubs(:today).returns(Date.parse('2011-04-29'))
584 584
585 585 query = IssueQuery.new(:project => Project.find(1), :name => '_')
586 586 query.add_filter('due_date', 'w', [''])
587 587 assert query.statement.match(/issues\.due_date > '2011-04-24 23:59:59(\.9+)?' AND issues\.due_date <= '2011-05-01 23:59:59(\.9+)?/), "range not found in #{query.statement}"
588 588 I18n.locale = :en
589 589 end
590 590
591 591 def test_range_for_this_week_with_week_starting_on_sunday
592 592 I18n.locale = :en
593 593 assert_equal '7', I18n.t(:general_first_day_of_week)
594 594
595 595 Date.stubs(:today).returns(Date.parse('2011-04-29'))
596 596
597 597 query = IssueQuery.new(:project => Project.find(1), :name => '_')
598 598 query.add_filter('due_date', 'w', [''])
599 599 assert query.statement.match(/issues\.due_date > '2011-04-23 23:59:59(\.9+)?' AND issues\.due_date <= '2011-04-30 23:59:59(\.9+)?/), "range not found in #{query.statement}"
600 600 end
601 601
602 602 def test_operator_does_not_contains
603 603 query = IssueQuery.new(:project => Project.find(1), :name => '_')
604 604 query.add_filter('subject', '!~', ['uNable'])
605 605 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) NOT LIKE '%unable%'")
606 606 find_issues_with_query(query)
607 607 end
608 608
609 609 def test_filter_assigned_to_me
610 610 user = User.find(2)
611 611 group = Group.find(10)
612 612 User.current = user
613 613 i1 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => user)
614 614 i2 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => group)
615 615 i3 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => Group.find(11))
616 616 group.users << user
617 617
618 618 query = IssueQuery.new(:name => '_', :filters => { 'assigned_to_id' => {:operator => '=', :values => ['me']}})
619 619 result = query.issues
620 620 assert_equal Issue.visible.where(:assigned_to_id => ([2] + user.reload.group_ids)).sort_by(&:id), result.sort_by(&:id)
621 621
622 622 assert result.include?(i1)
623 623 assert result.include?(i2)
624 624 assert !result.include?(i3)
625 625 end
626 626
627 627 def test_user_custom_field_filtered_on_me
628 628 User.current = User.find(2)
629 629 cf = IssueCustomField.create!(:field_format => 'user', :is_for_all => true, :is_filter => true, :name => 'User custom field', :tracker_ids => [1])
630 630 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :custom_field_values => {cf.id.to_s => '2'}, :subject => 'Test', :author_id => 1)
631 631 issue2 = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {cf.id.to_s => '3'})
632 632
633 633 query = IssueQuery.new(:name => '_', :project => Project.find(1))
634 634 filter = query.available_filters["cf_#{cf.id}"]
635 635 assert_not_nil filter
636 636 assert_include 'me', filter[:values].map{|v| v[1]}
637 637
638 638 query.filters = { "cf_#{cf.id}" => {:operator => '=', :values => ['me']}}
639 639 result = query.issues
640 640 assert_equal 1, result.size
641 641 assert_equal issue1, result.first
642 642 end
643 643
644 644 def test_filter_my_projects
645 645 User.current = User.find(2)
646 646 query = IssueQuery.new(:name => '_')
647 647 filter = query.available_filters['project_id']
648 648 assert_not_nil filter
649 649 assert_include 'mine', filter[:values].map{|v| v[1]}
650 650
651 651 query.filters = { 'project_id' => {:operator => '=', :values => ['mine']}}
652 652 result = query.issues
653 653 assert_nil result.detect {|issue| !User.current.member_of?(issue.project)}
654 654 end
655 655
656 656 def test_filter_watched_issues
657 657 User.current = User.find(1)
658 658 query = IssueQuery.new(:name => '_', :filters => { 'watcher_id' => {:operator => '=', :values => ['me']}})
659 659 result = find_issues_with_query(query)
660 660 assert_not_nil result
661 661 assert !result.empty?
662 662 assert_equal Issue.visible.watched_by(User.current).sort_by(&:id), result.sort_by(&:id)
663 663 User.current = nil
664 664 end
665 665
666 666 def test_filter_unwatched_issues
667 667 User.current = User.find(1)
668 668 query = IssueQuery.new(:name => '_', :filters => { 'watcher_id' => {:operator => '!', :values => ['me']}})
669 669 result = find_issues_with_query(query)
670 670 assert_not_nil result
671 671 assert !result.empty?
672 672 assert_equal((Issue.visible - Issue.watched_by(User.current)).sort_by(&:id).size, result.sort_by(&:id).size)
673 673 User.current = nil
674 674 end
675 675
676 676 def test_filter_on_custom_field_should_ignore_projects_with_field_disabled
677 677 field = IssueCustomField.generate!(:trackers => Tracker.all, :project_ids => [1, 3, 4], :is_filter => true)
678 678 Issue.generate!(:project_id => 3, :tracker_id => 2, :custom_field_values => {field.id.to_s => 'Foo'})
679 679 Issue.generate!(:project_id => 4, :tracker_id => 2, :custom_field_values => {field.id.to_s => 'Foo'})
680 680
681 681 query = IssueQuery.new(:name => '_', :project => Project.find(1))
682 682 query.filters = {"cf_#{field.id}" => {:operator => '=', :values => ['Foo']}}
683 683 assert_equal 2, find_issues_with_query(query).size
684 684
685 685 field.project_ids = [1, 3] # Disable the field for project 4
686 686 field.save!
687 687 assert_equal 1, find_issues_with_query(query).size
688 688 end
689 689
690 690 def test_filter_on_custom_field_should_ignore_trackers_with_field_disabled
691 691 field = IssueCustomField.generate!(:tracker_ids => [1, 2], :is_for_all => true, :is_filter => true)
692 692 Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {field.id.to_s => 'Foo'})
693 693 Issue.generate!(:project_id => 1, :tracker_id => 2, :custom_field_values => {field.id.to_s => 'Foo'})
694 694
695 695 query = IssueQuery.new(:name => '_', :project => Project.find(1))
696 696 query.filters = {"cf_#{field.id}" => {:operator => '=', :values => ['Foo']}}
697 697 assert_equal 2, find_issues_with_query(query).size
698 698
699 699 field.tracker_ids = [1] # Disable the field for tracker 2
700 700 field.save!
701 701 assert_equal 1, find_issues_with_query(query).size
702 702 end
703 703
704 704 def test_filter_on_project_custom_field
705 705 field = ProjectCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
706 706 CustomValue.create!(:custom_field => field, :customized => Project.find(3), :value => 'Foo')
707 707 CustomValue.create!(:custom_field => field, :customized => Project.find(5), :value => 'Foo')
708 708
709 709 query = IssueQuery.new(:name => '_')
710 710 filter_name = "project.cf_#{field.id}"
711 711 assert_include filter_name, query.available_filters.keys
712 712 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
713 713 assert_equal [3, 5], find_issues_with_query(query).map(&:project_id).uniq.sort
714 714 end
715 715
716 716 def test_filter_on_author_custom_field
717 717 field = UserCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
718 718 CustomValue.create!(:custom_field => field, :customized => User.find(3), :value => 'Foo')
719 719
720 720 query = IssueQuery.new(:name => '_')
721 721 filter_name = "author.cf_#{field.id}"
722 722 assert_include filter_name, query.available_filters.keys
723 723 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
724 724 assert_equal [3], find_issues_with_query(query).map(&:author_id).uniq.sort
725 725 end
726 726
727 727 def test_filter_on_assigned_to_custom_field
728 728 field = UserCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
729 729 CustomValue.create!(:custom_field => field, :customized => User.find(3), :value => 'Foo')
730 730
731 731 query = IssueQuery.new(:name => '_')
732 732 filter_name = "assigned_to.cf_#{field.id}"
733 733 assert_include filter_name, query.available_filters.keys
734 734 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
735 735 assert_equal [3], find_issues_with_query(query).map(&:assigned_to_id).uniq.sort
736 736 end
737 737
738 738 def test_filter_on_fixed_version_custom_field
739 739 field = VersionCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
740 740 CustomValue.create!(:custom_field => field, :customized => Version.find(2), :value => 'Foo')
741 741
742 742 query = IssueQuery.new(:name => '_')
743 743 filter_name = "fixed_version.cf_#{field.id}"
744 744 assert_include filter_name, query.available_filters.keys
745 745 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
746 746 assert_equal [2], find_issues_with_query(query).map(&:fixed_version_id).uniq.sort
747 747 end
748 748
749 749 def test_filter_on_relations_with_a_specific_issue
750 750 IssueRelation.delete_all
751 751 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
752 752 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
753 753
754 754 query = IssueQuery.new(:name => '_')
755 755 query.filters = {"relates" => {:operator => '=', :values => ['1']}}
756 756 assert_equal [2, 3], find_issues_with_query(query).map(&:id).sort
757 757
758 758 query = IssueQuery.new(:name => '_')
759 759 query.filters = {"relates" => {:operator => '=', :values => ['2']}}
760 760 assert_equal [1], find_issues_with_query(query).map(&:id).sort
761 761 end
762 762
763 763 def test_filter_on_relations_with_any_issues_in_a_project
764 764 IssueRelation.delete_all
765 765 with_settings :cross_project_issue_relations => '1' do
766 766 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
767 767 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(2).issues.first)
768 768 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first)
769 769 end
770 770
771 771 query = IssueQuery.new(:name => '_')
772 772 query.filters = {"relates" => {:operator => '=p', :values => ['2']}}
773 773 assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort
774 774
775 775 query = IssueQuery.new(:name => '_')
776 776 query.filters = {"relates" => {:operator => '=p', :values => ['3']}}
777 777 assert_equal [1], find_issues_with_query(query).map(&:id).sort
778 778
779 779 query = IssueQuery.new(:name => '_')
780 780 query.filters = {"relates" => {:operator => '=p', :values => ['4']}}
781 781 assert_equal [], find_issues_with_query(query).map(&:id).sort
782 782 end
783 783
784 784 def test_filter_on_relations_with_any_issues_not_in_a_project
785 785 IssueRelation.delete_all
786 786 with_settings :cross_project_issue_relations => '1' do
787 787 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
788 788 #IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(1).issues.first)
789 789 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first)
790 790 end
791 791
792 792 query = IssueQuery.new(:name => '_')
793 793 query.filters = {"relates" => {:operator => '=!p', :values => ['1']}}
794 794 assert_equal [1], find_issues_with_query(query).map(&:id).sort
795 795 end
796 796
797 797 def test_filter_on_relations_with_no_issues_in_a_project
798 798 IssueRelation.delete_all
799 799 with_settings :cross_project_issue_relations => '1' do
800 800 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
801 801 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(3).issues.first)
802 802 IssueRelation.create!(:relation_type => "relates", :issue_to => Project.find(2).issues.first, :issue_from => Issue.find(3))
803 803 end
804 804
805 805 query = IssueQuery.new(:name => '_')
806 806 query.filters = {"relates" => {:operator => '!p', :values => ['2']}}
807 807 ids = find_issues_with_query(query).map(&:id).sort
808 808 assert_include 2, ids
809 809 assert_not_include 1, ids
810 810 assert_not_include 3, ids
811 811 end
812 812
813 813 def test_filter_on_relations_with_no_issues
814 814 IssueRelation.delete_all
815 815 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
816 816 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
817 817
818 818 query = IssueQuery.new(:name => '_')
819 819 query.filters = {"relates" => {:operator => '!*', :values => ['']}}
820 820 ids = find_issues_with_query(query).map(&:id)
821 821 assert_equal [], ids & [1, 2, 3]
822 822 assert_include 4, ids
823 823 end
824 824
825 825 def test_filter_on_relations_with_any_issues
826 826 IssueRelation.delete_all
827 827 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
828 828 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
829 829
830 830 query = IssueQuery.new(:name => '_')
831 831 query.filters = {"relates" => {:operator => '*', :values => ['']}}
832 832 assert_equal [1, 2, 3], find_issues_with_query(query).map(&:id).sort
833 833 end
834 834
835 835 def test_filter_on_relations_should_not_ignore_other_filter
836 836 issue = Issue.generate!
837 837 issue1 = Issue.generate!(:status_id => 1)
838 838 issue2 = Issue.generate!(:status_id => 2)
839 839 IssueRelation.create!(:relation_type => "relates", :issue_from => issue, :issue_to => issue1)
840 840 IssueRelation.create!(:relation_type => "relates", :issue_from => issue, :issue_to => issue2)
841 841
842 842 query = IssueQuery.new(:name => '_')
843 843 query.filters = {
844 844 "status_id" => {:operator => '=', :values => ['1']},
845 845 "relates" => {:operator => '=', :values => [issue.id.to_s]}
846 846 }
847 847 assert_equal [issue1], find_issues_with_query(query)
848 848 end
849 849
850 850 def test_statement_should_be_nil_with_no_filters
851 851 q = IssueQuery.new(:name => '_')
852 852 q.filters = {}
853 853
854 854 assert q.valid?
855 855 assert_nil q.statement
856 856 end
857 857
858 858 def test_default_columns
859 859 q = IssueQuery.new
860 860 assert q.columns.any?
861 861 assert q.inline_columns.any?
862 862 assert q.block_columns.empty?
863 863 end
864 864
865 865 def test_set_column_names
866 866 q = IssueQuery.new
867 867 q.column_names = ['tracker', :subject, '', 'unknonw_column']
868 868 assert_equal [:id, :tracker, :subject], q.columns.collect {|c| c.name}
869 869 end
870 870
871 871 def test_has_column_should_accept_a_column_name
872 872 q = IssueQuery.new
873 873 q.column_names = ['tracker', :subject]
874 874 assert q.has_column?(:tracker)
875 875 assert !q.has_column?(:category)
876 876 end
877 877
878 878 def test_has_column_should_accept_a_column
879 879 q = IssueQuery.new
880 880 q.column_names = ['tracker', :subject]
881 881
882 882 tracker_column = q.available_columns.detect {|c| c.name==:tracker}
883 883 assert_kind_of QueryColumn, tracker_column
884 884 category_column = q.available_columns.detect {|c| c.name==:category}
885 885 assert_kind_of QueryColumn, category_column
886 886
887 887 assert q.has_column?(tracker_column)
888 888 assert !q.has_column?(category_column)
889 889 end
890 890
891 891 def test_inline_and_block_columns
892 892 q = IssueQuery.new
893 893 q.column_names = ['subject', 'description', 'tracker']
894 894
895 895 assert_equal [:id, :subject, :tracker], q.inline_columns.map(&:name)
896 896 assert_equal [:description], q.block_columns.map(&:name)
897 897 end
898 898
899 899 def test_custom_field_columns_should_be_inline
900 900 q = IssueQuery.new
901 901 columns = q.available_columns.select {|column| column.is_a? QueryCustomFieldColumn}
902 902 assert columns.any?
903 903 assert_nil columns.detect {|column| !column.inline?}
904 904 end
905 905
906 906 def test_query_should_preload_spent_hours
907 907 q = IssueQuery.new(:name => '_', :column_names => [:subject, :spent_hours])
908 908 assert q.has_column?(:spent_hours)
909 909 issues = q.issues
910 910 assert_not_nil issues.first.instance_variable_get("@spent_hours")
911 911 end
912 912
913 913 def test_groupable_columns_should_include_custom_fields
914 914 q = IssueQuery.new
915 915 column = q.groupable_columns.detect {|c| c.name == :cf_1}
916 916 assert_not_nil column
917 917 assert_kind_of QueryCustomFieldColumn, column
918 918 end
919 919
920 920 def test_groupable_columns_should_not_include_multi_custom_fields
921 921 field = CustomField.find(1)
922 922 field.update_attribute :multiple, true
923 923
924 924 q = IssueQuery.new
925 925 column = q.groupable_columns.detect {|c| c.name == :cf_1}
926 926 assert_nil column
927 927 end
928 928
929 929 def test_groupable_columns_should_include_user_custom_fields
930 930 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1], :field_format => 'user')
931 931
932 932 q = IssueQuery.new
933 933 assert q.groupable_columns.detect {|c| c.name == "cf_#{cf.id}".to_sym}
934 934 end
935 935
936 936 def test_groupable_columns_should_include_version_custom_fields
937 937 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1], :field_format => 'version')
938 938
939 939 q = IssueQuery.new
940 940 assert q.groupable_columns.detect {|c| c.name == "cf_#{cf.id}".to_sym}
941 941 end
942 942
943 943 def test_grouped_with_valid_column
944 944 q = IssueQuery.new(:group_by => 'status')
945 945 assert q.grouped?
946 946 assert_not_nil q.group_by_column
947 947 assert_equal :status, q.group_by_column.name
948 948 assert_not_nil q.group_by_statement
949 949 assert_equal 'status', q.group_by_statement
950 950 end
951 951
952 952 def test_grouped_with_invalid_column
953 953 q = IssueQuery.new(:group_by => 'foo')
954 954 assert !q.grouped?
955 955 assert_nil q.group_by_column
956 956 assert_nil q.group_by_statement
957 957 end
958 958
959 959 def test_sortable_columns_should_sort_assignees_according_to_user_format_setting
960 960 with_settings :user_format => 'lastname_coma_firstname' do
961 961 q = IssueQuery.new
962 962 assert q.sortable_columns.has_key?('assigned_to')
963 963 assert_equal %w(users.lastname users.firstname users.id), q.sortable_columns['assigned_to']
964 964 end
965 965 end
966 966
967 967 def test_sortable_columns_should_sort_authors_according_to_user_format_setting
968 968 with_settings :user_format => 'lastname_coma_firstname' do
969 969 q = IssueQuery.new
970 970 assert q.sortable_columns.has_key?('author')
971 971 assert_equal %w(authors.lastname authors.firstname authors.id), q.sortable_columns['author']
972 972 end
973 973 end
974 974
975 975 def test_sortable_columns_should_include_custom_field
976 976 q = IssueQuery.new
977 977 assert q.sortable_columns['cf_1']
978 978 end
979 979
980 980 def test_sortable_columns_should_not_include_multi_custom_field
981 981 field = CustomField.find(1)
982 982 field.update_attribute :multiple, true
983 983
984 984 q = IssueQuery.new
985 985 assert !q.sortable_columns['cf_1']
986 986 end
987 987
988 988 def test_default_sort
989 989 q = IssueQuery.new
990 990 assert_equal [], q.sort_criteria
991 991 end
992 992
993 993 def test_set_sort_criteria_with_hash
994 994 q = IssueQuery.new
995 995 q.sort_criteria = {'0' => ['priority', 'desc'], '2' => ['tracker']}
996 996 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
997 997 end
998 998
999 999 def test_set_sort_criteria_with_array
1000 1000 q = IssueQuery.new
1001 1001 q.sort_criteria = [['priority', 'desc'], 'tracker']
1002 1002 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
1003 1003 end
1004 1004
1005 1005 def test_create_query_with_sort
1006 1006 q = IssueQuery.new(:name => 'Sorted')
1007 1007 q.sort_criteria = [['priority', 'desc'], 'tracker']
1008 1008 assert q.save
1009 1009 q.reload
1010 1010 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
1011 1011 end
1012 1012
1013 1013 def test_sort_by_string_custom_field_asc
1014 1014 q = IssueQuery.new
1015 1015 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
1016 1016 assert c
1017 1017 assert c.sortable
1018 1018 issues = q.issues(:order => "#{c.sortable} ASC")
1019 1019 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
1020 1020 assert !values.empty?
1021 1021 assert_equal values.sort, values
1022 1022 end
1023 1023
1024 1024 def test_sort_by_string_custom_field_desc
1025 1025 q = IssueQuery.new
1026 1026 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
1027 1027 assert c
1028 1028 assert c.sortable
1029 1029 issues = q.issues(:order => "#{c.sortable} DESC")
1030 1030 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
1031 1031 assert !values.empty?
1032 1032 assert_equal values.sort.reverse, values
1033 1033 end
1034 1034
1035 1035 def test_sort_by_float_custom_field_asc
1036 1036 q = IssueQuery.new
1037 1037 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'float' }
1038 1038 assert c
1039 1039 assert c.sortable
1040 1040 issues = q.issues(:order => "#{c.sortable} ASC")
1041 1041 values = issues.collect {|i| begin; Kernel.Float(i.custom_value_for(c.custom_field).to_s); rescue; nil; end}.compact
1042 1042 assert !values.empty?
1043 1043 assert_equal values.sort, values
1044 1044 end
1045 1045
1046 1046 def test_invalid_query_should_raise_query_statement_invalid_error
1047 1047 q = IssueQuery.new
1048 1048 assert_raise Query::StatementInvalid do
1049 1049 q.issues(:conditions => "foo = 1")
1050 1050 end
1051 1051 end
1052 1052
1053 1053 def test_issue_count
1054 1054 q = IssueQuery.new(:name => '_')
1055 1055 issue_count = q.issue_count
1056 1056 assert_equal q.issues.size, issue_count
1057 1057 end
1058 1058
1059 1059 def test_issue_count_with_archived_issues
1060 1060 p = Project.generate! do |project|
1061 1061 project.status = Project::STATUS_ARCHIVED
1062 1062 end
1063 1063 i = Issue.generate!( :project => p, :tracker => p.trackers.first )
1064 1064 assert !i.visible?
1065 1065
1066 1066 test_issue_count
1067 1067 end
1068 1068
1069 1069 def test_issue_count_by_association_group
1070 1070 q = IssueQuery.new(:name => '_', :group_by => 'assigned_to')
1071 1071 count_by_group = q.issue_count_by_group
1072 1072 assert_kind_of Hash, count_by_group
1073 1073 assert_equal %w(NilClass User), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
1074 1074 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
1075 1075 assert count_by_group.has_key?(User.find(3))
1076 1076 end
1077 1077
1078 1078 def test_issue_count_by_list_custom_field_group
1079 1079 q = IssueQuery.new(:name => '_', :group_by => 'cf_1')
1080 1080 count_by_group = q.issue_count_by_group
1081 1081 assert_kind_of Hash, count_by_group
1082 1082 assert_equal %w(NilClass String), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
1083 1083 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
1084 1084 assert count_by_group.has_key?('MySQL')
1085 1085 end
1086 1086
1087 1087 def test_issue_count_by_date_custom_field_group
1088 1088 q = IssueQuery.new(:name => '_', :group_by => 'cf_8')
1089 1089 count_by_group = q.issue_count_by_group
1090 1090 assert_kind_of Hash, count_by_group
1091 1091 assert_equal %w(Date NilClass), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
1092 1092 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
1093 1093 end
1094 1094
1095 1095 def test_issue_count_with_nil_group_only
1096 1096 Issue.update_all("assigned_to_id = NULL")
1097 1097
1098 1098 q = IssueQuery.new(:name => '_', :group_by => 'assigned_to')
1099 1099 count_by_group = q.issue_count_by_group
1100 1100 assert_kind_of Hash, count_by_group
1101 1101 assert_equal 1, count_by_group.keys.size
1102 1102 assert_nil count_by_group.keys.first
1103 1103 end
1104 1104
1105 1105 def test_issue_ids
1106 1106 q = IssueQuery.new(:name => '_')
1107 1107 order = "issues.subject, issues.id"
1108 1108 issues = q.issues(:order => order)
1109 1109 assert_equal issues.map(&:id), q.issue_ids(:order => order)
1110 1110 end
1111 1111
1112 1112 def test_label_for
1113 1113 set_language_if_valid 'en'
1114 1114 q = IssueQuery.new
1115 1115 assert_equal 'Assignee', q.label_for('assigned_to_id')
1116 1116 end
1117 1117
1118 1118 def test_label_for_fr
1119 1119 set_language_if_valid 'fr'
1120 1120 q = IssueQuery.new
1121 1121 s = "Assign\xc3\xa9 \xc3\xa0"
1122 1122 s.force_encoding('UTF-8') if s.respond_to?(:force_encoding)
1123 1123 assert_equal s, q.label_for('assigned_to_id')
1124 1124 end
1125 1125
1126 1126 def test_editable_by
1127 1127 admin = User.find(1)
1128 1128 manager = User.find(2)
1129 1129 developer = User.find(3)
1130 1130
1131 1131 # Public query on project 1
1132 1132 q = IssueQuery.find(1)
1133 1133 assert q.editable_by?(admin)
1134 1134 assert q.editable_by?(manager)
1135 1135 assert !q.editable_by?(developer)
1136 1136
1137 1137 # Private query on project 1
1138 1138 q = IssueQuery.find(2)
1139 1139 assert q.editable_by?(admin)
1140 1140 assert !q.editable_by?(manager)
1141 1141 assert q.editable_by?(developer)
1142 1142
1143 1143 # Private query for all projects
1144 1144 q = IssueQuery.find(3)
1145 1145 assert q.editable_by?(admin)
1146 1146 assert !q.editable_by?(manager)
1147 1147 assert q.editable_by?(developer)
1148 1148
1149 1149 # Public query for all projects
1150 1150 q = IssueQuery.find(4)
1151 1151 assert q.editable_by?(admin)
1152 1152 assert !q.editable_by?(manager)
1153 1153 assert !q.editable_by?(developer)
1154 1154 end
1155 1155
1156 1156 def test_visible_scope
1157 1157 query_ids = IssueQuery.visible(User.anonymous).map(&:id)
1158 1158
1159 1159 assert query_ids.include?(1), 'public query on public project was not visible'
1160 1160 assert query_ids.include?(4), 'public query for all projects was not visible'
1161 1161 assert !query_ids.include?(2), 'private query on public project was visible'
1162 1162 assert !query_ids.include?(3), 'private query for all projects was visible'
1163 1163 assert !query_ids.include?(7), 'public query on private project was visible'
1164 1164 end
1165 1165
1166 1166 def test_query_with_public_visibility_should_be_visible_to_anyone
1167 1167 q = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_PUBLIC)
1168 1168
1169 1169 assert q.visible?(User.anonymous)
1170 1170 assert IssueQuery.visible(User.anonymous).find_by_id(q.id)
1171 1171
1172 1172 assert q.visible?(User.find(7))
1173 1173 assert IssueQuery.visible(User.find(7)).find_by_id(q.id)
1174 1174
1175 1175 assert q.visible?(User.find(2))
1176 1176 assert IssueQuery.visible(User.find(2)).find_by_id(q.id)
1177 1177
1178 1178 assert q.visible?(User.find(1))
1179 1179 assert IssueQuery.visible(User.find(1)).find_by_id(q.id)
1180 1180 end
1181 1181
1182 1182 def test_query_with_roles_visibility_should_be_visible_to_user_with_role
1183 1183 q = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_ROLES, :role_ids => [1,2])
1184 1184
1185 1185 assert !q.visible?(User.anonymous)
1186 1186 assert_nil IssueQuery.visible(User.anonymous).find_by_id(q.id)
1187 1187
1188 1188 assert !q.visible?(User.find(7))
1189 1189 assert_nil IssueQuery.visible(User.find(7)).find_by_id(q.id)
1190 1190
1191 1191 assert q.visible?(User.find(2))
1192 1192 assert IssueQuery.visible(User.find(2)).find_by_id(q.id)
1193 1193
1194 1194 assert q.visible?(User.find(1))
1195 1195 assert IssueQuery.visible(User.find(1)).find_by_id(q.id)
1196 1196 end
1197 1197
1198 1198 def test_query_with_private_visibility_should_be_visible_to_owner
1199 1199 q = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_PRIVATE, :user => User.find(7))
1200 1200
1201 1201 assert !q.visible?(User.anonymous)
1202 1202 assert_nil IssueQuery.visible(User.anonymous).find_by_id(q.id)
1203 1203
1204 1204 assert q.visible?(User.find(7))
1205 1205 assert IssueQuery.visible(User.find(7)).find_by_id(q.id)
1206 1206
1207 1207 assert !q.visible?(User.find(2))
1208 1208 assert_nil IssueQuery.visible(User.find(2)).find_by_id(q.id)
1209 1209
1210 1210 assert q.visible?(User.find(1))
1211 1211 assert_nil IssueQuery.visible(User.find(1)).find_by_id(q.id)
1212 1212 end
1213 1213
1214 1214 test "#available_filters should include users of visible projects in cross-project view" do
1215 1215 users = IssueQuery.new.available_filters["assigned_to_id"]
1216 1216 assert_not_nil users
1217 1217 assert users[:values].map{|u|u[1]}.include?("3")
1218 1218 end
1219 1219
1220 1220 test "#available_filters should include users of subprojects" do
1221 1221 user1 = User.generate!
1222 1222 user2 = User.generate!
1223 1223 project = Project.find(1)
1224 1224 Member.create!(:principal => user1, :project => project.children.visible.first, :role_ids => [1])
1225 1225
1226 1226 users = IssueQuery.new(:project => project).available_filters["assigned_to_id"]
1227 1227 assert_not_nil users
1228 1228 assert users[:values].map{|u|u[1]}.include?(user1.id.to_s)
1229 1229 assert !users[:values].map{|u|u[1]}.include?(user2.id.to_s)
1230 1230 end
1231 1231
1232 1232 test "#available_filters should include visible projects in cross-project view" do
1233 1233 projects = IssueQuery.new.available_filters["project_id"]
1234 1234 assert_not_nil projects
1235 1235 assert projects[:values].map{|u|u[1]}.include?("1")
1236 1236 end
1237 1237
1238 1238 test "#available_filters should include 'member_of_group' filter" do
1239 1239 query = IssueQuery.new
1240 1240 assert query.available_filters.keys.include?("member_of_group")
1241 1241 assert_equal :list_optional, query.available_filters["member_of_group"][:type]
1242 1242 assert query.available_filters["member_of_group"][:values].present?
1243 assert_equal Group.all.sort.map {|g| [g.name, g.id.to_s]},
1243 assert_equal Group.givable.sort.map {|g| [g.name, g.id.to_s]},
1244 1244 query.available_filters["member_of_group"][:values].sort
1245 1245 end
1246 1246
1247 1247 test "#available_filters should include 'assigned_to_role' filter" do
1248 1248 query = IssueQuery.new
1249 1249 assert query.available_filters.keys.include?("assigned_to_role")
1250 1250 assert_equal :list_optional, query.available_filters["assigned_to_role"][:type]
1251 1251
1252 1252 assert query.available_filters["assigned_to_role"][:values].include?(['Manager','1'])
1253 1253 assert query.available_filters["assigned_to_role"][:values].include?(['Developer','2'])
1254 1254 assert query.available_filters["assigned_to_role"][:values].include?(['Reporter','3'])
1255 1255
1256 1256 assert ! query.available_filters["assigned_to_role"][:values].include?(['Non member','4'])
1257 1257 assert ! query.available_filters["assigned_to_role"][:values].include?(['Anonymous','5'])
1258 1258 end
1259 1259
1260 1260 def test_available_filters_should_include_custom_field_according_to_user_visibility
1261 1261 visible_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => true)
1262 1262 hidden_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => false, :role_ids => [1])
1263 1263
1264 1264 with_current_user User.find(3) do
1265 1265 query = IssueQuery.new
1266 1266 assert_include "cf_#{visible_field.id}", query.available_filters.keys
1267 1267 assert_not_include "cf_#{hidden_field.id}", query.available_filters.keys
1268 1268 end
1269 1269 end
1270 1270
1271 1271 def test_available_columns_should_include_custom_field_according_to_user_visibility
1272 1272 visible_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => true)
1273 1273 hidden_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => false, :role_ids => [1])
1274 1274
1275 1275 with_current_user User.find(3) do
1276 1276 query = IssueQuery.new
1277 1277 assert_include :"cf_#{visible_field.id}", query.available_columns.map(&:name)
1278 1278 assert_not_include :"cf_#{hidden_field.id}", query.available_columns.map(&:name)
1279 1279 end
1280 1280 end
1281 1281
1282 1282 context "#statement" do
1283 1283 context "with 'member_of_group' filter" do
1284 1284 setup do
1285 1285 Group.destroy_all # No fixtures
1286 1286 @user_in_group = User.generate!
1287 1287 @second_user_in_group = User.generate!
1288 1288 @user_in_group2 = User.generate!
1289 1289 @user_not_in_group = User.generate!
1290 1290
1291 1291 @group = Group.generate!.reload
1292 1292 @group.users << @user_in_group
1293 1293 @group.users << @second_user_in_group
1294 1294
1295 1295 @group2 = Group.generate!.reload
1296 1296 @group2.users << @user_in_group2
1297 1297
1298 1298 end
1299 1299
1300 1300 should "search assigned to for users in the group" do
1301 1301 @query = IssueQuery.new(:name => '_')
1302 1302 @query.add_filter('member_of_group', '=', [@group.id.to_s])
1303 1303
1304 1304 assert_query_statement_includes @query, "#{Issue.table_name}.assigned_to_id IN ('#{@user_in_group.id}','#{@second_user_in_group.id}','#{@group.id}')"
1305 1305 assert_find_issues_with_query_is_successful @query
1306 1306 end
1307 1307
1308 1308 should "search not assigned to any group member (none)" do
1309 1309 @query = IssueQuery.new(:name => '_')
1310 1310 @query.add_filter('member_of_group', '!*', [''])
1311 1311
1312 1312 # Users not in a group
1313 1313 assert_query_statement_includes @query, "#{Issue.table_name}.assigned_to_id IS NULL OR #{Issue.table_name}.assigned_to_id NOT IN ('#{@user_in_group.id}','#{@second_user_in_group.id}','#{@user_in_group2.id}','#{@group.id}','#{@group2.id}')"
1314 1314 assert_find_issues_with_query_is_successful @query
1315 1315 end
1316 1316
1317 1317 should "search assigned to any group member (all)" do
1318 1318 @query = IssueQuery.new(:name => '_')
1319 1319 @query.add_filter('member_of_group', '*', [''])
1320 1320
1321 1321 # Only users in a group
1322 1322 assert_query_statement_includes @query, "#{Issue.table_name}.assigned_to_id IN ('#{@user_in_group.id}','#{@second_user_in_group.id}','#{@user_in_group2.id}','#{@group.id}','#{@group2.id}')"
1323 1323 assert_find_issues_with_query_is_successful @query
1324 1324 end
1325 1325
1326 1326 should "return an empty set with = empty group" do
1327 1327 @empty_group = Group.generate!
1328 1328 @query = IssueQuery.new(:name => '_')
1329 1329 @query.add_filter('member_of_group', '=', [@empty_group.id.to_s])
1330 1330
1331 1331 assert_equal [], find_issues_with_query(@query)
1332 1332 end
1333 1333
1334 1334 should "return issues with ! empty group" do
1335 1335 @empty_group = Group.generate!
1336 1336 @query = IssueQuery.new(:name => '_')
1337 1337 @query.add_filter('member_of_group', '!', [@empty_group.id.to_s])
1338 1338
1339 1339 assert_find_issues_with_query_is_successful @query
1340 1340 end
1341 1341 end
1342 1342
1343 1343 context "with 'assigned_to_role' filter" do
1344 1344 setup do
1345 1345 @manager_role = Role.find_by_name('Manager')
1346 1346 @developer_role = Role.find_by_name('Developer')
1347 1347
1348 1348 @project = Project.generate!
1349 1349 @manager = User.generate!
1350 1350 @developer = User.generate!
1351 1351 @boss = User.generate!
1352 1352 @guest = User.generate!
1353 1353 User.add_to_project(@manager, @project, @manager_role)
1354 1354 User.add_to_project(@developer, @project, @developer_role)
1355 1355 User.add_to_project(@boss, @project, [@manager_role, @developer_role])
1356 1356
1357 1357 @issue1 = Issue.generate!(:project => @project, :assigned_to_id => @manager.id)
1358 1358 @issue2 = Issue.generate!(:project => @project, :assigned_to_id => @developer.id)
1359 1359 @issue3 = Issue.generate!(:project => @project, :assigned_to_id => @boss.id)
1360 1360 @issue4 = Issue.generate!(:project => @project, :assigned_to_id => @guest.id)
1361 1361 @issue5 = Issue.generate!(:project => @project)
1362 1362 end
1363 1363
1364 1364 should "search assigned to for users with the Role" do
1365 1365 @query = IssueQuery.new(:name => '_', :project => @project)
1366 1366 @query.add_filter('assigned_to_role', '=', [@manager_role.id.to_s])
1367 1367
1368 1368 assert_query_result [@issue1, @issue3], @query
1369 1369 end
1370 1370
1371 1371 should "search assigned to for users with the Role on the issue project" do
1372 1372 other_project = Project.generate!
1373 1373 User.add_to_project(@developer, other_project, @manager_role)
1374 1374
1375 1375 @query = IssueQuery.new(:name => '_', :project => @project)
1376 1376 @query.add_filter('assigned_to_role', '=', [@manager_role.id.to_s])
1377 1377
1378 1378 assert_query_result [@issue1, @issue3], @query
1379 1379 end
1380 1380
1381 1381 should "return an empty set with empty role" do
1382 1382 @empty_role = Role.generate!
1383 1383 @query = IssueQuery.new(:name => '_', :project => @project)
1384 1384 @query.add_filter('assigned_to_role', '=', [@empty_role.id.to_s])
1385 1385
1386 1386 assert_query_result [], @query
1387 1387 end
1388 1388
1389 1389 should "search assigned to for users without the Role" do
1390 1390 @query = IssueQuery.new(:name => '_', :project => @project)
1391 1391 @query.add_filter('assigned_to_role', '!', [@manager_role.id.to_s])
1392 1392
1393 1393 assert_query_result [@issue2, @issue4, @issue5], @query
1394 1394 end
1395 1395
1396 1396 should "search assigned to for users not assigned to any Role (none)" do
1397 1397 @query = IssueQuery.new(:name => '_', :project => @project)
1398 1398 @query.add_filter('assigned_to_role', '!*', [''])
1399 1399
1400 1400 assert_query_result [@issue4, @issue5], @query
1401 1401 end
1402 1402
1403 1403 should "search assigned to for users assigned to any Role (all)" do
1404 1404 @query = IssueQuery.new(:name => '_', :project => @project)
1405 1405 @query.add_filter('assigned_to_role', '*', [''])
1406 1406
1407 1407 assert_query_result [@issue1, @issue2, @issue3], @query
1408 1408 end
1409 1409
1410 1410 should "return issues with ! empty role" do
1411 1411 @empty_role = Role.generate!
1412 1412 @query = IssueQuery.new(:name => '_', :project => @project)
1413 1413 @query.add_filter('assigned_to_role', '!', [@empty_role.id.to_s])
1414 1414
1415 1415 assert_query_result [@issue1, @issue2, @issue3, @issue4, @issue5], @query
1416 1416 end
1417 1417 end
1418 1418 end
1419 1419 end
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now