...
 
Commits (209)
......@@ -3,6 +3,7 @@
/.loadpath
/.powrc
/.rvmrc
/.ruby-version
/config/additional_environment.rb
/config/configuration.yml
/config/database.yml
......
......@@ -5,6 +5,7 @@ syntax: glob
.loadpath
.powrc
.rvmrc
.ruby-version
config/additional_environment.rb
config/configuration.yml
config/database.yml
......
source 'https://rubygems.org'
if Gem::Version.new(Bundler::VERSION) < Gem::Version.new('1.5.0')
abort "Redmine requires Bundler 1.5.0 or higher (you're using #{Bundler::VERSION}).\nPlease update with 'gem update bundler'."
end
gem "bundler", ">= 1.5.0", "< 2.0.0"
gem "rails", "4.2.8"
gem "rails", "4.2.11.1"
gem "addressable", "2.4.0" if RUBY_VERSION < "2.0"
if RUBY_VERSION < "2.1"
gem "public_suffix", (RUBY_VERSION < "2.0" ? "~> 1.4" : "~> 2.0.5")
end
gem "jquery-rails", "~> 3.1.4"
gem "coderay", "~> 1.1.1"
gem "request_store", "1.0.5"
......@@ -15,17 +16,19 @@ gem "actionpack-xml_parser"
gem "roadie-rails", "~> 1.1.1"
gem "roadie", "~> 3.2.1"
gem "mimemagic"
gem "mail", "~> 2.6.4"
gem "nokogiri", (RUBY_VERSION >= "2.1" ? "~> 1.7.2" : "~> 1.6.8")
gem "nokogiri", (RUBY_VERSION >= "2.1" ? "~> 1.8.1" : "~> 1.6.8")
gem "i18n", "~> 0.7.0"
gem "ffi", "1.9.14", :platforms => :mingw if RUBY_VERSION < "2.0"
gem "xpath", "< 3.2.0" if RUBY_VERSION < "2.3"
# Request at least rails-html-sanitizer 1.0.3 because of security advisories
gem "rails-html-sanitizer", ">= 1.0.3"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
gem "rbpdf", "~> 1.19.2"
gem "rbpdf", "~> 1.19.6"
# Optional gem for LDAP authentication
group :ldap do
......@@ -41,7 +44,7 @@ end
platforms :mri, :mingw, :x64_mingw do
# Optional gem for exporting the gantt to a PNG file, not supported with jruby
group :rmagick do
gem "rmagick", ">= 2.14.0"
gem "rmagick", "~> 2.16.0"
end
# Optional Markdown support, not for JRuby
......@@ -95,7 +98,7 @@ group :test do
# TODO: remove this after upgrading to Rails 5
gem "test_after_commit", "~> 0.4.2"
# For running UI tests
gem "capybara"
gem "capybara", '~> 2.13'
gem "selenium-webdriver", "~> 2.53.4"
end
......
......@@ -87,7 +87,7 @@ class AccountController < ApplicationController
@user.must_change_passwd = false
if @user.save
@token.destroy
Mailer.password_updated(@user)
Mailer.password_updated(@user, { remote_ip: request.remote_ip })
flash[:notice] = l(:notice_account_password_updated)
redirect_to signin_path
return
......@@ -98,7 +98,7 @@ class AccountController < ApplicationController
return
else
if request.post?
email = params[:mail].to_s
email = params[:mail].to_s.strip
user = User.find_by_mail(email)
# user not found
unless user
......
......@@ -19,7 +19,7 @@ class AutoCompletesController < ApplicationController
before_action :find_project
def issues
@issues = []
issues = []
q = (params[:q] || params[:term]).to_s.strip
status = params[:status].to_s
issue_id = params[:issue_id].to_s
......@@ -32,13 +32,14 @@ class AutoCompletesController < ApplicationController
scope = scope.where.not(:id => issue_id.to_i)
end
if q.match(/\A#?(\d+)\z/)
@issues << scope.find_by_id($1.to_i)
issues << scope.find_by_id($1.to_i)
end
@issues += scope.like(q).order(:id => :desc).limit(10).to_a
@issues.compact!
issues += scope.like(q).order(:id => :desc).limit(10).to_a
issues.compact!
end
render :layout => false
render :json => format_issues_json(issues)
end
private
......@@ -50,4 +51,13 @@ class AutoCompletesController < ApplicationController
rescue ActiveRecord::RecordNotFound
render_404
end
def format_issues_json(issues)
issues.map {|issue| {
'id' => issue.id,
'label' => "#{issue.tracker} ##{issue.id}: #{issue.subject.to_s.truncate(60)}",
'value' => issue.id
}
}
end
end
......@@ -39,6 +39,7 @@ class CalendarsController < ApplicationController
@calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
retrieve_query
@query.group_by = nil
@query.sort_criteria = nil
if @query.valid?
events = []
events += @query.issues(:include => [:tracker, :assigned_to, :priority],
......
......@@ -91,8 +91,10 @@ class EnumerationsController < ApplicationController
def build_new_enumeration
class_name = params[:enumeration] && params[:enumeration][:type] || params[:type]
@enumeration = Enumeration.new_subclass_instance(class_name, enumeration_params)
if @enumeration.nil?
@enumeration = Enumeration.new_subclass_instance(class_name)
if @enumeration
@enumeration.attributes = enumeration_params || {}
else
render_404
end
end
......@@ -105,6 +107,7 @@ class EnumerationsController < ApplicationController
def enumeration_params
# can't require enumeration on #new action
params.permit(:enumeration => [:name, :active, :is_default])[:enumeration]
cf_ids = @enumeration.available_custom_fields.map{|c| c.id.to_s}
params.permit(:enumeration => [:name, :active, :is_default, :position, :custom_field_values => cf_ids])[:enumeration]
end
end
......@@ -46,7 +46,13 @@ class IssueRelationsController < ApplicationController
@relation.issue_from = @issue
@relation.safe_attributes = params[:relation]
@relation.init_journals(User.current)
saved = @relation.save
begin
saved = @relation.save
rescue ActiveRecord::RecordNotUnique
saved = false
@relation.errors.add :base, :taken
end
respond_to do |format|
format.html { redirect_to issue_path(@issue) }
......
......@@ -40,7 +40,8 @@ class IssuesController < ApplicationController
helper :timelog
def index
retrieve_query
use_session = !request.format.csv?
retrieve_query(IssueQuery, use_session)
if @query.valid?
respond_to do |format|
......@@ -367,7 +368,12 @@ class IssuesController < ApplicationController
when 'destroy'
# nothing to do
when 'nullify'
if Setting.timelog_required_fields.include?('issue_id')
flash.now[:error] = l(:field_issue) + " " + ::I18n.t('activerecord.errors.messages.blank')
return
else
time_entries.update_all(:issue_id => nil)
end
when 'reassign'
reassign_to = @project && @project.issues.find_by_id(params[:reassign_to_id])
if reassign_to.nil?
......
......@@ -98,14 +98,4 @@ class NewsController < ApplicationController
@news.destroy
redirect_to project_news_index_path(@project)
end
private
def find_optional_project
return true unless params[:project_id]
@project = Project.find(params[:project_id])
authorize
rescue ActiveRecord::RecordNotFound
render_404
end
end
......@@ -37,7 +37,7 @@ class SearchController < ApplicationController
end
# quick jump to an issue
if (m = @question.match(/^#?(\d+)$/)) && (issue = Issue.visible.find_by_id(m[1].to_i))
if !api_request? && (m = @question.match(/^#?(\d+)$/)) && (issue = Issue.visible.find_by_id(m[1].to_i))
redirect_to issue_path(issue)
return
end
......@@ -49,7 +49,7 @@ class SearchController < ApplicationController
when 'my_projects'
User.current.projects
when 'subprojects'
@project ? (@project.self_and_descendants.active.to_a) : nil
@project ? (@project.self_and_descendants.to_a) : nil
else
@project
end
......
......@@ -67,7 +67,8 @@ class SettingsController < ApplicationController
end
if request.post?
Setting.send "plugin_#{@plugin.id}=", params[:settings].permit!.to_h
setting = params[:settings] ? params[:settings].permit!.to_h : {}
Setting.send "plugin_#{@plugin.id}=", setting
flash[:notice] = l(:notice_successful_update)
redirect_to plugin_settings_path(@plugin)
else
......
......@@ -114,6 +114,7 @@ class TimelogController < ApplicationController
:time_entry => {
:project_id => params[:time_entry][:project_id],
:issue_id => @time_entry.issue_id,
:spent_on => @time_entry.spent_on,
:activity_id => @time_entry.activity_id
},
:back_url => params[:back_url]
......
......@@ -106,6 +106,6 @@ class TrackersController < ApplicationController
return
end
@trackers = Tracker.sorted.to_a
@custom_fields = IssueCustomField.all.sort
@custom_fields = IssueCustomField.sorted
end
end
......@@ -32,7 +32,7 @@
class WikiController < ApplicationController
default_search_scope :wiki_pages
before_action :find_wiki, :authorize
before_action :find_existing_or_new_page, :only => [:show, :edit, :update]
before_action :find_existing_or_new_page, :only => [:show, :edit]
before_action :find_existing_page, :only => [:rename, :protect, :history, :diff, :annotate, :add_attachment, :destroy, :destroy_version]
before_action :find_attachments, :only => [:preview]
accept_api_auth :index, :show, :update, :destroy
......@@ -100,14 +100,14 @@ class WikiController < ApplicationController
if User.current.allowed_to?(:export_wiki_pages, @project)
if params[:format] == 'pdf'
send_file_headers! :type => 'application/pdf', :filename => "#{@page.title}.pdf"
send_file_headers! :type => 'application/pdf', :filename => filename_for_content_disposition("#{@page.title}.pdf")
return
elsif params[:format] == 'html'
export = render_to_string :action => 'export', :layout => false
send_data(export, :type => 'text/html', :filename => "#{@page.title}.html")
send_data(export, :type => 'text/html', :filename => filename_for_content_disposition("#{@page.title}.html"))
return
elsif params[:format] == 'txt'
send_data(@content.text, :type => 'text/plain', :filename => "#{@page.title}.txt")
send_data(@content.text, :type => 'text/plain', :filename => filename_for_content_disposition("#{@page.title}.txt"))
return
end
end
......@@ -150,6 +150,8 @@ class WikiController < ApplicationController
# Creates a new page or updates an existing one
def update
@page = @wiki.find_or_new_page(params[:id])
return render_403 unless editable?
was_new_page = @page.new_record?
@page.safe_attributes = params[:wiki_page]
......
......@@ -176,7 +176,8 @@ module ApplicationHelper
end
case object.class.name
when 'Array'
object.map {|o| format_object(o, html)}.join(', ').html_safe
formatted_objects = object.map {|o| format_object(o, html)}
html ? safe_join(formatted_objects, ', ') : formatted_objects.join(', ')
when 'Time'
format_time(object)
when 'Date'
......@@ -220,11 +221,12 @@ module ApplicationHelper
end
def thumbnail_tag(attachment)
thumbnail_size = Setting.thumbnails_size.to_i
link_to(
image_tag(
thumbnail_path(attachment),
:srcset => "#{thumbnail_path(attachment, :size => Setting.thumbnails_size.to_i * 2)} 2x",
:width => Setting.thumbnails_size
:srcset => "#{thumbnail_path(attachment, :size => thumbnail_size * 2)} 2x",
:style => "max-width: #{thumbnail_size}px; max-height: #{thumbnail_size}px;"
),
named_attachment_path(
attachment,
......@@ -650,7 +652,7 @@ module ApplicationHelper
if options[:formatting] == false
text = h(text)
else
formatting = options[:formatting] || Setting.text_formatting
formatting = Setting.text_formatting
text = Redmine::WikiFormatting.to_html(formatting, text, :object => obj, :attribute => attr)
end
......@@ -975,7 +977,7 @@ module ApplicationHelper
attachments = options[:attachments] || []
attachments += obj.attachments if obj.respond_to?(:attachments)
if attachments && attachment = Attachment.latest_attach(attachments, name)
link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
link = link_to_attachment(attachment, :only_path => only_path, :class => 'attachment')
end
when 'project'
if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
......@@ -1270,7 +1272,7 @@ module ApplicationHelper
link_to_function '',
"toggleCheckboxesBySelector('#{selector}')",
:title => "#{l(:button_check_all)} / #{l(:button_uncheck_all)}",
:class => 'toggle-checkboxes'
:class => 'icon icon-checked'
end
def progress_bar(pcts, options={})
......@@ -1419,7 +1421,16 @@ module ApplicationHelper
elsif user.to_s =~ %r{<(.+?)>}
email = $1
end
return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
if email.present?
gravatar(email.to_s.downcase, options) rescue nil
elsif user.is_a?(AnonymousUser)
options[:size] &&= options[:size].to_s
image_tag 'anonymous.png',
GravatarHelper::DEFAULT_OPTIONS
.except(:default, :rating, :ssl).merge(options)
else
nil
end
else
''
end
......
......@@ -127,8 +127,8 @@ module IssuesHelper
content_tag('td', check_box_tag("ids[]", other_issue.id, false, :id => nil), :class => 'checkbox') +
content_tag('td', relation.to_s(@issue) {|other| link_to_issue(other, :project => Setting.cross_project_issue_relations?)}.html_safe, :class => 'subject', :style => 'width: 50%') +
content_tag('td', other_issue.status, :class => 'status') +
content_tag('td', other_issue.start_date, :class => 'start_date') +
content_tag('td', other_issue.due_date, :class => 'due_date') +
content_tag('td', format_date(other_issue.start_date), :class => 'start_date') +
content_tag('td', format_date(other_issue.due_date), :class => 'due_date') +
content_tag('td', other_issue.disabled_core_fields.include?('done_ratio') ? '' : progress_bar(other_issue.done_ratio), :class=> 'done_ratio') +
content_tag('td', link, :class => 'buttons'),
:id => "relation-#{relation.id}",
......@@ -246,8 +246,12 @@ module IssuesHelper
issue_fields_rows do |rows|
values.each_with_index do |value, i|
css = "cf_#{value.custom_field.id}"
attr_value = show_value(value)
if value.custom_field.text_formatting == 'full'
attr_value = content_tag('div', attr_value, class: 'wiki')
end
m = (i < half ? :left : :right)
rows.send m, custom_field_name_tag(value.custom_field), show_value(value), :class => css
rows.send m, custom_field_name_tag(value.custom_field), attr_value, :class => css
end
end
end
......@@ -310,7 +314,7 @@ module IssuesHelper
# Returns an array of users that are proposed as watchers
# on the new issue form
def users_for_new_issue_watchers(issue)
users = issue.watcher_users
users = issue.watcher_users.select{|u| u.status == User::STATUS_ACTIVE}
if issue.project.users.count <= 20
users = (users + issue.project.users.sort).uniq
end
......
......@@ -198,7 +198,8 @@ module QueriesHelper
def column_content(column, item)
value = column.value_object(item)
if value.is_a?(Array)
value.collect {|v| column_value(column, item, v)}.compact.join(', ').html_safe
values = value.collect {|v| column_value(column, item, v)}.compact
safe_join(values, ', ')
else
column_value(column, item, value)
end
......
......@@ -138,7 +138,7 @@ module RepositoriesHelper
select_tag('repository_scm',
options_for_select(scm_options, repository.class.name.demodulize),
:disabled => (repository && !repository.new_record?),
:data => {:remote => true, :method => 'get'})
:data => {:remote => true, :method => 'get', :url => new_project_repository_path(repository.project)})
end
def with_leading_slash(path)
......
......@@ -156,7 +156,7 @@ class Attachment < ActiveRecord::Base
end
def title
title = filename.to_s
title = filename.dup
if description.present?
title << " (#{description})"
end
......
......@@ -38,12 +38,18 @@ class CustomValue < ActiveRecord::Base
custom_field.editable?
end
def visible?
custom_field.visible?
def visible?(user=User.current)
if custom_field.visible?
true
elsif customized.respond_to?(:project)
custom_field.visible_by?(customized.project, user)
else
false
end
end
def attachments_visible?(user)
visible? && customized && customized.visible?(user)
visible?(user) && customized && customized.visible?(user)
end
def required?
......
......@@ -217,6 +217,7 @@ class Import < ActiveRecord::Base
csv_options = {:headers => false}
csv_options[:encoding] = settings['encoding'].to_s.presence || 'UTF-8'
csv_options[:encoding] = 'bom|UTF-8' if csv_options[:encoding] == 'UTF-8'
separator = settings['separator'].to_s
csv_options[:col_sep] = separator if separator.size == 1
wrapper = settings['wrapper'].to_s
......
......@@ -116,8 +116,6 @@ class Issue < ActiveRecord::Base
after_save :after_create_from_copy
after_destroy :update_parent_attributes
after_create :send_notification
# Keep it at the end of after_save callbacks
after_save :clear_assigned_to_was
# Returns a SQL conditions string used to find all issues visible by the specified user
def self.visible_condition(user, options={})
......@@ -277,7 +275,8 @@ class Issue < ActiveRecord::Base
end
end
unless options[:watchers] == false
self.watcher_user_ids = issue.watcher_user_ids.dup
self.watcher_user_ids =
issue.watcher_users.select{|u| u.status == User::STATUS_ACTIVE}.map(&:id)
end
@copied_from = issue
@copy_options = options
......@@ -443,7 +442,7 @@ class Issue < ActiveRecord::Base
end
def estimated_hours=(h)
write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
write_attribute :estimated_hours, (h.is_a?(String) ? (h.to_hours || h) : h)
end
safe_attributes 'project_id',
......@@ -1302,7 +1301,7 @@ class Issue < ActiveRecord::Base
# Reschedules the issue on the given date or the next working day and saves the record.
# If the issue is a parent task, this is done by rescheduling its subtasks.
def reschedule_on!(date)
def reschedule_on!(date, journal=nil)
return if date.nil?
if leaf? || !dates_derived?
if start_date.nil? || start_date != date
......@@ -1310,6 +1309,9 @@ class Issue < ActiveRecord::Base
# Issue can not be moved earlier than its soonest start date
date = [soonest_start(true), date].compact.max
end
if journal
init_journal(journal.user)
end
reschedule_on(date)
begin
save
......@@ -1633,6 +1635,8 @@ class Issue < ActiveRecord::Base
copy.author = author
copy.project = project
copy.parent_issue_id = copied_issue_ids[child.parent_id]
copy.fixed_version_id = nil unless child.fixed_version.present? && child.fixed_version.status == 'open'
copy.assigned_to = nil unless child.assigned_to_id.present? && child.assigned_to.status == User::STATUS_ACTIVE
unless copy.save
logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
next
......@@ -1791,7 +1795,7 @@ class Issue < ActiveRecord::Base
def reschedule_following_issues
if start_date_changed? || due_date_changed?
relations_from.each do |relation|
relation.set_issue_to_dates
relation.set_issue_to_dates(@current_journal)
end
end
end
......
......@@ -122,7 +122,10 @@ class IssueImport < Import
end
end
if issue.project && version_name = row_value(row, 'fixed_version')
if version = issue.project.versions.named(version_name).first
version =
issue.project.versions.named(version_name).first ||
issue.project.shared_versions.named(version_name).first
if version
attributes['fixed_version_id'] = version.id
elsif create_versions?
version = issue.project.versions.build
......
......@@ -187,18 +187,29 @@ class IssueQuery < Query
@available_columns += issue_custom_fields.visible.collect {|cf| QueryCustomFieldColumn.new(cf) }
if User.current.allowed_to?(:view_time_entries, project, :global => true)
# insert the columns after total_estimated_hours or at the end
index = @available_columns.find_index {|column| column.name == :total_estimated_hours}
index = (index ? index + 1 : -1)
# insert the column after total_estimated_hours or at the end
subselect = "SELECT SUM(hours) FROM #{TimeEntry.table_name}" +
" JOIN #{Project.table_name} ON #{Project.table_name}.id = #{TimeEntry.table_name}.project_id" +
" WHERE (#{TimeEntry.visible_condition(User.current)}) AND #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id"
@available_columns.insert index, QueryColumn.new(:spent_hours,
:sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)",
:sortable => "COALESCE((#{subselect}), 0)",
:default_order => 'desc',
:caption => :label_spent_time,
:totalable => true
)
subselect = "SELECT SUM(hours) FROM #{TimeEntry.table_name}" +
" JOIN #{Project.table_name} ON #{Project.table_name}.id = #{TimeEntry.table_name}.project_id" +
" JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" +
" WHERE (#{TimeEntry.visible_condition(User.current)})" +
" AND subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt"
@available_columns.insert index+1, QueryColumn.new(:total_spent_hours,
:sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" +
" WHERE subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)",
:sortable => "COALESCE((#{subselect}), 0)",
:default_order => 'desc',
:caption => :label_total_spent_time
)
......@@ -251,15 +262,10 @@ class IssueQuery < Query
# Returns sum of all the issue's time entries hours
def total_for_spent_hours(scope)
total = if group_by_column.try(:name) == :project
# TODO: remove this when https://github.com/rails/rails/issues/21922 is fixed
# We have to do a custom join without the time_entries.project_id column
# that would trigger a ambiguous column name error
scope.joins("JOIN (SELECT issue_id, hours FROM #{TimeEntry.table_name}) AS joined_time_entries ON joined_time_entries.issue_id = #{Issue.table_name}.id").
sum("joined_time_entries.hours")
else
scope.joins(:time_entries).sum("#{TimeEntry.table_name}.hours")
end
total = scope.joins(:time_entries).
where(TimeEntry.visible_condition(User.current)).
sum("#{TimeEntry.table_name}.hours")
map_total(total) {|t| t.to_f.round(2)}
end
......@@ -367,7 +373,7 @@ class IssueQuery < Query
neg = (operator == '!' ? 'NOT' : '')
subquery = "SELECT 1 FROM #{Journal.table_name} sj" +
" WHERE sj.journalized_type='Issue' AND sj.journalized_id=#{Issue.table_name}.id AND (#{sql_for_field field, '=', value, 'sj', 'user_id'})" +
" AND sj.id = (SELECT MAX(#{Journal.table_name}.id) FROM #{Journal.table_name}" +
" AND sj.id IN (SELECT MAX(#{Journal.table_name}.id) FROM #{Journal.table_name}" +
" WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id" +
" AND (#{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)}))"
......@@ -376,8 +382,26 @@ class IssueQuery < Query
def sql_for_watcher_id_field(field, operator, value)
db_table = Watcher.table_name
"#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
sql_for_field(field, '=', value, db_table, 'user_id') + ')'
me, others = value.partition { |id| ['0', User.current.id.to_s].include?(id) }
sql = if others.any?
"SELECT #{Issue.table_name}.id FROM #{Issue.table_name} " +
"INNER JOIN #{db_table} ON #{Issue.table_name}.id = #{db_table}.watchable_id AND #{db_table}.watchable_type = 'Issue' " +
"LEFT OUTER JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Issue.table_name}.project_id " +
"WHERE (" +
sql_for_field(field, '=', me, db_table, 'user_id') +
') OR (' +
Project.allowed_to_condition(User.current, :view_issue_watchers) +
' AND ' +
sql_for_field(field, '=', others, db_table, 'user_id') +
')'
else
"SELECT #{db_table}.watchable_id FROM #{db_table} " +
"WHERE #{db_table}.watchable_type='Issue' AND " +
sql_for_field(field, '=', me, db_table, 'user_id')
end
"#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (#{sql})"
end
def sql_for_member_of_group_field(field, operator, value)
......
......@@ -176,10 +176,10 @@ class IssueRelation < ActiveRecord::Base
set_issue_to_dates
end
def set_issue_to_dates
def set_issue_to_dates(journal=nil)
soonest_start = self.successor_soonest_start
if soonest_start && issue_to
issue_to.reschedule_on!(soonest_start)
issue_to.reschedule_on!(soonest_start, journal)
end
end
......@@ -204,13 +204,19 @@ class IssueRelation < ActiveRecord::Base
# Reverses the relation if needed so that it gets stored in the proper way
# Should not be reversed before validation so that it can be displayed back
# as entered on new relation form
# as entered on new relation form.
#
# Orders relates relations by ID, so that uniqueness index in DB is triggered
# on concurrent access.
def reverse_if_needed
if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
issue_tmp = issue_to
self.issue_to = issue_from
self.issue_from = issue_tmp
self.relation_type = TYPES[relation_type][:reverse]
elsif relation_type == TYPE_RELATES && issue_from_id > issue_to_id
self.issue_to, self.issue_from = issue_from, issue_to
end
end
......@@ -225,6 +231,8 @@ class IssueRelation < ActiveRecord::Base
issue_from.blocks? issue_to
when 'blocks'
issue_to.blocks? issue_from
when 'relates'
self.class.where(issue_from_id: issue_to, issue_to_id: issue_from).present?
else
false
end
......
......@@ -54,7 +54,7 @@ class MailHandler < ActionMailer::Base
def self.safe_receive(*args)
receive(*args)
rescue Exception => e
logger.error "MailHandler: an unexpected error occurred when receiving email: #{e.message}" if logger
Rails.logger.error "MailHandler: an unexpected error occurred when receiving email: #{e.message}"
return false
end
......@@ -65,7 +65,7 @@ class MailHandler < ActionMailer::Base
%w(project status tracker category priority assigned_to fixed_version).each do |option|
options[:issue][option.to_sym] = env[option] if env[option]
end
%w(allow_override unknown_user no_permission_check no_account_notice default_group project_from_subaddress).each do |option|
%w(allow_override unknown_user no_permission_check no_account_notice no_notification default_group project_from_subaddress).each do |option|
options[option.to_sym] = env[option] if env[option]
end
if env['private']
......@@ -250,8 +250,8 @@ class MailHandler < ActionMailer::Base
# add To and Cc as watchers before saving so the watchers can reply to Redmine
add_watchers(issue)
add_attachments(issue)
issue.save!
add_attachments(issue)
if logger
logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
end
......@@ -286,7 +286,7 @@ class MailHandler < ActionMailer::Base
reply
else
if logger
logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
logger.info "MailHandler: ignoring reply from [#{email.from.first}] to a locked topic"
end
end
end
......@@ -296,6 +296,7 @@ class MailHandler < ActionMailer::Base
if email.attachments && email.attachments.any?
email.attachments.each do |attachment|
next unless accept_attachment?(attachment)
next unless attachment.body.decoded.size > 0
obj.attachments << Attachment.create(:container => obj,
:file => attachment.body.decoded,
:filename => attachment.filename,
......
......@@ -311,7 +311,7 @@ class Mailer < ActionMailer::Base
end
# Notifies user that his password was updated
def self.password_updated(user)
def self.password_updated(user, options={})
# Don't send a notification to the dummy email address when changing the password
# of the default admin account which is required after the first login
# TODO: maybe not the best way to handle this
......@@ -320,6 +320,8 @@ class Mailer < ActionMailer::Base
security_notification(user,
message: :mail_body_password_updated,
title: :button_change_password,
remote_ip: options[:remote_ip],
originator: user,
url: {controller: 'my', action: 'password'}
).deliver
end
......@@ -333,7 +335,6 @@ class Mailer < ActionMailer::Base
end
def security_notification(recipients, options={})
redmine_headers 'Sender' => User.current.login
@user = Array(recipients).detect{|r| r.is_a? User }
set_language_if_valid(@user.try :language)
@message = l(options[:message],
......@@ -341,7 +342,11 @@ class Mailer < ActionMailer::Base
value: options[:value]
)
@title = options[:title] && l(options[:title])
@originator = options[:originator] || User.current
@remote_ip = options[:remote_ip] || @originator.remote_ip
@url = options[:url] && (options[:url].is_a?(Hash) ? url_for(options[:url]) : options[:url])
redmine_headers 'Sender' => @originator.login
redmine_headers 'Url' => @url
mail :to => recipients,
:subject => "[#{Setting.app_title}] #{l(:mail_subject_security_notification)}"
end
......
......@@ -319,9 +319,10 @@ class Query < ActiveRecord::Base
" INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
" INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
" INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
" INNER JOIN #{Project.table_name} p ON p.id = m.project_id AND p.status <> ?" +
" WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
" OR #{table_name}.user_id = ?",
VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, user.id)
VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, Project::STATUS_ARCHIVED, user.id)
elsif user.logged?
scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
else
......@@ -340,7 +341,7 @@ class Query < ActiveRecord::Base
if project
(user.roles_for_project(project) & roles).any?
else
Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
user.memberships.joins(:member_roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
end
else
user == self.user
......@@ -398,6 +399,8 @@ class Query < ActiveRecord::Base
params[:v][field] = options[:values]
end
params[:c] = column_names
params[:group_by] = group_by.to_s if group_by.present?
params[:t] = totalable_names.map(&:to_s) if totalable_names.any?
params[:sort] = sort_criteria.to_param
params[:set_filter] = 1
params
......
......@@ -23,7 +23,7 @@ class TimeEntryQuery < Query
self.available_columns = [
QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
QueryColumn.new(:tweek, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :caption => l(:label_week)),
QueryColumn.new(:tweek, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :caption => :label_week),
QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
......@@ -35,8 +35,7 @@ class TimeEntryQuery < Query
def initialize(attributes=nil, *args)
super attributes
self.filters ||= {}
add_filter('spent_on', '*') unless filters.present?
self.filters ||= { 'spent_on' => {:operator => "*", :values => []} }
end
def initialize_available_filters
......@@ -64,7 +63,7 @@ class TimeEntryQuery < Query
add_available_filter("issue.fixed_version_id",
:type => :list,
:name => l("label_attribute_of_issue", :name => l(:field_fixed_version)),
:values => lambda { fixed_version_values }) if project
:values => lambda { fixed_version_values })
add_available_filter("user_id",
:type => :list_optional, :values => lambda { author_values }
......@@ -112,9 +111,18 @@ class TimeEntryQuery < Query
[['spent_on', 'desc']]
end
# If a filter against a single issue is set, returns its id, otherwise nil.
def filtered_issue_id
if value_for('issue_id').to_s =~ /\A(\d+)\z/
$1
end
end
def base_scope
TimeEntry.visible.
joins(:project, :user).
includes(:activity).
references(:activity).
left_join_issue.
where(statement)
end
......@@ -124,9 +132,7 @@ class TimeEntryQuery < Query
base_scope.
order(order_option).
joins(joins_for_order_statement(order_option.join(','))).
includes(:activity).
references(:activity)
joins(joins_for_order_statement(order_option.join(',')))
end
# Returns sum of all the spent hours
......@@ -153,7 +159,7 @@ class TimeEntryQuery < Query
end
def sql_for_issue_fixed_version_id_field(field, operator, value)
issue_ids = Issue.where(:fixed_version_id => value.first.to_i).pluck(:id)
issue_ids = Issue.where(:fixed_version_id => value.map(&:to_i)).pluck(:id)
case operator
when "="
if issue_ids.any?
......
......@@ -219,7 +219,7 @@ class User < Principal
# Returns the user that matches provided login and password, or nil
def self.try_to_login(login, password, active_only=true)
login = login.to_s
login = login.to_s.strip
password = password.to_s
# Make sure no one can sign in with an empty login or password
......@@ -603,36 +603,40 @@ class User < Principal
# Includes the projects that the user is a member of and the projects
# that grant custom permissions to the builtin groups.
def project_ids_by_role
return @project_ids_by_role if @project_ids_by_role
group_class = anonymous? ? GroupAnonymous : GroupNonMember
group_id = group_class.pluck(:id).first
members = Member.joins(:project, :member_roles).
where("#{Project.table_name}.status <> 9").
where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Member.table_name}.user_id = ?)", self.id, true, group_id).
pluck(:user_id, :role_id, :project_id)
hash = {}
members.each do |user_id, role_id, project_id|
# Ignore the roles of the builtin group if the user is a member of the project
next if user_id != id && project_ids.include?(project_id)
hash[role_id] ||= []
hash[role_id] << project_id
end
result = Hash.new([])
if hash.present?
roles = Role.where(:id => hash.keys).to_a
hash.each do |role_id, proj_ids|
role = roles.detect {|r| r.id == role_id}
if role
result[role] = proj_ids.uniq
# Clear project condition for when called from chained scopes
# eg. project.children.visible(user)
Project.unscoped do
return @project_ids_by_role if @project_ids_by_role
group_class = anonymous? ? GroupAnonymous : GroupNonMember
group_id = group_class.pluck(:id).first
members = Member.joins(:project, :member_roles).
where("#{Project.table_name}.status <> 9").
where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Member.table_name}.user_id = ?)", self.id, true, group_id).
pluck(:user_id, :role_id, :project_id)
hash = {}
members.each do |user_id, role_id, project_id|
# Ignore the roles of the builtin group if the user is a member of the project
next if user_id != id && project_ids.include?(project_id)
hash[role_id] ||= []
hash[role_id] << project_id
end
result = Hash.new([])
if hash.present?
roles = Role.where(:id => hash.keys).to_a
hash.each do |role_id, proj_ids|
role = roles.detect {|r| r.id == role_id}
if role
result[role] = proj_ids.uniq
end
end
end
@project_ids_by_role = result
end
@project_ids_by_role = result
end
# Returns the ids of visible projects
......
......@@ -45,7 +45,7 @@ class Version < ActiveRecord::Base
scope :like, lambda {|arg|
if arg.present?
pattern = "%#{arg.to_s.strip}%"
where("LOWER(#{Version.table_name}.name) LIKE :p", :p => pattern)
where([Redmine::Database.like("#{Version.table_name}.name", '?'), pattern])
end
}
scope :open, lambda { where(:status => 'open') }
......@@ -268,7 +268,7 @@ class Version < ActiveRecord::Base
end
def deletable?
fixed_issues.empty? && !referenced_by_a_custom_field?
fixed_issues.empty? && !referenced_by_a_custom_field? && attachments.empty?
end
def default_project_version
......
......@@ -11,7 +11,7 @@
<% end %>
</table>
<br />
<div class="box">
<div class="box autoscroll">
<pre><%= Redmine::Info.environment %></pre>
</div>
......
<%= title l(:label_plugins) %>
<% if @plugins.any? %>
<table class="list plugins">
<% @plugins.each do |plugin| %>
<tr id="plugin-<%= plugin.id %>">
<td class="name"><span class="name"><%= plugin.name %></span>
<%= content_tag('span', plugin.description, :class => 'description') unless plugin.description.blank? %>
<%= content_tag('span', link_to(plugin.url, plugin.url), :class => 'url') unless plugin.url.blank? %>
</td>
<td class="author"><%= plugin.author_url.blank? ? plugin.author : link_to(plugin.author, plugin.author_url) %></td>
<td class="version"><span class="icon"><%= plugin.version %></span></td>
<td class="configure"><%= link_to(l(:button_configure), plugin_settings_path(plugin)) if plugin.configurable? %></td>
</tr>
<% end %>
</table>
<div class="autoscroll">
<table class="list plugins">
<% @plugins.each do |plugin| %>
<tr id="plugin-<%= plugin.id %>">
<td class="name"><span class="name"><%= plugin.name %></span>
<%= content_tag('span', plugin.description, :class => 'description') unless plugin.description.blank? %>
<%= content_tag('span', link_to(plugin.url, plugin.url), :class => 'url') unless plugin.url.blank? %>
</td>
<td class="author"><%= plugin.author_url.blank? ? plugin.author : link_to(plugin.author, plugin.author_url) %></td>
<td class="version"><span class="icon"><%= plugin.version %></span></td>
<td class="configure"><%= link_to(l(:button_configure), plugin_settings_path(plugin)) if plugin.configurable? %></td>
</tr>
<% end %>
</table>
</div>
<p><a href="#" id="check-for-updates"><%= l(:label_check_for_updates) %></a></p>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
......@@ -27,7 +29,7 @@ $(document).ready(function(){
dataType: "jsonp",
url: "https://www.redmine.org/plugins/check_updates",
data: <%= raw_json plugin_data_for_updates(@plugins) %>,
timeout: 3000,
timeout: 10000,
beforeSend: function(){
$('#ajax-indicator').show();
},
......
......@@ -16,7 +16,7 @@
<%= hidden_field_tag "#{attachment_param}[p#{i}][id]", attachment.id %>
<% else %>
<%= text_field_tag("#{attachment_param}[p#{i}][description]", attachment.description, :maxlength => 255, :placeholder => l(:label_optional_description), :class => 'description') if description %>
<%= link_to('&nbsp;'.html_safe, attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'remove-upload') %>
<%= link_to('&nbsp;'.html_safe, attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'icon-only icon-del remove-upload') %>
<%= hidden_field_tag "#{attachment_param}[p#{i}][token]", attachment.token %>
<% end %>
</span>
......
......@@ -39,7 +39,7 @@
<ul>
<% @priorities.each do |p| -%>
<li><%= context_menu_link p.name, bulk_update_issues_path(:ids => @issue_ids, :issue => {'priority_id' => p}, :back_url => @back), :method => :post,
:selected => (@issue && p == @issue.priority), :disabled => (!@can[:edit] || @issues.detect {|i| !i.leaf?}) %></li>
:selected => (@issue && p == @issue.priority), :disabled => (!@can[:edit] || @issues.any?(&:priority_derived?)) %></li>
<% end -%>
</ul>
</li>
......@@ -97,7 +97,7 @@
<ul>
<% (0..10).map{|x|x*10}.each do |p| -%>
<li><%= context_menu_link "#{p}%", bulk_update_issues_path(:ids => @issue_ids, :issue => {'done_ratio' => p}, :back_url => @back), :method => :post,
:selected => (@issue && p == @issue.done_ratio), :disabled => (!@can[:edit] || @issues.detect {|i| !i.leaf?}) %></li>
:selected => (@issue && p == @issue.done_ratio), :disabled => (!@can[:edit] || @issues.any?(&:done_ratio_derived?)) %></li>
<% end -%>
</ul>
</li>
......
......@@ -6,41 +6,43 @@
<% delete_allowed = User.current.allowed_to?(:manage_files, @project) %>
<table class="list files">
<thead><tr>
<%= sort_header_tag('filename', :caption => l(:field_filename)) %>
<%= sort_header_tag('created_on', :caption => l(:label_date), :default_order => 'desc') %>
<%= sort_header_tag('size', :caption => l(:field_filesize), :default_order => 'desc') %>
<%= sort_header_tag('downloads', :caption => l(:label_downloads_abbr), :default_order => 'desc') %>
<th><%= l(:field_digest) %></th>
<th></th>
</tr></thead>
<tbody>
<% @containers.each do |container| %>
<% next if container.attachments.empty? -%>
<% if container.is_a?(Version) -%>
<tr>
<th colspan="6">
<%= link_to(container, {:controller => 'versions', :action => 'show', :id => container}, :class => "icon icon-package") %>
</th>
</tr>
<% end -%>
<% container.attachments.each do |file| %>
<tr class="file">
<td class="filename"><%= link_to_attachment file, :title => file.description -%></td>
<td class="created_on"><%= format_time(file.created_on) %></td>
<td class="filesize"><%= number_to_human_size(file.filesize) %></td>
<td class="downloads"><%= file.downloads %></td>
<td class="digest"><%= file.digest_type %>: <%= file.digest %></td>
<td class="buttons">
<%= link_to_attachment file, class: 'icon-only icon-download', title: l(:button_download), download: true %>
<%= link_to(l(:button_delete), attachment_path(file), :class => 'icon-only icon-del',
:data => {:confirm => l(:text_are_you_sure)}, :method => :delete) if delete_allowed %>
</td>
</tr>
<div class="autoscroll">
<table class="list files">
<thead><tr>
<%= sort_header_tag('filename', :caption => l(:field_filename)) %>
<%= sort_header_tag('created_on', :caption => l(:label_date), :default_order => 'desc') %>
<%= sort_header_tag('size', :caption => l(:field_filesize), :default_order => 'desc') %>
<%= sort_header_tag('downloads', :caption => l(:label_downloads_abbr), :default_order => 'desc') %>
<th><%= l(:field_digest) %></th>
<th></th>
</tr></thead>
<tbody>
<% @containers.each do |container| %>
<% next if container.attachments.empty? -%>
<% if container.is_a?(Version) -%>
<tr>
<th colspan="6">
<%= link_to(container, {:controller => 'versions', :action => 'show', :id => container}, :class => "icon icon-package") %>
</th>
</tr>
<% end -%>
<% container.attachments.each do |file| %>
<tr class="file">
<td class="filename"><%= link_to_attachment file, :title => file.description -%></td>
<td class="created_on"><%= format_time(file.created_on) %></td>
<td class="filesize"><%= number_to_human_size(file.filesize) %></td>
<td class="downloads"><%= file.downloads %></td>
<td class="digest"><%= file.digest_type %>: <%= file.digest %></td>
<td class="buttons">
<%= link_to_attachment file, class: 'icon-only icon-download', title: l(:button_download), download: true %>
<%= link_to(l(:button_delete), attachment_path(file), :class => 'icon-only icon-del',
:data => {:confirm => l(:text_are_you_sure)}, :method => :delete) if delete_allowed %>
</td>
</tr>
<% end %>
<% end %>
<% end %>
</tbody>
</table>
</tbody>
</table>
</div>
<% html_title(l(:label_attachment_plural)) -%>
......@@ -2,6 +2,6 @@ $('#relations').html('<%= escape_javascript(render :partial => 'issues/relations
<% if @relation.errors.empty? %>
$('#relation_delay').val('');
$('#relation_issue_to_id').val('');
$('#relation_issue_to_id').focus();
<% end %>
$('#new-relation-form').show();
$('#relation_issue_to_id').focus();
......@@ -14,7 +14,7 @@
:onchange => "updateIssueFrom('#{escape_javascript update_issue_form_path(@project, @issue)}', this)" %></p>
<% end %>
<% if @issue.safe_attribute? 'tracker_id' %>
<% if @issue.safe_attribute?('tracker_id') || (@issue.persisted? && @issue.tracker_id_changed?) %>
<p><%= f.select :tracker_id, trackers_options_for_select(@issue), {:required => true},
:onchange => "updateIssueFrom('#{escape_javascript update_issue_form_path(@project, @issue)}', this)" %></p>
<% end %>
......
......@@ -31,7 +31,9 @@
<% end %>
<tr id="issue-<%= issue.id %>" class="hascontextmenu <%= cycle('odd', 'even') %> <%= issue.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>">
<td class="checkbox hide-when-print"><%= check_box_tag("ids[]", issue.id, false, :id => nil) %></td>
<%= raw query.inline_columns.map {|column| "<td class=\"#{column.css_classes}\">#{column_content(column, issue)}</td>"}.join %>
<% query.inline_columns.each do |column| %>
<%= content_tag('td', column_content(column, issue), :class => column.css_classes) %>
<% end %>
</tr>
<% query.block_columns.each do |column|
if (text = column_content(column, issue)) && text.present? -%>
......
<% if @issue.safe_attribute? 'watcher_user_ids' -%>
<%= hidden_field_tag 'issue[watcher_user_ids][]', '' %>
<p id="watchers_form"><label><%= l(:label_issue_watchers) %></label>
<span id="watchers_inputs">
<%= watchers_checkboxes(@issue, users_for_new_issue_watchers(@issue)) %>
......
......@@ -6,7 +6,9 @@
<p><strong><%= l(:text_destroy_time_entries_question, :hours => number_with_precision(@hours, :precision => 2)) %></strong></p>
<p>
<label><%= radio_button_tag 'todo', 'destroy', true %> <%= l(:text_destroy_time_entries) %></label><br />
<% unless Setting.timelog_required_fields.include?('issue_id') %>
<label><%= radio_button_tag 'todo', 'nullify', false %> <%= l(:text_assign_time_entries_to_project) %></label><br />
<% end %>
<% if @project %>
<label><%= radio_button_tag 'todo', 'reassign', false, :onchange => 'if (this.checked) { $("#reassign_to_id").focus(); }' %> <%= l(:text_reassign_time_entries) %></label>
<%= text_field_tag 'reassign_to_id', params[:reassign_to_id], :size => 6, :onfocus => '$("#todo_reassign").attr("checked", true);' %>
......