##// END OF EJS Templates
Ability to search all projects or the projects the user belongs to (#791)....
Jean-Philippe Lang -
r1420:073818f8bc46
parent child
Show More
@@ -1,111 +1,111
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 SearchController < ApplicationController
19 19 layout 'base'
20 20
21 21 before_filter :find_optional_project
22 22
23 23 helper :messages
24 24 include MessagesHelper
25 25
26 26 def index
27 27 @question = params[:q] || ""
28 28 @question.strip!
29 29 @all_words = params[:all_words] || (params[:submit] ? false : true)
30 30 @titles_only = !params[:titles_only].nil?
31 31
32 projects_to_search =
33 case params[:projects]
34 when 'all'
35 nil
36 when 'my_projects'
37 User.current.memberships.collect(&:project)
38 else
39 @project
40 end
41
32 42 offset = nil
33 43 begin; offset = params[:offset].to_time if params[:offset]; rescue; end
34 44
35 45 # quick jump to an issue
36 46 if @question.match(/^#?(\d+)$/) && Issue.find_by_id($1, :include => :project, :conditions => Project.visible_by(User.current))
37 47 redirect_to :controller => "issues", :action => "show", :id => $1
38 48 return
39 49 end
40 50
41 if @project
51 @object_types = %w(issues news documents changesets wiki_pages messages projects)
52 if projects_to_search.is_a? Project
53 # don't search projects
54 @object_types.delete('projects')
42 55 # only show what the user is allowed to view
43 @object_types = %w(issues news documents changesets wiki_pages messages)
44 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, @project)}
56 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
57 end
45 58
46 59 @scope = @object_types.select {|t| params[t]}
47 60 @scope = @object_types if @scope.empty?
48 else
49 @object_types = @scope = %w(projects)
50 end
51 61
52 62 # extract tokens from the question
53 63 # eg. hello "bye bye" => ["hello", "bye bye"]
54 64 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
55 65 # tokens must be at least 3 character long
56 66 @tokens = @tokens.uniq.select {|w| w.length > 2 }
57 67
58 68 if !@tokens.empty?
59 69 # no more than 5 tokens to search for
60 70 @tokens.slice! 5..-1 if @tokens.size > 5
61 71 # strings used in sql like statement
62 72 like_tokens = @tokens.collect {|w| "%#{w.downcase}%"}
63 73 @results = []
64 74 limit = 10
65 if @project
66 75 @scope.each do |s|
67 @results += s.singularize.camelcase.constantize.search(like_tokens, @project,
76 @results += s.singularize.camelcase.constantize.search(like_tokens, projects_to_search,
68 77 :all_words => @all_words,
69 78 :titles_only => @titles_only,
70 79 :limit => (limit+1),
71 80 :offset => offset,
72 81 :before => params[:previous].nil?)
73 82 end
74 83 @results = @results.sort {|a,b| b.event_datetime <=> a.event_datetime}
75 84 if params[:previous].nil?
76 85 @pagination_previous_date = @results[0].event_datetime if offset && @results[0]
77 86 if @results.size > limit
78 87 @pagination_next_date = @results[limit-1].event_datetime
79 88 @results = @results[0, limit]
80 89 end
81 90 else
82 91 @pagination_next_date = @results[-1].event_datetime if offset && @results[-1]
83 92 if @results.size > limit
84 93 @pagination_previous_date = @results[-(limit)].event_datetime
85 94 @results = @results[-(limit), limit]
86 95 end
87 96 end
88 97 else
89 operator = @all_words ? ' AND ' : ' OR '
90 @results += Project.find(:all,
91 :limit => limit,
92 :conditions => [ (["(#{Project.visible_by(User.current)}) AND (LOWER(name) like ? OR LOWER(description) like ?)"] * like_tokens.size).join(operator), * (like_tokens * 2).sort]
93 ) if @scope.include? 'projects'
94 # if only one project is found, user is redirected to its overview
95 redirect_to :controller => 'projects', :action => 'show', :id => @results.first and return if @results.size == 1
96 end
97 else
98 98 @question = ""
99 99 end
100 100 render :layout => false if request.xhr?
101 101 end
102 102
103 103 private
104 104 def find_optional_project
105 105 return true unless params[:id]
106 106 @project = Project.find(params[:id])
107 107 check_project_privacy
108 108 rescue ActiveRecord::RecordNotFound
109 109 render_404
110 110 end
111 111 end
@@ -1,38 +1,45
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 module SearchHelper
19 19 def highlight_tokens(text, tokens)
20 20 return text unless text && tokens && !tokens.empty?
21 21 regexp = Regexp.new "(#{tokens.join('|')})", Regexp::IGNORECASE
22 22 result = ''
23 23 text.split(regexp).each_with_index do |words, i|
24 24 if result.length > 1200
25 25 # maximum length of the preview reached
26 26 result << '...'
27 27 break
28 28 end
29 29 if i.even?
30 30 result << h(words.length > 100 ? "#{words[0..44]} ... #{words[-45..-1]}" : words)
31 31 else
32 32 t = (tokens.index(words.downcase) || 0) % 4
33 33 result << content_tag('span', h(words), :class => "highlight token-#{t}")
34 34 end
35 35 end
36 36 result
37 37 end
38
39 def project_select_tag
40 options = [[l(:label_project_all), 'all']]
41 options << [l(:label_my_projects), 'my_projects'] unless User.current.memberships.empty?
42 options << [@project.name, ''] unless @project.nil?
43 select_tag('projects', options_for_select(options, params[:projects].to_s)) if options.size > 1
44 end
38 45 end
@@ -1,131 +1,131
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 Changeset < ActiveRecord::Base
19 19 belongs_to :repository
20 20 has_many :changes, :dependent => :delete_all
21 21 has_and_belongs_to_many :issues
22 22
23 23 acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.revision}" + (o.comments.blank? ? '' : (': ' + o.comments))},
24 24 :description => :comments,
25 25 :datetime => :committed_on,
26 26 :author => :committer,
27 27 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project_id, :rev => o.revision}}
28 28
29 29 acts_as_searchable :columns => 'comments',
30 :include => :repository,
30 :include => {:repository => :project},
31 31 :project_key => "#{Repository.table_name}.project_id",
32 32 :date_column => 'committed_on'
33 33
34 34 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
35 35 validates_uniqueness_of :revision, :scope => :repository_id
36 36 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
37 37
38 38 def revision=(r)
39 39 write_attribute :revision, (r.nil? ? nil : r.to_s)
40 40 end
41 41
42 42 def comments=(comment)
43 43 write_attribute(:comments, comment.strip)
44 44 end
45 45
46 46 def committed_on=(date)
47 47 self.commit_date = date
48 48 super
49 49 end
50 50
51 51 def project
52 52 repository.project
53 53 end
54 54
55 55 def after_create
56 56 scan_comment_for_issue_ids
57 57 end
58 58 require 'pp'
59 59
60 60 def scan_comment_for_issue_ids
61 61 return if comments.blank?
62 62 # keywords used to reference issues
63 63 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
64 64 # keywords used to fix issues
65 65 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
66 66 # status and optional done ratio applied
67 67 fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
68 68 done_ratio = Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
69 69
70 70 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
71 71 return if kw_regexp.blank?
72 72
73 73 referenced_issues = []
74 74
75 75 if ref_keywords.delete('*')
76 76 # find any issue ID in the comments
77 77 target_issue_ids = []
78 78 comments.scan(%r{([\s\(,-^])#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] }
79 79 referenced_issues += repository.project.issues.find_all_by_id(target_issue_ids)
80 80 end
81 81
82 82 comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match|
83 83 action = match[0]
84 84 target_issue_ids = match[1].scan(/\d+/)
85 85 target_issues = repository.project.issues.find_all_by_id(target_issue_ids)
86 86 if fix_status && fix_keywords.include?(action.downcase)
87 87 # update status of issues
88 88 logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
89 89 target_issues.each do |issue|
90 90 # the issue may have been updated by the closure of another one (eg. duplicate)
91 91 issue.reload
92 92 # don't change the status is the issue is closed
93 93 next if issue.status.is_closed?
94 94 user = committer_user || User.anonymous
95 95 csettext = "r#{self.revision}"
96 96 if self.scmid && (! (csettext =~ /^r[0-9]+$/))
97 97 csettext = "commit:\"#{self.scmid}\""
98 98 end
99 99 journal = issue.init_journal(user, l(:text_status_changed_by_changeset, csettext))
100 100 issue.status = fix_status
101 101 issue.done_ratio = done_ratio if done_ratio
102 102 issue.save
103 103 Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
104 104 end
105 105 end
106 106 referenced_issues += target_issues
107 107 end
108 108
109 109 self.issues = referenced_issues.uniq
110 110 end
111 111
112 112 # Returns the Redmine User corresponding to the committer
113 113 def committer_user
114 114 if committer && committer.strip =~ /^([^<]+)(<(.*)>)?$/
115 115 username, email = $1.strip, $3
116 116 u = User.find_by_login(username)
117 117 u ||= User.find_by_mail(email) unless email.blank?
118 118 u
119 119 end
120 120 end
121 121
122 122 # Returns the previous changeset
123 123 def previous
124 124 @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
125 125 end
126 126
127 127 # Returns the next changeset
128 128 def next
129 129 @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
130 130 end
131 131 end
@@ -1,30 +1,30
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Document < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :category, :class_name => "Enumeration", :foreign_key => "category_id"
21 21 has_many :attachments, :as => :container, :dependent => :destroy
22 22
23 acts_as_searchable :columns => ['title', 'description']
23 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
24 24 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
25 25 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
26 26 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
27 27
28 28 validates_presence_of :project, :title, :category
29 29 validates_length_of :title, :maximum => 60
30 30 end
@@ -1,250 +1,250
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 Issue < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :tracker
21 21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 25 belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id'
26 26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27 27
28 28 has_many :journals, :as => :journalized, :dependent => :destroy
29 29 has_many :attachments, :as => :container, :dependent => :destroy
30 30 has_many :time_entries, :dependent => :delete_all
31 31 has_many :custom_values, :dependent => :delete_all, :as => :customized
32 32 has_many :custom_fields, :through => :custom_values
33 33 has_and_belongs_to_many :changesets, :order => "revision ASC"
34 34
35 35 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
36 36 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
37 37
38 38 acts_as_watchable
39 acts_as_searchable :columns => ['subject', 'description'], :with => {:journal => :issue}
39 acts_as_searchable :columns => ['subject', "#{table_name}.description"], :include => :project, :with => {:journal => :issue}
40 40 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id}: #{o.subject}"},
41 41 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}}
42 42
43 43 validates_presence_of :subject, :description, :priority, :project, :tracker, :author, :status
44 44 validates_length_of :subject, :maximum => 255
45 45 validates_inclusion_of :done_ratio, :in => 0..100
46 46 validates_numericality_of :estimated_hours, :allow_nil => true
47 47 validates_associated :custom_values, :on => :update
48 48
49 49 def after_initialize
50 50 if new_record?
51 51 # set default values for new records only
52 52 self.status ||= IssueStatus.default
53 53 self.priority ||= Enumeration.default('IPRI')
54 54 end
55 55 end
56 56
57 57 def copy_from(arg)
58 58 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
59 59 self.attributes = issue.attributes.dup
60 60 self.custom_values = issue.custom_values.collect {|v| v.clone}
61 61 self
62 62 end
63 63
64 64 # Move an issue to a new project and tracker
65 65 def move_to(new_project, new_tracker = nil)
66 66 transaction do
67 67 if new_project && project_id != new_project.id
68 68 # delete issue relations
69 69 unless Setting.cross_project_issue_relations?
70 70 self.relations_from.clear
71 71 self.relations_to.clear
72 72 end
73 73 # issue is moved to another project
74 74 self.category = nil
75 75 self.fixed_version = nil
76 76 self.project = new_project
77 77 end
78 78 if new_tracker
79 79 self.tracker = new_tracker
80 80 end
81 81 if save
82 82 # Manually update project_id on related time entries
83 83 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
84 84 else
85 85 rollback_db_transaction
86 86 return false
87 87 end
88 88 end
89 89 return true
90 90 end
91 91
92 92 def priority_id=(pid)
93 93 self.priority = nil
94 94 write_attribute(:priority_id, pid)
95 95 end
96 96
97 97 def estimated_hours=(h)
98 98 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
99 99 end
100 100
101 101 def validate
102 102 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
103 103 errors.add :due_date, :activerecord_error_not_a_date
104 104 end
105 105
106 106 if self.due_date and self.start_date and self.due_date < self.start_date
107 107 errors.add :due_date, :activerecord_error_greater_than_start_date
108 108 end
109 109
110 110 if start_date && soonest_start && start_date < soonest_start
111 111 errors.add :start_date, :activerecord_error_invalid
112 112 end
113 113 end
114 114
115 115 def validate_on_create
116 116 errors.add :tracker_id, :activerecord_error_invalid unless project.trackers.include?(tracker)
117 117 end
118 118
119 119 def before_create
120 120 # default assignment based on category
121 121 if assigned_to.nil? && category && category.assigned_to
122 122 self.assigned_to = category.assigned_to
123 123 end
124 124 end
125 125
126 126 def before_save
127 127 if @current_journal
128 128 # attributes changes
129 129 (Issue.column_names - %w(id description)).each {|c|
130 130 @current_journal.details << JournalDetail.new(:property => 'attr',
131 131 :prop_key => c,
132 132 :old_value => @issue_before_change.send(c),
133 133 :value => send(c)) unless send(c)==@issue_before_change.send(c)
134 134 }
135 135 # custom fields changes
136 136 custom_values.each {|c|
137 137 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
138 138 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
139 139 @current_journal.details << JournalDetail.new(:property => 'cf',
140 140 :prop_key => c.custom_field_id,
141 141 :old_value => @custom_values_before_change[c.custom_field_id],
142 142 :value => c.value)
143 143 }
144 144 @current_journal.save
145 145 end
146 146 # Save the issue even if the journal is not saved (because empty)
147 147 true
148 148 end
149 149
150 150 def after_save
151 151 # Reload is needed in order to get the right status
152 152 reload
153 153
154 154 # Update start/due dates of following issues
155 155 relations_from.each(&:set_issue_to_dates)
156 156
157 157 # Close duplicates if the issue was closed
158 158 if @issue_before_change && !@issue_before_change.closed? && self.closed?
159 159 duplicates.each do |duplicate|
160 160 # Reload is need in case the duplicate was updated by a previous duplicate
161 161 duplicate.reload
162 162 # Don't re-close it if it's already closed
163 163 next if duplicate.closed?
164 164 # Same user and notes
165 165 duplicate.init_journal(@current_journal.user, @current_journal.notes)
166 166 duplicate.update_attribute :status, self.status
167 167 end
168 168 end
169 169 end
170 170
171 171 def custom_value_for(custom_field)
172 172 self.custom_values.each {|v| return v if v.custom_field_id == custom_field.id }
173 173 return nil
174 174 end
175 175
176 176 def init_journal(user, notes = "")
177 177 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
178 178 @issue_before_change = self.clone
179 179 @issue_before_change.status = self.status
180 180 @custom_values_before_change = {}
181 181 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
182 182 @current_journal
183 183 end
184 184
185 185 # Return true if the issue is closed, otherwise false
186 186 def closed?
187 187 self.status.is_closed?
188 188 end
189 189
190 190 # Users the issue can be assigned to
191 191 def assignable_users
192 192 project.assignable_users
193 193 end
194 194
195 195 # Returns an array of status that user is able to apply
196 196 def new_statuses_allowed_to(user)
197 197 statuses = status.find_new_statuses_allowed_to(user.role_for_project(project), tracker)
198 198 statuses << status unless statuses.empty?
199 199 statuses.uniq.sort
200 200 end
201 201
202 202 # Returns the mail adresses of users that should be notified for the issue
203 203 def recipients
204 204 recipients = project.recipients
205 205 # Author and assignee are always notified unless they have been locked
206 206 recipients << author.mail if author && author.active?
207 207 recipients << assigned_to.mail if assigned_to && assigned_to.active?
208 208 recipients.compact.uniq
209 209 end
210 210
211 211 def spent_hours
212 212 @spent_hours ||= time_entries.sum(:hours) || 0
213 213 end
214 214
215 215 def relations
216 216 (relations_from + relations_to).sort
217 217 end
218 218
219 219 def all_dependent_issues
220 220 dependencies = []
221 221 relations_from.each do |relation|
222 222 dependencies << relation.issue_to
223 223 dependencies += relation.issue_to.all_dependent_issues
224 224 end
225 225 dependencies
226 226 end
227 227
228 228 # Returns an array of the duplicate issues
229 229 def duplicates
230 230 relations.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.other_issue(self)}
231 231 end
232 232
233 233 def duration
234 234 (start_date && due_date) ? due_date - start_date : 0
235 235 end
236 236
237 237 def soonest_start
238 238 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
239 239 end
240 240
241 241 def self.visible_by(usr)
242 242 with_scope(:find => { :conditions => Project.visible_by(usr) }) do
243 243 yield
244 244 end
245 245 end
246 246
247 247 def to_s
248 248 "#{tracker} ##{id}: #{subject}"
249 249 end
250 250 end
@@ -1,66 +1,66
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Journal < ActiveRecord::Base
19 19 belongs_to :journalized, :polymorphic => true
20 20 # added as a quick fix to allow eager loading of the polymorphic association
21 21 # since always associated to an issue, for now
22 22 belongs_to :issue, :foreign_key => :journalized_id
23 23
24 24 belongs_to :user
25 25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
26 26 attr_accessor :indice
27 27
28 28 acts_as_searchable :columns => 'notes',
29 :include => :issue,
29 :include => {:issue => :project},
30 30 :project_key => "#{Issue.table_name}.project_id",
31 31 :date_column => "#{Issue.table_name}.created_on"
32 32
33 33 acts_as_event :title => Proc.new {|o| "#{o.issue.tracker.name} ##{o.issue.id}: #{o.issue.subject}" + ((s = o.new_status) ? " (#{s})" : '') },
34 34 :description => :notes,
35 35 :author => :user,
36 36 :type => Proc.new {|o| (s = o.new_status) && s.is_closed? ? 'issue-closed' : 'issue-edit' },
37 37 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
38 38
39 39 def save
40 40 # Do not save an empty journal
41 41 (details.empty? && notes.blank?) ? false : super
42 42 end
43 43
44 44 # Returns the new status if the journal contains a status change, otherwise nil
45 45 def new_status
46 46 c = details.detect {|detail| detail.prop_key == 'status_id'}
47 47 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
48 48 end
49 49
50 50 def new_value_for(prop)
51 51 c = details.detect {|detail| detail.prop_key == prop}
52 52 c ? c.value : nil
53 53 end
54 54
55 55 def editable_by?(usr)
56 56 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
57 57 end
58 58
59 59 def project
60 60 journalized.respond_to?(:project) ? journalized.project : nil
61 61 end
62 62
63 63 def attachments
64 64 journalized.respond_to?(:attachments) ? journalized.attachments : nil
65 65 end
66 66 end
@@ -1,68 +1,68
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 Message < ActiveRecord::Base
19 19 belongs_to :board
20 20 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
21 21 acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
22 22 has_many :attachments, :as => :container, :dependent => :destroy
23 23 belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
24 24
25 25 acts_as_searchable :columns => ['subject', 'content'],
26 :include => :board,
26 :include => {:board, :project},
27 27 :project_key => 'project_id',
28 :date_column => 'created_on'
28 :date_column => "#{table_name}.created_on"
29 29 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
30 30 :description => :content,
31 31 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
32 32 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id, :id => o.id}}
33 33
34 34 attr_protected :locked, :sticky
35 35 validates_presence_of :subject, :content
36 36 validates_length_of :subject, :maximum => 255
37 37
38 38 def validate_on_create
39 39 # Can not reply to a locked topic
40 40 errors.add_to_base 'Topic is locked' if root.locked? && self != root
41 41 end
42 42
43 43 def after_create
44 44 board.update_attribute(:last_message_id, self.id)
45 45 board.increment! :messages_count
46 46 if parent
47 47 parent.reload.update_attribute(:last_reply_id, self.id)
48 48 else
49 49 board.increment! :topics_count
50 50 end
51 51 end
52 52
53 53 def after_destroy
54 54 # The following line is required so that the previous counter
55 55 # updates (due to children removal) are not overwritten
56 56 board.reload
57 57 board.decrement! :messages_count
58 58 board.decrement! :topics_count unless parent
59 59 end
60 60
61 61 def sticky?
62 62 sticky == 1
63 63 end
64 64
65 65 def project
66 66 board.project
67 67 end
68 68 end
@@ -1,34 +1,34
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 News < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
21 21 has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on"
22 22
23 23 validates_presence_of :title, :description
24 24 validates_length_of :title, :maximum => 60
25 25 validates_length_of :summary, :maximum => 255
26 26
27 acts_as_searchable :columns => ['title', 'description']
27 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
28 28 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
29 29
30 30 # returns latest news for projects visible by user
31 31 def self.latest(user=nil, count=5)
32 32 find(:all, :limit => count, :conditions => Project.visible_by(user), :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
33 33 end
34 34 end
@@ -1,257 +1,261
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 # Project statuses
20 20 STATUS_ACTIVE = 1
21 21 STATUS_ARCHIVED = 9
22 22
23 23 has_many :members, :include => :user, :conditions => "#{User.table_name}.status=#{User::STATUS_ACTIVE}"
24 24 has_many :users, :through => :members
25 25 has_many :custom_values, :dependent => :delete_all, :as => :customized
26 26 has_many :enabled_modules, :dependent => :delete_all
27 27 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
28 28 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
29 29 has_many :issue_changes, :through => :issues, :source => :journals
30 30 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
31 31 has_many :time_entries, :dependent => :delete_all
32 32 has_many :queries, :dependent => :delete_all
33 33 has_many :documents, :dependent => :destroy
34 34 has_many :news, :dependent => :delete_all, :include => :author
35 35 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
36 36 has_many :boards, :dependent => :destroy, :order => "position ASC"
37 37 has_one :repository, :dependent => :destroy
38 38 has_many :changesets, :through => :repository
39 39 has_one :wiki, :dependent => :destroy
40 40 # Custom field for the project issues
41 41 has_and_belongs_to_many :custom_fields,
42 42 :class_name => 'IssueCustomField',
43 43 :order => "#{CustomField.table_name}.position",
44 44 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
45 45 :association_foreign_key => 'custom_field_id'
46 46
47 47 acts_as_tree :order => "name", :counter_cache => true
48 48
49 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id'
49 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
50 50 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
51 51 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}}
52 52
53 53 attr_protected :status, :enabled_module_names
54 54
55 55 validates_presence_of :name, :identifier
56 56 validates_uniqueness_of :name, :identifier
57 57 validates_associated :custom_values, :on => :update
58 58 validates_associated :repository, :wiki
59 59 validates_length_of :name, :maximum => 30
60 60 validates_length_of :homepage, :maximum => 60
61 61 validates_length_of :identifier, :in => 3..20
62 62 validates_format_of :identifier, :with => /^[a-z0-9\-]*$/
63 63
64 64 before_destroy :delete_all_members
65 65
66 66 def identifier=(identifier)
67 67 super unless identifier_frozen?
68 68 end
69 69
70 70 def identifier_frozen?
71 71 errors[:identifier].nil? && !(new_record? || identifier.blank?)
72 72 end
73 73
74 74 def issues_with_subprojects(include_subprojects=false)
75 75 conditions = nil
76 76 if include_subprojects
77 77 ids = [id] + child_ids
78 78 conditions = ["#{Project.table_name}.id IN (#{ids.join(',')}) AND #{Project.visible_by}"]
79 79 end
80 80 conditions ||= ["#{Project.table_name}.id = ?", id]
81 81 # Quick and dirty fix for Rails 2 compatibility
82 82 Issue.send(:with_scope, :find => { :conditions => conditions }) do
83 83 Version.send(:with_scope, :find => { :conditions => conditions }) do
84 84 yield
85 85 end
86 86 end
87 87 end
88 88
89 89 # returns latest created projects
90 90 # non public projects will be returned only if user is a member of those
91 91 def self.latest(user=nil, count=5)
92 92 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
93 93 end
94 94
95 95 def self.visible_by(user=nil)
96 96 user ||= User.current
97 97 if user && user.admin?
98 98 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
99 99 elsif user && user.memberships.any?
100 100 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
101 101 else
102 102 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
103 103 end
104 104 end
105 105
106 106 def self.allowed_to_condition(user, permission, options={})
107 107 statements = []
108 108 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
109 109 if options[:project]
110 110 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
111 111 project_statement << " OR #{Project.table_name}.parent_id = #{options[:project].id}" if options[:with_subprojects]
112 112 base_statement = "(#{project_statement}) AND (#{base_statement})"
113 113 end
114 114 if user.admin?
115 115 # no restriction
116 116 elsif user.logged?
117 117 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}" if Role.non_member.allowed_to?(permission)
118 118 allowed_project_ids = user.memberships.select {|m| m.role.allowed_to?(permission)}.collect {|m| m.project_id}
119 119 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
120 120 elsif Role.anonymous.allowed_to?(permission)
121 121 # anonymous user allowed on public project
122 122 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
123 123 else
124 124 # anonymous user is not authorized
125 125 statements << "1=0"
126 126 end
127 127 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
128 128 end
129 129
130 130 def project_condition(with_subprojects)
131 131 cond = "#{Project.table_name}.id = #{id}"
132 132 cond = "(#{cond} OR #{Project.table_name}.parent_id = #{id})" if with_subprojects
133 133 cond
134 134 end
135 135
136 136 def self.find(*args)
137 137 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
138 138 project = find_by_identifier(*args)
139 139 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
140 140 project
141 141 else
142 142 super
143 143 end
144 144 end
145 145
146 146 def to_param
147 147 identifier
148 148 end
149 149
150 150 def active?
151 151 self.status == STATUS_ACTIVE
152 152 end
153 153
154 154 def archive
155 155 # Archive subprojects if any
156 156 children.each do |subproject|
157 157 subproject.archive
158 158 end
159 159 update_attribute :status, STATUS_ARCHIVED
160 160 end
161 161
162 162 def unarchive
163 163 return false if parent && !parent.active?
164 164 update_attribute :status, STATUS_ACTIVE
165 165 end
166 166
167 167 def active_children
168 168 children.select {|child| child.active?}
169 169 end
170 170
171 171 # Returns an array of the trackers used by the project and its sub projects
172 172 def rolled_up_trackers
173 173 @rolled_up_trackers ||=
174 174 Tracker.find(:all, :include => :projects,
175 175 :select => "DISTINCT #{Tracker.table_name}.*",
176 176 :conditions => ["#{Project.table_name}.id = ? OR #{Project.table_name}.parent_id = ?", id, id],
177 177 :order => "#{Tracker.table_name}.position")
178 178 end
179 179
180 180 # Deletes all project's members
181 181 def delete_all_members
182 182 Member.delete_all(['project_id = ?', id])
183 183 end
184 184
185 185 # Users issues can be assigned to
186 186 def assignable_users
187 187 members.select {|m| m.role.assignable?}.collect {|m| m.user}.sort
188 188 end
189 189
190 190 # Returns the mail adresses of users that should be always notified on project events
191 191 def recipients
192 192 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
193 193 end
194 194
195 195 # Returns an array of all custom fields enabled for project issues
196 196 # (explictly associated custom fields and custom fields enabled for all projects)
197 197 def custom_fields_for_issues(tracker)
198 198 all_custom_fields.select {|c| tracker.custom_fields.include? c }
199 199 end
200 200
201 201 def all_custom_fields
202 202 @all_custom_fields ||= (IssueCustomField.for_all + custom_fields).uniq
203 203 end
204 204
205 def project
206 self
207 end
208
205 209 def <=>(project)
206 210 name.downcase <=> project.name.downcase
207 211 end
208 212
209 213 def to_s
210 214 name
211 215 end
212 216
213 217 # Returns a short description of the projects (first lines)
214 218 def short_description(length = 255)
215 219 description.gsub(/^(.{#{length}}[^\n]*).*$/m, '\1').strip if description
216 220 end
217 221
218 222 def allows_to?(action)
219 223 if action.is_a? Hash
220 224 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
221 225 else
222 226 allowed_permissions.include? action
223 227 end
224 228 end
225 229
226 230 def module_enabled?(module_name)
227 231 module_name = module_name.to_s
228 232 enabled_modules.detect {|m| m.name == module_name}
229 233 end
230 234
231 235 def enabled_module_names=(module_names)
232 236 enabled_modules.clear
233 237 module_names = [] unless module_names && module_names.is_a?(Array)
234 238 module_names.each do |name|
235 239 enabled_modules << EnabledModule.new(:name => name.to_s)
236 240 end
237 241 end
238 242
239 243 protected
240 244 def validate
241 245 errors.add(parent_id, " must be a root project") if parent and parent.parent
242 246 errors.add_to_base("A project with subprojects can't be a subproject") if parent and children.size > 0
243 247 errors.add(:identifier, :activerecord_error_invalid) if !identifier.blank? && identifier.match(/^\d*$/)
244 248 end
245 249
246 250 private
247 251 def allowed_permissions
248 252 @allowed_permissions ||= begin
249 253 module_names = enabled_modules.collect {|m| m.name}
250 254 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
251 255 end
252 256 end
253 257
254 258 def allowed_actions
255 259 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
256 260 end
257 261 end
@@ -1,165 +1,165
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 'diff'
19 19 require 'enumerator'
20 20
21 21 class WikiPage < ActiveRecord::Base
22 22 belongs_to :wiki
23 23 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
24 24 has_many :attachments, :as => :container, :dependent => :destroy
25 25
26 26 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
27 27 :description => :text,
28 28 :datetime => :created_on,
29 29 :url => Proc.new {|o| {:controller => 'wiki', :id => o.wiki.project_id, :page => o.title}}
30 30
31 31 acts_as_searchable :columns => ['title', 'text'],
32 :include => [:wiki, :content],
32 :include => [{:wiki => :project}, :content],
33 33 :project_key => "#{Wiki.table_name}.project_id"
34 34
35 35 attr_accessor :redirect_existing_links
36 36
37 37 validates_presence_of :title
38 38 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
39 39 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
40 40 validates_associated :content
41 41
42 42 def title=(value)
43 43 value = Wiki.titleize(value)
44 44 @previous_title = read_attribute(:title) if @previous_title.blank?
45 45 write_attribute(:title, value)
46 46 end
47 47
48 48 def before_save
49 49 self.title = Wiki.titleize(title)
50 50 # Manage redirects if the title has changed
51 51 if !@previous_title.blank? && (@previous_title != title) && !new_record?
52 52 # Update redirects that point to the old title
53 53 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
54 54 r.redirects_to = title
55 55 r.title == r.redirects_to ? r.destroy : r.save
56 56 end
57 57 # Remove redirects for the new title
58 58 wiki.redirects.find_all_by_title(title).each(&:destroy)
59 59 # Create a redirect to the new title
60 60 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
61 61 @previous_title = nil
62 62 end
63 63 end
64 64
65 65 def before_destroy
66 66 # Remove redirects to this page
67 67 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
68 68 end
69 69
70 70 def pretty_title
71 71 WikiPage.pretty_title(title)
72 72 end
73 73
74 74 def content_for_version(version=nil)
75 75 result = content.versions.find_by_version(version.to_i) if version
76 76 result ||= content
77 77 result
78 78 end
79 79
80 80 def diff(version_to=nil, version_from=nil)
81 81 version_to = version_to ? version_to.to_i : self.content.version
82 82 version_from = version_from ? version_from.to_i : version_to - 1
83 83 version_to, version_from = version_from, version_to unless version_from < version_to
84 84
85 85 content_to = content.versions.find_by_version(version_to)
86 86 content_from = content.versions.find_by_version(version_from)
87 87
88 88 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
89 89 end
90 90
91 91 def annotate(version=nil)
92 92 version = version ? version.to_i : self.content.version
93 93 c = content.versions.find_by_version(version)
94 94 c ? WikiAnnotate.new(c) : nil
95 95 end
96 96
97 97 def self.pretty_title(str)
98 98 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
99 99 end
100 100
101 101 def project
102 102 wiki.project
103 103 end
104 104
105 105 def text
106 106 content.text if content
107 107 end
108 108
109 109 # Returns true if usr is allowed to edit the page, otherwise false
110 110 def editable_by?(usr)
111 111 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
112 112 end
113 113 end
114 114
115 115 class WikiDiff
116 116 attr_reader :diff, :words, :content_to, :content_from
117 117
118 118 def initialize(content_to, content_from)
119 119 @content_to = content_to
120 120 @content_from = content_from
121 121 @words = content_to.text.split(/(\s+)/)
122 122 @words = @words.select {|word| word != ' '}
123 123 words_from = content_from.text.split(/(\s+)/)
124 124 words_from = words_from.select {|word| word != ' '}
125 125 @diff = words_from.diff @words
126 126 end
127 127 end
128 128
129 129 class WikiAnnotate
130 130 attr_reader :lines, :content
131 131
132 132 def initialize(content)
133 133 @content = content
134 134 current = content
135 135 current_lines = current.text.split(/\r?\n/)
136 136 @lines = current_lines.collect {|t| [nil, nil, t]}
137 137 positions = []
138 138 current_lines.size.times {|i| positions << i}
139 139 while (current.previous)
140 140 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
141 141 d.each_slice(3) do |s|
142 142 sign, line = s[0], s[1]
143 143 if sign == '+' && positions[line] && positions[line] != -1
144 144 if @lines[positions[line]][0].nil?
145 145 @lines[positions[line]][0] = current.version
146 146 @lines[positions[line]][1] = current.author
147 147 end
148 148 end
149 149 end
150 150 d.each_slice(3) do |s|
151 151 sign, line = s[0], s[1]
152 152 if sign == '-'
153 153 positions.insert(line, -1)
154 154 else
155 155 positions[line] = nil
156 156 end
157 157 end
158 158 positions.compact!
159 159 # Stop if every line is annotated
160 160 break unless @lines.detect { |line| line[0].nil? }
161 161 current = current.previous
162 162 end
163 163 @lines.each { |line| line[0] ||= current.version }
164 164 end
165 165 end
@@ -1,45 +1,47
1 1 <h2><%= l(:label_search) %></h2>
2 2
3 3 <div class="box">
4 4 <% form_tag({}, :method => :get) do %>
5 5 <p><%= text_field_tag 'q', @question, :size => 60, :id => 'search-input' %>
6 6 <%= javascript_tag "Field.focus('search-input')" %>
7
7 <%= project_select_tag %>
8 <label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>
9 <label><%= check_box_tag 'titles_only', 1, @titles_only %> <%= l(:label_search_titles_only) %></label>
10 </p>
11 <p>
8 12 <% @object_types.each do |t| %>
9 13 <label><%= check_box_tag t, 1, @scope.include?(t) %> <%= l("label_#{t.singularize}_plural")%></label>
10 14 <% end %>
11 <br />
12 <label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>
13 <label><%= check_box_tag 'titles_only', 1, @titles_only %> <%= l(:label_search_titles_only) %></label>
14 15 </p>
15 <%= submit_tag l(:button_submit), :name => 'submit' %>
16
17 <p><%= submit_tag l(:button_submit), :name => 'submit' %></p>
16 18 <% end %>
17 19 </div>
18 20
19 21 <% if @results %>
20 22 <h3><%= l(:label_result_plural) %></h3>
21 <ul>
23 <ul id="search-results">
22 24 <% @results.each do |e| %>
23 <li><p><%= link_to highlight_tokens(truncate(e.event_title, 255), @tokens), e.event_url %><br />
25 <li><p><%= content_tag('span', h(e.project), :class => 'project') unless @project == e.project %> <%= link_to highlight_tokens(truncate(e.event_title, 255), @tokens), e.event_url %><br />
24 26 <%= highlight_tokens(e.event_description, @tokens) %><br />
25 27 <span class="author"><%= format_time(e.event_datetime) %></span></p></li>
26 28 <% end %>
27 29 </ul>
28 30 <% end %>
29 31
30 32 <p><center>
31 33 <% if @pagination_previous_date %>
32 34 <%= link_to_remote ('&#171; ' + l(:label_previous)),
33 35 {:update => :content,
34 36 :url => params.merge(:previous => 1, :offset => @pagination_previous_date.strftime("%Y%m%d%H%M%S"))
35 37 }, :href => url_for(params.merge(:previous => 1, :offset => @pagination_previous_date.strftime("%Y%m%d%H%M%S"))) %>&nbsp;
36 38 <% end %>
37 39 <% if @pagination_next_date %>
38 40 <%= link_to_remote (l(:label_next) + ' &#187;'),
39 41 {:update => :content,
40 42 :url => params.merge(:previous => nil, :offset => @pagination_next_date.strftime("%Y%m%d%H%M%S"))
41 43 }, :href => url_for(params.merge(:previous => nil, :offset => @pagination_next_date.strftime("%Y%m%d%H%M%S"))) %>
42 44 <% end %>
43 45 </center></p>
44 46
45 47 <% html_title(l(:label_search)) -%>
@@ -1,598 +1,598
1 1 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
2 2
3 3 h1, h2, h3, h4 { font-family: "Trebuchet MS", Verdana, sans-serif;}
4 4 h1 {margin:0; padding:0; font-size: 24px;}
5 5 h2, .wiki h1 {font-size: 20px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
6 6 h3, .wiki h2 {font-size: 16px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
7 7 h4, .wiki h3 {font-size: 13px;padding: 2px 10px 1px 0px;margin-bottom: 5px; border-bottom: 1px dotted #bbbbbb; color: #444;}
8 8
9 9 /***** Layout *****/
10 10 #wrapper {background: white;}
11 11
12 12 #top-menu {background: #2C4056; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
13 13 #top-menu ul {margin: 0; padding: 0;}
14 14 #top-menu li {
15 15 float:left;
16 16 list-style-type:none;
17 17 margin: 0px 0px 0px 0px;
18 18 padding: 0px 0px 0px 0px;
19 19 white-space:nowrap;
20 20 }
21 21 #top-menu a {color: #fff; padding-right: 8px; font-weight: bold;}
22 22 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
23 23
24 24 #account {float:right;}
25 25
26 26 #header {height:5.3em;margin:0;background-color:#507AAA;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
27 27 #header a {color:#f8f8f8;}
28 28 #quick-search {float:right;}
29 29
30 30 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
31 31 #main-menu ul {margin: 0; padding: 0;}
32 32 #main-menu li {
33 33 float:left;
34 34 list-style-type:none;
35 35 margin: 0px 2px 0px 0px;
36 36 padding: 0px 0px 0px 0px;
37 37 white-space:nowrap;
38 38 }
39 39 #main-menu li a {
40 40 display: block;
41 41 color: #fff;
42 42 text-decoration: none;
43 43 font-weight: bold;
44 44 margin: 0;
45 45 padding: 4px 10px 4px 10px;
46 46 }
47 47 #main-menu li a:hover {background:#759FCF; color:#fff;}
48 48 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
49 49
50 50 #main {background-color:#EEEEEE;}
51 51
52 52 #sidebar{ float: right; width: 17%; position: relative; z-index: 9; min-height: 600px; padding: 0; margin: 0;}
53 53 * html #sidebar{ width: 17%; }
54 54 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
55 55 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
56 56 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
57 57
58 58 #content { width: 80%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; height:600px; min-height: 600px;}
59 59 * html #content{ width: 80%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
60 60 html>body #content { height: auto; min-height: 600px; overflow: auto; }
61 61
62 62 #main.nosidebar #sidebar{ display: none; }
63 63 #main.nosidebar #content{ width: auto; border-right: 0; }
64 64
65 65 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
66 66
67 67 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
68 68 #login-form table td {padding: 6px;}
69 69 #login-form label {font-weight: bold;}
70 70
71 71 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
72 72
73 73 /***** Links *****/
74 74 a, a:link, a:visited{ color: #2A5685; text-decoration: none; }
75 75 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
76 76 a img{ border: 0; }
77 77
78 78 a.issue.closed, .issue.closed a { text-decoration: line-through; }
79 79
80 80 /***** Tables *****/
81 81 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
82 82 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
83 83 table.list td { overflow: hidden; vertical-align: top;}
84 84 table.list td.id { width: 2%; text-align: center;}
85 85 table.list td.checkbox { width: 15px; padding: 0px;}
86 86
87 87 table.list.issues { margin-top: 10px; }
88 88 tr.issue { text-align: center; white-space: nowrap; }
89 89 tr.issue td.subject, tr.issue td.category { white-space: normal; }
90 90 tr.issue td.subject { text-align: left; }
91 91 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
92 92
93 93 tr.entry { border: 1px solid #f8f8f8; }
94 94 tr.entry td { white-space: nowrap; }
95 95 tr.entry td.filename { width: 30%; }
96 96 tr.entry td.size { text-align: right; font-size: 90%; }
97 97 tr.entry td.revision, tr.entry td.author { text-align: center; }
98 98 tr.entry td.age { text-align: right; }
99 99
100 100 tr.changeset td.author { text-align: center; width: 15%; }
101 101 tr.changeset td.committed_on { text-align: center; width: 15%; }
102 102
103 103 tr.message { height: 2.6em; }
104 104 tr.message td.last_message { font-size: 80%; }
105 105 tr.message.locked td.subject a { background-image: url(../images/locked.png); }
106 106 tr.message.sticky td.subject a { background-image: url(../images/sticky.png); font-weight: bold; }
107 107
108 108 tr.user td { width:13%; }
109 109 tr.user td.email { width:18%; }
110 110 tr.user td { white-space: nowrap; }
111 111 tr.user.locked, tr.user.registered { color: #aaa; }
112 112 tr.user.locked a, tr.user.registered a { color: #aaa; }
113 113
114 114 tr.time-entry { text-align: center; white-space: nowrap; }
115 115 tr.time-entry td.subject, tr.time-entry td.comments { text-align: left; white-space: normal; }
116 116 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
117 117 td.hours .hours-dec { font-size: 0.9em; }
118 118
119 119 table.list tbody tr:hover { background-color:#ffffdd; }
120 120 table td {padding:2px;}
121 121 table p {margin:0;}
122 122 .odd {background-color:#f6f7f8;}
123 123 .even {background-color: #fff;}
124 124
125 125 .highlight { background-color: #FCFD8D;}
126 126 .highlight.token-1 { background-color: #faa;}
127 127 .highlight.token-2 { background-color: #afa;}
128 128 .highlight.token-3 { background-color: #aaf;}
129 129
130 130 .box{
131 131 padding:6px;
132 132 margin-bottom: 10px;
133 133 background-color:#f6f6f6;
134 134 color:#505050;
135 135 line-height:1.5em;
136 136 border: 1px solid #e4e4e4;
137 137 }
138 138
139 139 div.square {
140 140 border: 1px solid #999;
141 141 float: left;
142 142 margin: .3em .4em 0 .4em;
143 143 overflow: hidden;
144 144 width: .6em; height: .6em;
145 145 }
146 146 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
147 147 .contextual input {font-size:0.9em;}
148 148
149 149 .splitcontentleft{float:left; width:49%;}
150 150 .splitcontentright{float:right; width:49%;}
151 151 form {display: inline;}
152 152 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
153 153 fieldset {border: 1px solid #e4e4e4; margin:0;}
154 154 legend {color: #484848;}
155 155 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
156 156 textarea.wiki-edit { width: 99%; }
157 157 li p {margin-top: 0;}
158 158 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
159 159 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
160 160 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
161 161
162 162 fieldset#filters { padding: 0.7em; }
163 163 fieldset#filters p { margin: 1.2em 0 0.8em 2px; }
164 164 fieldset#filters .buttons { font-size: 0.9em; }
165 165 fieldset#filters table { border-collapse: collapse; }
166 166 fieldset#filters table td { padding: 0; vertical-align: middle; }
167 167 fieldset#filters tr.filter { height: 2em; }
168 168 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
169 169
170 170 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
171 171 div#issue-changesets .changeset { padding: 4px;}
172 172 div#issue-changesets .changeset { border-bottom: 1px solid #ddd; }
173 173 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
174 174
175 175 div#activity dl { margin-left: 2em; }
176 176 div#activity dd { margin-bottom: 1em; padding-left: 18px; }
177 177 div#activity dt { margin-bottom: 1px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
178 178 div#activity dt .time { color: #777; font-size: 80%; }
179 179 div#activity dd .description { font-style: italic; }
180 div#activity span.project:after { content: " -"; }
180 div#activity span.project:after, #search-results span.project:after { content: " -"; }
181 181 div#activity dt.issue { background-image: url(../images/ticket.png); }
182 182 div#activity dt.issue-edit { background-image: url(../images/ticket_edit.png); }
183 183 div#activity dt.issue-closed { background-image: url(../images/ticket_checked.png); }
184 184 div#activity dt.changeset { background-image: url(../images/changeset.png); }
185 185 div#activity dt.news { background-image: url(../images/news.png); }
186 186 div#activity dt.message { background-image: url(../images/message.png); }
187 187 div#activity dt.reply { background-image: url(../images/comments.png); }
188 188 div#activity dt.wiki-page { background-image: url(../images/wiki_edit.png); }
189 189 div#activity dt.attachment { background-image: url(../images/attachment.png); }
190 190 div#activity dt.document { background-image: url(../images/document.png); }
191 191
192 192 div#roadmap fieldset.related-issues { margin-bottom: 1em; }
193 193 div#roadmap fieldset.related-issues ul { margin-top: 0.3em; margin-bottom: 0.3em; }
194 194 div#roadmap .wiki h1:first-child { display: none; }
195 195 div#roadmap .wiki h1 { font-size: 120%; }
196 196 div#roadmap .wiki h2 { font-size: 110%; }
197 197
198 198 div#version-summary { float:right; width:380px; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
199 199 div#version-summary fieldset { margin-bottom: 1em; }
200 200 div#version-summary .total-hours { text-align: right; }
201 201
202 202 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
203 203 table#time-report tbody tr { font-style: italic; color: #777; }
204 204 table#time-report tbody tr.last-level { font-style: normal; color: #555; }
205 205 table#time-report tbody tr.total { font-style: normal; font-weight: bold; color: #555; background-color:#EEEEEE; }
206 206 table#time-report .hours-dec { font-size: 0.9em; }
207 207
208 208 .total-hours { font-size: 110%; font-weight: bold; }
209 209 .total-hours span.hours-int { font-size: 120%; }
210 210
211 211 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
212 212 #user_firstname, #user_lastname, #user_mail, #my_account_form select { width: 90%; }
213 213
214 214 .pagination {font-size: 90%}
215 215 p.pagination {margin-top:8px;}
216 216
217 217 /***** Tabular forms ******/
218 218 .tabular p{
219 219 margin: 0;
220 220 padding: 5px 0 8px 0;
221 221 padding-left: 180px; /*width of left column containing the label elements*/
222 222 height: 1%;
223 223 clear:left;
224 224 }
225 225
226 226 html>body .tabular p {overflow:auto;}
227 227
228 228 .tabular label{
229 229 font-weight: bold;
230 230 float: left;
231 231 text-align: right;
232 232 margin-left: -180px; /*width of left column*/
233 233 width: 175px; /*width of labels. Should be smaller than left column to create some right
234 234 margin*/
235 235 }
236 236
237 237 .tabular label.floating{
238 238 font-weight: normal;
239 239 margin-left: 0px;
240 240 text-align: left;
241 241 width: 200px;
242 242 }
243 243
244 244 input#time_entry_comments { width: 90%;}
245 245
246 246 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
247 247
248 248 .tabular.settings p{ padding-left: 300px; }
249 249 .tabular.settings label{ margin-left: -300px; width: 295px; }
250 250
251 251 .required {color: #bb0000;}
252 252 .summary {font-style: italic;}
253 253
254 254 #attachments_fields input[type=text] {margin-left: 8px; }
255 255
256 256 div.attachments p { margin:4px 0 2px 0; }
257 257 div.attachments img { vertical-align: middle; }
258 258 div.attachments span.author { font-size: 0.9em; color: #888; }
259 259
260 260 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
261 261 .other-formats span + span:before { content: "| "; }
262 262
263 263 a.feed { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
264 264
265 265 /***** Flash & error messages ****/
266 266 #errorExplanation, div.flash, .nodata, .warning {
267 267 padding: 4px 4px 4px 30px;
268 268 margin-bottom: 12px;
269 269 font-size: 1.1em;
270 270 border: 2px solid;
271 271 }
272 272
273 273 div.flash {margin-top: 8px;}
274 274
275 275 div.flash.error, #errorExplanation {
276 276 background: url(../images/false.png) 8px 5px no-repeat;
277 277 background-color: #ffe3e3;
278 278 border-color: #dd0000;
279 279 color: #550000;
280 280 }
281 281
282 282 div.flash.notice {
283 283 background: url(../images/true.png) 8px 5px no-repeat;
284 284 background-color: #dfffdf;
285 285 border-color: #9fcf9f;
286 286 color: #005f00;
287 287 }
288 288
289 289 .nodata, .warning {
290 290 text-align: center;
291 291 background-color: #FFEBC1;
292 292 border-color: #FDBF3B;
293 293 color: #A6750C;
294 294 }
295 295
296 296 #errorExplanation ul { font-size: 0.9em;}
297 297
298 298 /***** Ajax indicator ******/
299 299 #ajax-indicator {
300 300 position: absolute; /* fixed not supported by IE */
301 301 background-color:#eee;
302 302 border: 1px solid #bbb;
303 303 top:35%;
304 304 left:40%;
305 305 width:20%;
306 306 font-weight:bold;
307 307 text-align:center;
308 308 padding:0.6em;
309 309 z-index:100;
310 310 filter:alpha(opacity=50);
311 311 opacity: 0.5;
312 312 }
313 313
314 314 html>body #ajax-indicator { position: fixed; }
315 315
316 316 #ajax-indicator span {
317 317 background-position: 0% 40%;
318 318 background-repeat: no-repeat;
319 319 background-image: url(../images/loading.gif);
320 320 padding-left: 26px;
321 321 vertical-align: bottom;
322 322 }
323 323
324 324 /***** Calendar *****/
325 325 table.cal {border-collapse: collapse; width: 100%; margin: 8px 0 6px 0;border: 1px solid #d7d7d7;}
326 326 table.cal thead th {width: 14%;}
327 327 table.cal tbody tr {height: 100px;}
328 328 table.cal th { background-color:#EEEEEE; padding: 4px; }
329 329 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
330 330 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
331 331 table.cal td.odd p.day-num {color: #bbb;}
332 332 table.cal td.today {background:#ffffdd;}
333 333 table.cal td.today p.day-num {font-weight: bold;}
334 334
335 335 /***** Tooltips ******/
336 336 .tooltip{position:relative;z-index:24;}
337 337 .tooltip:hover{z-index:25;color:#000;}
338 338 .tooltip span.tip{display: none; text-align:left;}
339 339
340 340 div.tooltip:hover span.tip{
341 341 display:block;
342 342 position:absolute;
343 343 top:12px; left:24px; width:270px;
344 344 border:1px solid #555;
345 345 background-color:#fff;
346 346 padding: 4px;
347 347 font-size: 0.8em;
348 348 color:#505050;
349 349 }
350 350
351 351 /***** Progress bar *****/
352 352 table.progress {
353 353 border: 1px solid #D7D7D7;
354 354 border-collapse: collapse;
355 355 border-spacing: 0pt;
356 356 empty-cells: show;
357 357 text-align: center;
358 358 float:left;
359 359 margin: 1px 6px 1px 0px;
360 360 }
361 361
362 362 table.progress td { height: 0.9em; }
363 363 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
364 364 table.progress td.done { background: #DEF0DE none repeat scroll 0%; }
365 365 table.progress td.open { background: #FFF none repeat scroll 0%; }
366 366 p.pourcent {font-size: 80%;}
367 367 p.progress-info {clear: left; font-style: italic; font-size: 80%;}
368 368
369 369 /***** Tabs *****/
370 370 #content .tabs {height: 2.6em; border-bottom: 1px solid #bbbbbb; margin-bottom:1.2em; position:relative;}
371 371 #content .tabs ul {margin:0; position:absolute; bottom:-2px; padding-left:1em;}
372 372 #content .tabs>ul { bottom:-1px; } /* others */
373 373 #content .tabs ul li {
374 374 float:left;
375 375 list-style-type:none;
376 376 white-space:nowrap;
377 377 margin-right:8px;
378 378 background:#fff;
379 379 }
380 380 #content .tabs ul li a{
381 381 display:block;
382 382 font-size: 0.9em;
383 383 text-decoration:none;
384 384 line-height:1.3em;
385 385 padding:4px 6px 4px 6px;
386 386 border: 1px solid #ccc;
387 387 border-bottom: 1px solid #bbbbbb;
388 388 background-color: #eeeeee;
389 389 color:#777;
390 390 font-weight:bold;
391 391 }
392 392
393 393 #content .tabs ul li a:hover {
394 394 background-color: #ffffdd;
395 395 text-decoration:none;
396 396 }
397 397
398 398 #content .tabs ul li a.selected {
399 399 background-color: #fff;
400 400 border: 1px solid #bbbbbb;
401 401 border-bottom: 1px solid #fff;
402 402 }
403 403
404 404 #content .tabs ul li a.selected:hover {
405 405 background-color: #fff;
406 406 }
407 407
408 408 /***** Diff *****/
409 409 .diff_out { background: #fcc; }
410 410 .diff_in { background: #cfc; }
411 411
412 412 /***** Wiki *****/
413 413 div.wiki table {
414 414 border: 1px solid #505050;
415 415 border-collapse: collapse;
416 416 margin-bottom: 1em;
417 417 }
418 418
419 419 div.wiki table, div.wiki td, div.wiki th {
420 420 border: 1px solid #bbb;
421 421 padding: 4px;
422 422 }
423 423
424 424 div.wiki .external {
425 425 background-position: 0% 60%;
426 426 background-repeat: no-repeat;
427 427 padding-left: 12px;
428 428 background-image: url(../images/external.png);
429 429 }
430 430
431 431 div.wiki a.new {
432 432 color: #b73535;
433 433 }
434 434
435 435 div.wiki pre {
436 436 margin: 1em 1em 1em 1.6em;
437 437 padding: 2px;
438 438 background-color: #fafafa;
439 439 border: 1px solid #dadada;
440 440 width:95%;
441 441 overflow-x: auto;
442 442 }
443 443
444 444 div.wiki div.toc {
445 445 background-color: #ffffdd;
446 446 border: 1px solid #e4e4e4;
447 447 padding: 4px;
448 448 line-height: 1.2em;
449 449 margin-bottom: 12px;
450 450 margin-right: 12px;
451 451 display: table
452 452 }
453 453 * html div.wiki div.toc { width: 50%; } /* IE6 doesn't autosize div */
454 454
455 455 div.wiki div.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
456 456 div.wiki div.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
457 457
458 458 div.wiki div.toc a {
459 459 display: block;
460 460 font-size: 0.9em;
461 461 font-weight: normal;
462 462 text-decoration: none;
463 463 color: #606060;
464 464 }
465 465 div.wiki div.toc a:hover { color: #c61a1a; text-decoration: underline;}
466 466
467 467 div.wiki div.toc a.heading2 { margin-left: 6px; }
468 468 div.wiki div.toc a.heading3 { margin-left: 12px; font-size: 0.8em; }
469 469
470 470 /***** My page layout *****/
471 471 .block-receiver {
472 472 border:1px dashed #c0c0c0;
473 473 margin-bottom: 20px;
474 474 padding: 15px 0 15px 0;
475 475 }
476 476
477 477 .mypage-box {
478 478 margin:0 0 20px 0;
479 479 color:#505050;
480 480 line-height:1.5em;
481 481 }
482 482
483 483 .handle {
484 484 cursor: move;
485 485 }
486 486
487 487 a.close-icon {
488 488 display:block;
489 489 margin-top:3px;
490 490 overflow:hidden;
491 491 width:12px;
492 492 height:12px;
493 493 background-repeat: no-repeat;
494 494 cursor:pointer;
495 495 background-image:url('../images/close.png');
496 496 }
497 497
498 498 a.close-icon:hover {
499 499 background-image:url('../images/close_hl.png');
500 500 }
501 501
502 502 /***** Gantt chart *****/
503 503 .gantt_hdr {
504 504 position:absolute;
505 505 top:0;
506 506 height:16px;
507 507 border-top: 1px solid #c0c0c0;
508 508 border-bottom: 1px solid #c0c0c0;
509 509 border-right: 1px solid #c0c0c0;
510 510 text-align: center;
511 511 overflow: hidden;
512 512 }
513 513
514 514 .task {
515 515 position: absolute;
516 516 height:8px;
517 517 font-size:0.8em;
518 518 color:#888;
519 519 padding:0;
520 520 margin:0;
521 521 line-height:0.8em;
522 522 }
523 523
524 524 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
525 525 .task_done { background:#66f url(../images/task_done.png); border: 1px solid #66f; }
526 526 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
527 527 .milestone { background-image:url(../images/milestone.png); background-repeat: no-repeat; border: 0; }
528 528
529 529 /***** Icons *****/
530 530 .icon {
531 531 background-position: 0% 40%;
532 532 background-repeat: no-repeat;
533 533 padding-left: 20px;
534 534 padding-top: 2px;
535 535 padding-bottom: 3px;
536 536 }
537 537
538 538 .icon22 {
539 539 background-position: 0% 40%;
540 540 background-repeat: no-repeat;
541 541 padding-left: 26px;
542 542 line-height: 22px;
543 543 vertical-align: middle;
544 544 }
545 545
546 546 .icon-add { background-image: url(../images/add.png); }
547 547 .icon-edit { background-image: url(../images/edit.png); }
548 548 .icon-copy { background-image: url(../images/copy.png); }
549 549 .icon-del { background-image: url(../images/delete.png); }
550 550 .icon-move { background-image: url(../images/move.png); }
551 551 .icon-save { background-image: url(../images/save.png); }
552 552 .icon-cancel { background-image: url(../images/cancel.png); }
553 553 .icon-file { background-image: url(../images/file.png); }
554 554 .icon-folder { background-image: url(../images/folder.png); }
555 555 .open .icon-folder { background-image: url(../images/folder_open.png); }
556 556 .icon-package { background-image: url(../images/package.png); }
557 557 .icon-home { background-image: url(../images/home.png); }
558 558 .icon-user { background-image: url(../images/user.png); }
559 559 .icon-mypage { background-image: url(../images/user_page.png); }
560 560 .icon-admin { background-image: url(../images/admin.png); }
561 561 .icon-projects { background-image: url(../images/projects.png); }
562 562 .icon-logout { background-image: url(../images/logout.png); }
563 563 .icon-help { background-image: url(../images/help.png); }
564 564 .icon-attachment { background-image: url(../images/attachment.png); }
565 565 .icon-index { background-image: url(../images/index.png); }
566 566 .icon-history { background-image: url(../images/history.png); }
567 567 .icon-time { background-image: url(../images/time.png); }
568 568 .icon-stats { background-image: url(../images/stats.png); }
569 569 .icon-warning { background-image: url(../images/warning.png); }
570 570 .icon-fav { background-image: url(../images/fav.png); }
571 571 .icon-fav-off { background-image: url(../images/fav_off.png); }
572 572 .icon-reload { background-image: url(../images/reload.png); }
573 573 .icon-lock { background-image: url(../images/locked.png); }
574 574 .icon-unlock { background-image: url(../images/unlock.png); }
575 575 .icon-checked { background-image: url(../images/true.png); }
576 576 .icon-details { background-image: url(../images/zoom_in.png); }
577 577 .icon-report { background-image: url(../images/report.png); }
578 578
579 579 .icon22-projects { background-image: url(../images/22x22/projects.png); }
580 580 .icon22-users { background-image: url(../images/22x22/users.png); }
581 581 .icon22-tracker { background-image: url(../images/22x22/tracker.png); }
582 582 .icon22-role { background-image: url(../images/22x22/role.png); }
583 583 .icon22-workflow { background-image: url(../images/22x22/workflow.png); }
584 584 .icon22-options { background-image: url(../images/22x22/options.png); }
585 585 .icon22-notifications { background-image: url(../images/22x22/notifications.png); }
586 586 .icon22-authent { background-image: url(../images/22x22/authent.png); }
587 587 .icon22-info { background-image: url(../images/22x22/info.png); }
588 588 .icon22-comment { background-image: url(../images/22x22/comment.png); }
589 589 .icon22-package { background-image: url(../images/22x22/package.png); }
590 590 .icon22-settings { background-image: url(../images/22x22/settings.png); }
591 591 .icon22-plugin { background-image: url(../images/22x22/plugin.png); }
592 592
593 593 /***** Media print specific styles *****/
594 594 @media print {
595 595 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
596 596 #main { background: #fff; }
597 597 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; }
598 598 }
@@ -1,102 +1,114
1 1 require File.dirname(__FILE__) + '/../test_helper'
2 2 require 'search_controller'
3 3
4 4 # Re-raise errors caught by the controller.
5 5 class SearchController; def rescue_action(e) raise e end; end
6 6
7 7 class SearchControllerTest < Test::Unit::TestCase
8 fixtures :projects, :enabled_modules, :issues, :custom_fields, :custom_values
8 fixtures :projects, :enabled_modules, :roles, :users,
9 :issues, :trackers, :issue_statuses,
10 :custom_fields, :custom_values,
11 :repositories, :changesets
9 12
10 13 def setup
11 14 @controller = SearchController.new
12 15 @request = ActionController::TestRequest.new
13 16 @response = ActionController::TestResponse.new
14 17 User.current = nil
15 18 end
16 19
17 20 def test_search_for_projects
18 21 get :index
19 22 assert_response :success
20 23 assert_template 'index'
21 24
22 25 get :index, :q => "cook"
23 26 assert_response :success
24 27 assert_template 'index'
25 28 assert assigns(:results).include?(Project.find(1))
26 29 end
27 30
31 def test_search_all_projects
32 get :index, :q => 'recipe subproject commit', :submit => 'Search'
33 assert_response :success
34 assert_template 'index'
35 assert assigns(:results).include?(Issue.find(2))
36 assert assigns(:results).include?(Issue.find(5))
37 assert assigns(:results).include?(Changeset.find(101))
38 end
39
28 40 def test_search_without_searchable_custom_fields
29 41 CustomField.update_all "searchable = #{ActiveRecord::Base.connection.quoted_false}"
30 42
31 43 get :index, :id => 1
32 44 assert_response :success
33 45 assert_template 'index'
34 46 assert_not_nil assigns(:project)
35 47
36 48 get :index, :id => 1, :q => "can"
37 49 assert_response :success
38 50 assert_template 'index'
39 51 end
40 52
41 53 def test_search_with_searchable_custom_fields
42 54 get :index, :id => 1, :q => "stringforcustomfield"
43 55 assert_response :success
44 56 results = assigns(:results)
45 57 assert_not_nil results
46 58 assert_equal 1, results.size
47 59 assert results.include?(Issue.find(3))
48 60 end
49 61
50 62 def test_search_all_words
51 63 # 'all words' is on by default
52 64 get :index, :id => 1, :q => 'recipe updating saving'
53 65 results = assigns(:results)
54 66 assert_not_nil results
55 67 assert_equal 1, results.size
56 68 assert results.include?(Issue.find(3))
57 69 end
58 70
59 71 def test_search_one_of_the_words
60 72 get :index, :id => 1, :q => 'recipe updating saving', :submit => 'Search'
61 73 results = assigns(:results)
62 74 assert_not_nil results
63 75 assert_equal 3, results.size
64 76 assert results.include?(Issue.find(3))
65 77 end
66 78
67 79 def test_search_titles_only_without_result
68 80 get :index, :id => 1, :q => 'recipe updating saving', :all_words => '1', :titles_only => '1', :submit => 'Search'
69 81 results = assigns(:results)
70 82 assert_not_nil results
71 83 assert_equal 0, results.size
72 84 end
73 85
74 86 def test_search_titles_only
75 87 get :index, :id => 1, :q => 'recipe', :titles_only => '1', :submit => 'Search'
76 88 results = assigns(:results)
77 89 assert_not_nil results
78 90 assert_equal 2, results.size
79 91 end
80 92
81 93 def test_search_with_invalid_project_id
82 94 get :index, :id => 195, :q => 'recipe'
83 95 assert_response 404
84 96 assert_nil assigns(:results)
85 97 end
86 98
87 99 def test_quick_jump_to_issue
88 100 # issue of a public project
89 101 get :index, :q => "3"
90 102 assert_redirected_to 'issues/show/3'
91 103
92 104 # issue of a private project
93 105 get :index, :q => "4"
94 106 assert_response :success
95 107 assert_template 'index'
96 108 end
97 109
98 110 def test_tokens_with_quotes
99 111 get :index, :id => 1, :q => '"good bye" hello "bye bye"'
100 112 assert_equal ["good bye", "hello", "bye bye"], assigns(:tokens)
101 113 end
102 114 end
@@ -1,110 +1,122
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 module Redmine
19 19 module Acts
20 20 module Searchable
21 21 def self.included(base)
22 22 base.extend ClassMethods
23 23 end
24 24
25 25 module ClassMethods
26 26 def acts_as_searchable(options = {})
27 27 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
28 28
29 29 cattr_accessor :searchable_options
30 30 self.searchable_options = options
31 31
32 32 if searchable_options[:columns].nil?
33 33 raise 'No searchable column defined.'
34 34 elsif !searchable_options[:columns].is_a?(Array)
35 35 searchable_options[:columns] = [] << searchable_options[:columns]
36 36 end
37 37
38 38 if searchable_options[:project_key]
39 39 elsif column_names.include?('project_id')
40 40 searchable_options[:project_key] = "#{table_name}.project_id"
41 41 else
42 42 raise 'No project key defined.'
43 43 end
44 44
45 45 if searchable_options[:date_column]
46 46 elsif column_names.include?('created_on')
47 47 searchable_options[:date_column] = "#{table_name}.created_on"
48 48 else
49 49 raise 'No date column defined defined.'
50 50 end
51 51
52 # Permission needed to search this model
53 searchable_options[:permission] = "view_#{self.name.underscore.pluralize}".to_sym unless searchable_options.has_key?(:permission)
54
52 55 # Should we search custom fields on this model ?
53 56 searchable_options[:search_custom_fields] = !reflect_on_association(:custom_values).nil?
54 57
55 58 send :include, Redmine::Acts::Searchable::InstanceMethods
56 59 end
57 60 end
58 61
59 62 module InstanceMethods
60 63 def self.included(base)
61 64 base.extend ClassMethods
62 65 end
63 66
64 67 module ClassMethods
65 def search(tokens, project, options={})
68 # Search the model for the given tokens
69 # projects argument can be either nil (will search all projects), a project or an array of projects
70 def search(tokens, projects, options={})
66 71 tokens = [] << tokens unless tokens.is_a?(Array)
72 projects = [] << projects unless projects.nil? || projects.is_a?(Array)
73
67 74 find_options = {:include => searchable_options[:include]}
68 75 find_options[:limit] = options[:limit] if options[:limit]
69 76 find_options[:order] = "#{searchable_options[:date_column]} " + (options[:before] ? 'DESC' : 'ASC')
70 77 columns = searchable_options[:columns]
71 78 columns.slice!(1..-1) if options[:titles_only]
72 79
73 80 token_clauses = columns.collect {|column| "(LOWER(#{column}) LIKE ?)"}
74 81
75 82 if !options[:titles_only] && searchable_options[:search_custom_fields]
76 83 searchable_custom_field_ids = CustomField.find(:all,
77 84 :select => 'id',
78 85 :conditions => { :type => "#{self.name}CustomField",
79 86 :searchable => true }).collect(&:id)
80 87 if searchable_custom_field_ids.any?
81 88 custom_field_sql = "#{table_name}.id IN (SELECT customized_id FROM #{CustomValue.table_name}" +
82 89 " WHERE customized_type='#{self.name}' AND customized_id=#{table_name}.id AND LOWER(value) LIKE ?" +
83 90 " AND #{CustomValue.table_name}.custom_field_id IN (#{searchable_custom_field_ids.join(',')}))"
84 91 token_clauses << custom_field_sql
85 92 end
86 93 end
87 94
88 95 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
89 96
90 97 if options[:offset]
91 98 sql = "(#{sql}) AND (#{searchable_options[:date_column]} " + (options[:before] ? '<' : '>') + "'#{connection.quoted_date(options[:offset])}')"
92 99 end
93 100 find_options[:conditions] = [sql, * (tokens * token_clauses.size).sort]
94 101
95 results = with_scope(:find => {:conditions => ["#{searchable_options[:project_key]} = ?", project.id]}) do
102 project_conditions = []
103 project_conditions << (searchable_options[:permission].nil? ? Project.visible_by(User.current) :
104 Project.allowed_to_condition(User.current, searchable_options[:permission]))
105 project_conditions << "#{searchable_options[:project_key]} IN (#{projects.collect(&:id).join(',')})" unless projects.nil?
106
107 results = with_scope(:find => {:conditions => project_conditions.join(' AND ')}) do
96 108 find(:all, find_options)
97 109 end
98 110 if searchable_options[:with] && !options[:titles_only]
99 111 searchable_options[:with].each do |model, assoc|
100 results += model.to_s.camelcase.constantize.search(tokens, project, options).collect {|r| r.send assoc}
112 results += model.to_s.camelcase.constantize.search(tokens, projects, options).collect {|r| r.send assoc}
101 113 end
102 114 results.uniq!
103 115 end
104 116 results
105 117 end
106 118 end
107 119 end
108 120 end
109 121 end
110 122 end
General Comments 0
You need to be logged in to leave comments. Login now