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