Actualizar plugin Additionals a 3.0.0
This commit is contained in:
parent
3d976f1b3b
commit
a26f5567af
399 changed files with 70374 additions and 4093 deletions
|
@ -1,88 +1,99 @@
|
|||
module Additionals
|
||||
MAX_CUSTOM_MENU_ITEMS = 5
|
||||
SELECT2_INIT_ENTRIES = 20
|
||||
|
||||
DEFAULT_MODAL_WIDTH = '350px'.freeze
|
||||
GOTO_LIST = " \xc2\xbb".freeze
|
||||
LIST_SEPARATOR = GOTO_LIST + ' '
|
||||
LIST_SEPARATOR = "#{GOTO_LIST} ".freeze
|
||||
|
||||
RenderAsync.configuration.jquery = true
|
||||
|
||||
class << self
|
||||
def setup
|
||||
incompatible_plugins(%w[redmine_tweaks
|
||||
redmine_issue_control_panel
|
||||
incompatible_plugins %w[redmine_issue_control_panel
|
||||
redmine_editauthor
|
||||
redmine_changeauthor
|
||||
redmine_auto_watch])
|
||||
patch(%w[AccountController
|
||||
redmine_auto_watch]
|
||||
|
||||
patch %w[AccountController
|
||||
ApplicationController
|
||||
AutoCompletesController
|
||||
Issue
|
||||
IssuePriority
|
||||
TimeEntry
|
||||
Project
|
||||
Wiki
|
||||
WikiController
|
||||
ProjectsController
|
||||
WelcomeController
|
||||
ReportsController
|
||||
Principal
|
||||
QueryFilter
|
||||
Role
|
||||
User
|
||||
UserPreference])
|
||||
UserPreference]
|
||||
|
||||
Rails.configuration.assets.paths << Emoji.images_path
|
||||
Redmine::WikiFormatting.format_names.each do |format|
|
||||
case format
|
||||
when 'markdown'
|
||||
Redmine::WikiFormatting::Markdown::HTML.send(:include, Patches::FormatterMarkdownPatch)
|
||||
Redmine::WikiFormatting::Markdown::Helper.send(:include, Patches::FormattingHelperPatch)
|
||||
Redmine::WikiFormatting::Markdown::HTML.include Patches::FormatterMarkdownPatch
|
||||
Redmine::WikiFormatting::Markdown::Helper.include Patches::FormattingHelperPatch
|
||||
when 'textile'
|
||||
Redmine::WikiFormatting::Textile::Formatter.send(:include, Patches::FormatterTextilePatch)
|
||||
Redmine::WikiFormatting::Textile::Helper.send(:include, Patches::FormattingHelperPatch)
|
||||
Redmine::WikiFormatting::Textile::Formatter.include Patches::FormatterTextilePatch
|
||||
Redmine::WikiFormatting::Textile::Helper.include Patches::FormattingHelperPatch
|
||||
end
|
||||
end
|
||||
|
||||
IssuesController.send :helper, AdditionalsIssuesHelper
|
||||
SettingsController.send :helper, AdditionalsSettingsHelper
|
||||
WikiController.send :helper, AdditionalsWikiPdfHelper
|
||||
CustomFieldsController.send :helper, AdditionalsCustomFieldsHelper
|
||||
|
||||
# Static class patches
|
||||
IssuesController.send(:helper, AdditionalsIssuesHelper)
|
||||
WikiController.send(:helper, AdditionalsWikiPdfHelper)
|
||||
Redmine::AccessControl.send(:include, Additionals::Patches::AccessControlPatch)
|
||||
Redmine::AccessControl.include Additionals::Patches::AccessControlPatch
|
||||
|
||||
# Global helpers
|
||||
ActionView::Base.send :include, Additionals::Helpers
|
||||
ActionView::Base.send :include, AdditionalsFontawesomeHelper
|
||||
ActionView::Base.send :include, AdditionalsMenuHelper
|
||||
ActionView::Base.include Additionals::Helpers
|
||||
ActionView::Base.include AdditionalsFontawesomeHelper
|
||||
ActionView::Base.include AdditionalsMenuHelper
|
||||
ActionView::Base.include Additionals::AdditionalsSelect2Helper
|
||||
|
||||
# Hooks
|
||||
require_dependency 'additionals/hooks'
|
||||
|
||||
# Macros
|
||||
load_macros(%w[calendar cryptocompare date fa gist gmap group_users iframe
|
||||
load_macros %w[cryptocompare date fa gist gmap google_docs group_users iframe
|
||||
issue redmine_issue redmine_wiki
|
||||
last_updated_at last_updated_by meteoblue member new_issue project
|
||||
recently_updated reddit slideshare tradingview twitter user vimeo youtube])
|
||||
end
|
||||
|
||||
def settings
|
||||
settings_compatible(:plugin_additionals)
|
||||
recently_updated reddit slideshare tradingview twitter user vimeo youtube asciinema]
|
||||
end
|
||||
|
||||
def settings_compatible(plugin_name)
|
||||
if Setting[plugin_name].class == Hash
|
||||
if Rails.version >= '5.2'
|
||||
# convert Rails 4 data (this runs only once)
|
||||
new_settings = ActiveSupport::HashWithIndifferentAccess.new(Setting[plugin_name])
|
||||
Setting.send("#{plugin_name}=", new_settings)
|
||||
new_settings
|
||||
else
|
||||
ActionController::Parameters.new(Setting[plugin_name])
|
||||
end
|
||||
# convert Rails 4 data (this runs only once)
|
||||
new_settings = ActiveSupport::HashWithIndifferentAccess.new(Setting[plugin_name])
|
||||
Setting.send("#{plugin_name}=", new_settings)
|
||||
new_settings
|
||||
else
|
||||
# Rails 5 uses ActiveSupport::HashWithIndifferentAccess
|
||||
Setting[plugin_name]
|
||||
end
|
||||
end
|
||||
|
||||
# support with default setting as fall back
|
||||
def setting(value)
|
||||
if settings.key? value
|
||||
settings[value]
|
||||
else
|
||||
load_settings[value]
|
||||
end
|
||||
end
|
||||
|
||||
def setting?(value)
|
||||
true?(settings[value])
|
||||
true? setting(value)
|
||||
end
|
||||
|
||||
def true?(value)
|
||||
return true if value.to_i == 1 || value.to_s.casecmp('true').zero?
|
||||
return false if value.is_a? FalseClass
|
||||
return true if value.is_a?(TrueClass) || value.to_i == 1 || value.to_s.casecmp('true').zero?
|
||||
|
||||
false
|
||||
end
|
||||
|
@ -103,29 +114,49 @@ module Additionals
|
|||
|
||||
def patch(patches = [], plugin_id = 'additionals')
|
||||
patches.each do |name|
|
||||
patch_dir = Rails.root.join('plugins', plugin_id, 'lib', plugin_id, 'patches')
|
||||
patch_dir = Rails.root.join("plugins/#{plugin_id}/lib/#{plugin_id}/patches")
|
||||
require "#{patch_dir}/#{name.underscore}_patch"
|
||||
|
||||
target = name.constantize
|
||||
patch = "#{plugin_id.camelize}::Patches::#{name}Patch".constantize
|
||||
|
||||
target.send(:include, patch) unless target.included_modules.include?(patch)
|
||||
target.include(patch) unless target.included_modules.include?(patch)
|
||||
end
|
||||
end
|
||||
|
||||
def load_macros(macros = [], plugin_id = 'additionals')
|
||||
macro_dir = Rails.root.join('plugins', plugin_id, 'lib', plugin_id, 'wiki_macros')
|
||||
macro_dir = Rails.root.join("plugins/#{plugin_id}/lib/#{plugin_id}/wiki_macros")
|
||||
macros.each do |macro|
|
||||
require_dependency "#{macro_dir}/#{macro.underscore}_macro"
|
||||
end
|
||||
end
|
||||
|
||||
def load_settings(plugin_id = 'additionals')
|
||||
data = YAML.safe_load(ERB.new(IO.read(Rails.root.join('plugins',
|
||||
plugin_id,
|
||||
'config',
|
||||
'settings.yml'))).result) || {}
|
||||
data.symbolize_keys
|
||||
cached_settings_name = "@load_settings_#{plugin_id}"
|
||||
cached_settings = instance_variable_get cached_settings_name
|
||||
if cached_settings.nil?
|
||||
data = YAML.safe_load(ERB.new(IO.read(Rails.root.join("plugins/#{plugin_id}/config/settings.yml"))).result) || {}
|
||||
instance_variable_set cached_settings_name, data.symbolize_keys
|
||||
else
|
||||
cached_settings
|
||||
end
|
||||
end
|
||||
|
||||
def hash_remove_with_default(field, options, default = nil)
|
||||
value = nil
|
||||
if options.key? field
|
||||
value = options[field]
|
||||
options.delete field
|
||||
elsif !default.nil?
|
||||
value = default
|
||||
end
|
||||
[value, options]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def settings
|
||||
settings_compatible :plugin_additionals
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
16
plugins/additionals/lib/additionals/entity_methods.rb
Normal file
16
plugins/additionals/lib/additionals/entity_methods.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
module Additionals
|
||||
module EntityMethods
|
||||
def assignable_users(prj = nil)
|
||||
prj = project if project.present?
|
||||
users = prj.assignable_users_and_groups.to_a
|
||||
users << author if author&.active?
|
||||
if assigned_to_id_was.present?
|
||||
assignee = Principal.find_by(id: assigned_to_id_was)
|
||||
users << assignee if assignee
|
||||
end
|
||||
|
||||
users.uniq!
|
||||
users.sort
|
||||
end
|
||||
end
|
||||
end
|
|
@ -39,10 +39,8 @@ module Additionals
|
|||
esc = Regexp.last_match(2)
|
||||
smiley = Regexp.last_match(3)
|
||||
if esc.nil?
|
||||
leading + content_tag(:span,
|
||||
'',
|
||||
class: "additionals smiley smiley-#{name}",
|
||||
title: smiley)
|
||||
leading + tag.span(class: "additionals smiley smiley-#{name}",
|
||||
title: smiley)
|
||||
else
|
||||
leading + smiley
|
||||
end
|
||||
|
@ -55,12 +53,11 @@ module Additionals
|
|||
emoji_code = Regexp.last_match(1)
|
||||
emoji = Emoji.find_by_alias(emoji_code) # rubocop:disable Rails/DynamicFindBy
|
||||
if emoji.present?
|
||||
tag(:img,
|
||||
src: inline_emojify_image_path(emoji.image_filename),
|
||||
title: ":#{emoji_code}:",
|
||||
style: 'vertical-align: middle',
|
||||
width: '20',
|
||||
height: '20')
|
||||
tag.img src: inline_emojify_image_path(emoji.image_filename),
|
||||
title: ":#{emoji_code}:",
|
||||
style: 'vertical-align: middle',
|
||||
width: '20',
|
||||
height: '20'
|
||||
else
|
||||
match
|
||||
end
|
||||
|
@ -69,7 +66,7 @@ module Additionals
|
|||
end
|
||||
|
||||
def inline_emojify_image_path(image_filename)
|
||||
path = Setting.protocol + '://' + Setting.host_name
|
||||
path = "#{Setting.protocol}://#{Setting.host_name}"
|
||||
# TODO: use relative path, if not for mailer
|
||||
# path = '/' + Rails.public_path.relative_path_from Rails.root.join('public')
|
||||
"#{path}/images/emoji/" + image_filename
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
# Global helper functions
|
||||
module Additionals
|
||||
module Helpers
|
||||
def additionals_list_title(options)
|
||||
|
@ -8,11 +7,11 @@ module Additionals
|
|||
issue_path(options[:issue]),
|
||||
class: options[:issue].css_classes)
|
||||
elsif options[:user]
|
||||
title << avatar(options[:user], size: 50) + ' ' + options[:user].name
|
||||
title << safe_join([avatar(options[:user], size: 50), options[:user].name], ' ')
|
||||
end
|
||||
title << options[:name] if options[:name]
|
||||
title << h(options[:query].name) if options[:query] && !options[:query].new_record?
|
||||
safe_join(title, Additionals::LIST_SEPARATOR)
|
||||
safe_join title, Additionals::LIST_SEPARATOR
|
||||
end
|
||||
|
||||
def additionals_title_for_locale(title, lang)
|
||||
|
@ -29,44 +28,34 @@ module Additionals
|
|||
|
||||
def additionals_i18n_title(options, title)
|
||||
i18n_title = "#{title}_#{::I18n.locale}".to_sym
|
||||
if options.key?(i18n_title)
|
||||
if options.key? i18n_title
|
||||
options[i18n_title]
|
||||
elsif options.key?(title)
|
||||
elsif options.key? title
|
||||
options[title]
|
||||
end
|
||||
end
|
||||
|
||||
def additionals_settings_tabs
|
||||
tabs = [{ name: 'general', partial: 'additionals/settings/general', label: :label_general },
|
||||
{ name: 'content', partial: 'additionals/settings/overview', label: :label_overview_page },
|
||||
{ name: 'wiki', partial: 'additionals/settings/wiki', label: :label_wiki },
|
||||
{ name: 'macros', partial: 'additionals/settings/macros', label: :label_macro_plural },
|
||||
{ name: 'rules', partial: 'additionals/settings/issues', label: :label_issue_plural },
|
||||
{ name: 'projects', partial: 'additionals/settings/projects', label: :label_project_plural },
|
||||
{ name: 'users', partial: 'additionals/settings/users', label: :label_user_plural },
|
||||
{ name: 'web', partial: 'additionals/settings/web_apis', label: :label_web_apis }]
|
||||
|
||||
if User.current.try(:hrm_user_type_id).nil?
|
||||
tabs << { name: 'menu', partial: 'additionals/settings/menu', label: :label_settings_menu }
|
||||
end
|
||||
|
||||
tabs
|
||||
end
|
||||
|
||||
def render_issue_macro_link(issue, text, comment_id = nil)
|
||||
only_path = controller_path.split('_').last != 'mailer'
|
||||
content = link_to(text, issue_url(issue, only_path: only_path), class: issue.css_classes)
|
||||
if comment_id.nil?
|
||||
content
|
||||
else
|
||||
render_issue_with_comment(issue, content, comment_id, only_path)
|
||||
render_issue_with_comment issue, content, comment_id, only_path: only_path
|
||||
end
|
||||
end
|
||||
|
||||
def render_issue_with_comment(issue, content, comment_id, only_path = false)
|
||||
comment = issue.journals
|
||||
.where(private_notes: false)
|
||||
.offset(comment_id - 1).limit(1).first.try(:notes)
|
||||
def render_issue_with_comment(issue, content, comment_id, only_path: false)
|
||||
journal = issue.journals.select(:notes, :private_notes, :user_id).offset(comment_id - 1).limit(1).first
|
||||
comment = if journal
|
||||
user = User.current
|
||||
if user.allowed_to?(:view_private_notes, issue.project) ||
|
||||
!journal.private_notes? ||
|
||||
journal.user == user
|
||||
journal.notes
|
||||
end
|
||||
end
|
||||
|
||||
if comment.blank?
|
||||
comment = 'N/A'
|
||||
comment_link = comment_id
|
||||
|
@ -74,9 +63,9 @@ module Additionals
|
|||
comment_link = link_to(comment_id, issue_url(issue, only_path: only_path, anchor: "note-#{comment_id}"))
|
||||
end
|
||||
|
||||
content_tag :div, class: 'issue-macro box' do
|
||||
content_tag(:div, safe_join([content, '-', l(:label_comment), comment_link], ' '), class: 'issue-macro-subject') +
|
||||
content_tag(:div, textilizable(comment), class: 'issue-macro-comment journal has-notes')
|
||||
tag.div class: 'issue-macro box' do
|
||||
tag.div(safe_join([content, '-', l(:label_comment), comment_link], ' '), class: 'issue-macro-subject') +
|
||||
tag.div(textilizable(comment), class: 'issue-macro-comment journal has-notes')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -145,9 +134,12 @@ module Additionals
|
|||
rc
|
||||
end
|
||||
|
||||
def additionals_library_load(module_name)
|
||||
method = "additionals_load_#{module_name}"
|
||||
send(method)
|
||||
def additionals_library_load(module_names)
|
||||
s = []
|
||||
Array(module_names).each do |module_name|
|
||||
s << send("additionals_load_#{module_name}")
|
||||
end
|
||||
safe_join s
|
||||
end
|
||||
|
||||
def system_uptime
|
||||
|
@ -158,7 +150,7 @@ module Additionals
|
|||
min = 0
|
||||
hours = 0
|
||||
days = 0
|
||||
if secs > 0
|
||||
if secs.positive?
|
||||
min = (secs / 60).round
|
||||
hours = (secs / 3_600).round
|
||||
days = (secs / 86_400).round
|
||||
|
@ -171,8 +163,15 @@ module Additionals
|
|||
"#{min} #{l(:minutes, count: min)}"
|
||||
end
|
||||
else
|
||||
days = `uptime | awk '{print $3}'`.to_i.round
|
||||
"#{days} #{l(:days, count: days)}"
|
||||
# this should be mac os
|
||||
seconds = `sysctl -n kern.boottime | awk '{print $4}'`.tr(',', '')
|
||||
so = DateTime.strptime(seconds.strip, '%s')
|
||||
if so.present?
|
||||
time_tag(so)
|
||||
else
|
||||
days = `uptime | awk '{print $3}'`.to_i.round
|
||||
"#{days} #{l(:days, count: days)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -185,16 +184,7 @@ module Additionals
|
|||
end
|
||||
|
||||
def windows_platform?
|
||||
true if /cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM
|
||||
end
|
||||
|
||||
def bootstrap_datepicker_locale
|
||||
s = ''
|
||||
locale = User.current.language.presence || ::I18n.locale
|
||||
locale = 'es' if locale == 'es-PA'
|
||||
locale = 'sr-latin' if locale == 'sr-YU'
|
||||
s = javascript_include_tag("locales/bootstrap-datepicker.#{locale.downcase}.min", plugin: 'additionals') unless locale == 'en'
|
||||
s
|
||||
true if /cygwin|mswin|mingw|bccwin|wince|emx/.match?(RUBY_PLATFORM)
|
||||
end
|
||||
|
||||
def autocomplete_select_entries(name, type, option_tags, options = {})
|
||||
|
@ -202,7 +192,7 @@ module Additionals
|
|||
# if option_tags is not an array, it should be an object
|
||||
option_tags = options_for_select([[option_tags.try(:name), option_tags.try(:id)]], option_tags.try(:id))
|
||||
end
|
||||
options[:project] = @project if @project.present? && options[:project].blank?
|
||||
options[:project] = @project if @project && options[:project].blank?
|
||||
|
||||
s = []
|
||||
s << hidden_field_tag("#{name}[]", '') if options[:multiple]
|
||||
|
@ -217,7 +207,21 @@ module Additionals
|
|||
locals: { field_id: sanitize_to_id(name),
|
||||
ajax_url: send("#{type}_path", project_id: options[:project], user_id: options[:user_id]),
|
||||
options: options })
|
||||
safe_join(s)
|
||||
safe_join s
|
||||
end
|
||||
|
||||
def project_list_css_classes(project, level)
|
||||
classes = [cycle('odd', 'even')]
|
||||
classes += project.css_classes.split(' ')
|
||||
if level.positive?
|
||||
classes << 'idnt'
|
||||
classes << "idnt-#{level}"
|
||||
end
|
||||
classes.join(' ')
|
||||
end
|
||||
|
||||
def addtionals_textarea_cols(text, options = {})
|
||||
[[(options[:min].presence || 8), text.to_s.length / 50].max, (options[:max].presence || 20)].min
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -232,37 +236,50 @@ module Additionals
|
|||
end
|
||||
|
||||
def additionals_include_js(js_name)
|
||||
if additionals_already_loaded('js', js_name)
|
||||
if additionals_already_loaded 'js', js_name
|
||||
''
|
||||
else
|
||||
javascript_include_tag(js_name, plugin: 'additionals') + "\n"
|
||||
javascript_include_tag js_name, plugin: 'additionals'
|
||||
end
|
||||
end
|
||||
|
||||
def additionals_include_css(css)
|
||||
if additionals_already_loaded('css', css)
|
||||
if additionals_already_loaded 'css', css
|
||||
''
|
||||
else
|
||||
stylesheet_link_tag(css, plugin: 'additionals') + "\n"
|
||||
stylesheet_link_tag css, plugin: 'additionals'
|
||||
end
|
||||
end
|
||||
|
||||
def additionals_load_select2
|
||||
additionals_include_js('additionals_to_select2')
|
||||
additionals_include_css('select2') +
|
||||
additionals_include_js('select2.min') +
|
||||
additionals_include_js('select2_helper')
|
||||
end
|
||||
|
||||
def additionals_load_clipboardjs
|
||||
additionals_include_js 'clipboard.min'
|
||||
end
|
||||
|
||||
def additionals_load_observe_field
|
||||
additionals_include_js('additionals_observe_field')
|
||||
additionals_include_js 'additionals_observe_field'
|
||||
end
|
||||
|
||||
def additionals_load_font_awesome
|
||||
additionals_include_css('fontawesome-all.min')
|
||||
additionals_include_css 'fontawesome-all.min'
|
||||
end
|
||||
|
||||
def additionals_load_nvd3
|
||||
additionals_include_css('nv.d3.min') +
|
||||
additionals_include_js('d3.min') +
|
||||
additionals_include_js('nv.d3.min')
|
||||
def additionals_load_chartjs
|
||||
additionals_include_css('Chart.min') +
|
||||
additionals_include_js('Chart.bundle.min')
|
||||
end
|
||||
|
||||
def additionals_load_chartjs_datalabels
|
||||
additionals_include_js 'chartjs-plugin-datalabels.min'
|
||||
end
|
||||
|
||||
def additionals_load_chartjs_colorschemes
|
||||
additionals_include_js 'chartjs-plugin-colorschemes.min'
|
||||
end
|
||||
|
||||
def additionals_load_mermaid
|
||||
|
@ -271,30 +288,48 @@ module Additionals
|
|||
end
|
||||
|
||||
def additionals_load_d3
|
||||
additionals_include_js('d3.min')
|
||||
additionals_include_js 'd3.min'
|
||||
end
|
||||
|
||||
def additionals_load_d3plus
|
||||
additionals_include_js('d3plus.full.min')
|
||||
additionals_include_js 'd3plus.full.min'
|
||||
end
|
||||
|
||||
def additionals_load_zeroclipboard
|
||||
additionals_include_js('zeroclipboard_min')
|
||||
def additionals_load_d3plus_old
|
||||
additionals_include_js 'd3plus-old.full.min'
|
||||
end
|
||||
|
||||
def additionals_load_d3plus_hierarchy
|
||||
additionals_include_js 'd3plus-hierarchy.full'
|
||||
end
|
||||
|
||||
def additionals_load_d3plus_network
|
||||
additionals_include_js 'd3plus-network.full.min'
|
||||
end
|
||||
|
||||
def user_with_avatar(user, options = {})
|
||||
return if user.nil?
|
||||
|
||||
options[:size] = 14 if options[:size].nil?
|
||||
options[:class] = 'additionals-avatar' if options[:class].nil?
|
||||
s = []
|
||||
s << avatar(user, options)
|
||||
s << if options[:no_link]
|
||||
user.name
|
||||
else
|
||||
link_to_user(user)
|
||||
end
|
||||
safe_join(s)
|
||||
if user.type == 'Group'
|
||||
if options[:no_link]
|
||||
user.name
|
||||
elsif Redmine::Plugin.installed? 'redmine_hrm'
|
||||
link_to_hrm_group user
|
||||
else
|
||||
user.name
|
||||
end
|
||||
else
|
||||
options[:size] = 14 if options[:size].nil?
|
||||
options[:class] = 'additionals-avatar' if options[:class].nil?
|
||||
s = []
|
||||
s << avatar(user, options)
|
||||
s << if options[:no_link]
|
||||
user.name
|
||||
else
|
||||
link_to_user user
|
||||
end
|
||||
safe_join s
|
||||
end
|
||||
end
|
||||
|
||||
def options_for_menu_select(active)
|
||||
|
@ -303,24 +338,25 @@ module Additionals
|
|||
l(:label_app_menu) => 'app' }, active)
|
||||
end
|
||||
|
||||
def options_for_overview_select(active)
|
||||
options_for_select({ l(:button_hide) => '',
|
||||
l(:show_on_redmine_home) => 'home',
|
||||
l(:show_on_project_overview) => 'project',
|
||||
l(:show_always) => 'always' }, active)
|
||||
end
|
||||
|
||||
def options_for_welcome_select(active)
|
||||
options_for_select({ l(:button_hide) => '',
|
||||
l(:show_welcome_left) => 'left',
|
||||
l(:show_welcome_right) => 'right' }, active)
|
||||
end
|
||||
|
||||
def human_float_number(value, options = {})
|
||||
ActionController::Base.helpers.number_with_precision(value,
|
||||
precision: options[:precision].presence || 2,
|
||||
separator: options[:separator].presence || '.',
|
||||
strip_insignificant_zeros: true)
|
||||
end
|
||||
|
||||
def query_list_back_url_tag(project = nil, params = nil)
|
||||
url = if controller_name == 'dashboard_async_blocks' && request.query_parameters.key?('dashboard_id')
|
||||
dashboard_link_path project,
|
||||
Dashboard.find_by(id: request.query_parameters['dashboard_id']),
|
||||
refresh: 1
|
||||
elsif params.nil?
|
||||
url_for params: request.query_parameters
|
||||
else
|
||||
url_for params: params
|
||||
end
|
||||
|
||||
hidden_field_tag 'back_url', url, id: nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,33 +1,45 @@
|
|||
# Redmine hooks
|
||||
module Additionals
|
||||
class AdditionalsHookListener < Redmine::Hook::ViewListener
|
||||
include IssuesHelper
|
||||
include AdditionalsIssuesHelper
|
||||
|
||||
render_on(:view_layouts_base_html_head, partial: 'additionals/html_head')
|
||||
render_on(:view_layouts_base_content, partial: 'additionals/content')
|
||||
render_on(:view_layouts_base_body_bottom, partial: 'additionals/body_bottom')
|
||||
render_on :view_layouts_base_html_head, partial: 'additionals/html_head'
|
||||
render_on :view_layouts_base_body_top, partial: 'additionals/body_top'
|
||||
render_on :view_layouts_base_body_bottom, partial: 'additionals/body_bottom'
|
||||
|
||||
render_on(:view_account_login_bottom, partial: 'login_text')
|
||||
render_on(:view_issues_context_menu_start, partial: 'additionals_closed_issues')
|
||||
render_on(:view_issues_bulk_edit_details_bottom, partial: 'change_author_bulk')
|
||||
render_on(:view_issues_form_details_bottom, partial: 'change_author')
|
||||
render_on(:view_issues_new_top, partial: 'new_ticket_message')
|
||||
render_on(:view_issues_sidebar_issues_bottom, partial: 'issues/additionals_sidebar')
|
||||
render_on(:view_issues_sidebar_queries_bottom, partial: 'additionals/global_sidebar')
|
||||
render_on(:view_projects_show_right, partial: 'project_overview')
|
||||
render_on(:view_projects_show_sidebar_bottom, partial: 'additionals/global_sidebar')
|
||||
render_on(:view_welcome_index_right, partial: 'overview_right')
|
||||
render_on(:view_my_account_preferences, partial: 'users/autowatch_involved_issue')
|
||||
render_on(:view_users_form_preferences, partial: 'users/autowatch_involved_issue')
|
||||
render_on(:view_users_show_contextual, partial: 'users/additionals_contextual')
|
||||
render_on :view_account_login_bottom, partial: 'login_text'
|
||||
render_on :view_issues_context_menu_start, partial: 'additionals_closed_issues'
|
||||
render_on :view_issues_bulk_edit_details_bottom, partial: 'change_author_bulk'
|
||||
render_on :view_issues_form_details_bottom, partial: 'change_author'
|
||||
render_on :view_issues_new_top, partial: 'new_ticket_message'
|
||||
render_on :view_issues_sidebar_issues_bottom, partial: 'issues/additionals_sidebar_issues'
|
||||
render_on :view_issues_sidebar_queries_bottom, partial: 'issues/additionals_sidebar_queries'
|
||||
render_on :view_my_account_preferences, partial: 'users/autowatch_involved_issue'
|
||||
render_on :view_users_form_preferences, partial: 'users/autowatch_involved_issue'
|
||||
render_on :view_users_show_contextual, partial: 'users/additionals_contextual'
|
||||
render_on :view_wiki_show_sidebar_bottom, partial: 'additionals_sidebar'
|
||||
|
||||
def helper_issues_show_detail_after_setting(context = {})
|
||||
d = context[:detail]
|
||||
return unless d.prop_key == 'author_id'
|
||||
detail = context[:detail]
|
||||
return unless detail.prop_key == 'author_id'
|
||||
|
||||
d[:value] = find_name_by_reflection('author', d.value)
|
||||
d[:old_value] = find_name_by_reflection('author', d.old_value)
|
||||
detail[:value] = find_name_by_reflection('author', detail.value) || detail.value
|
||||
detail[:old_value] = find_name_by_reflection('author', detail.old_value) || detail.old_value
|
||||
end
|
||||
|
||||
def view_layouts_base_content(context = {})
|
||||
controller = context[:controller]
|
||||
return if controller.nil?
|
||||
|
||||
controller_name = context[:controller].params[:controller]
|
||||
action_name = context[:controller].params[:action]
|
||||
|
||||
return if controller_name == 'account' && action_name == 'login' ||
|
||||
controller_name == 'my' ||
|
||||
controller_name == 'account' && action_name == 'lost_password' ||
|
||||
!Additionals.setting?(:add_go_to_top)
|
||||
|
||||
link_to l(:label_go_to_top), '#gototop', class: 'gototop'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module AccessControlPatch
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
def self.available_project_modules
|
||||
@available_project_modules = available_project_modules_all
|
||||
.reject { |m| Additionals.settings[:disabled_modules].to_a.include?(m.to_s) }
|
||||
end
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def self.available_project_modules_all
|
||||
@permissions.collect(&:project_module).uniq.compact
|
||||
end
|
||||
included do
|
||||
def self.available_project_modules
|
||||
@available_project_modules = available_project_modules_all
|
||||
.reject { |m| Additionals.setting(:disabled_modules).to_a.include?(m.to_s) }
|
||||
end
|
||||
|
||||
def self.available_project_modules_all
|
||||
@permissions.collect(&:project_module).compact!.uniq
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,9 +1,25 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module AccountControllerPatch
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
invisible_captcha only: [:register] if Additionals.setting?(:invisible_captcha)
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
invisible_captcha(only: [:register],
|
||||
on_timestamp_spam: :timestamp_spam_check,
|
||||
if: -> { Additionals.setting?(:invisible_captcha) })
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def timestamp_spam_check
|
||||
# redmine uses same action for _GET and _POST
|
||||
return unless request.post?
|
||||
|
||||
if respond_to?(:redirect_back)
|
||||
redirect_back(fallback_location: home_url, flash: { error: InvisibleCaptcha.timestamp_error_message })
|
||||
else
|
||||
redirect_to :back, flash: { error: InvisibleCaptcha.timestamp_error_message }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module ApplicationControllerPatch
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
before_action :enable_smileys
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def enable_smileys
|
||||
return if Redmine::WikiFormatting::Textile::Formatter::RULES.include?(:inline_smileys) ||
|
||||
!Additionals.setting?(:legacy_smiley_support)
|
||||
|
||||
Redmine::WikiFormatting::Textile::Formatter::RULES << :inline_smileys
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,28 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module AutoCompletesControllerPatch
|
||||
def fontawesome
|
||||
icons = AdditionalsFontAwesome.search_for_select(params[:q].to_s.strip,
|
||||
params[:selected].to_s.strip)
|
||||
icons.sort! { |x, y| x[:text] <=> y[:text] }
|
||||
|
||||
respond_to do |format|
|
||||
format.js { render json: icons }
|
||||
format.html { render json: icons }
|
||||
end
|
||||
end
|
||||
|
||||
def issue_assignee
|
||||
assignee_classes = ['User']
|
||||
assignee_classes << 'Group' if Setting.issue_group_assignment?
|
||||
|
||||
scope = Principal.where(type: assignee_classes).limit(100)
|
||||
scope = scope.member_of(project) if @project.present?
|
||||
scope = scope.distinct
|
||||
@assignee = scope.active.visible.sorted.like(params[:q]).to_a
|
||||
@assignee = @assignee.sort! { |x, y| x.name <=> y.name }
|
||||
render layout: false, partial: 'issue_assignee'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,17 +1,17 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module FormatterMarkdownPatch
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
base.send(:include, Additionals::Formatter)
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Add a postprocess hook to redcarpet's html formatter
|
||||
def postprocess(text)
|
||||
if Additionals.setting?(:legacy_smiley_support)
|
||||
render_inline_smileys(inline_emojify(text))
|
||||
else
|
||||
text
|
||||
end
|
||||
included do
|
||||
include Additionals::Formatter
|
||||
|
||||
# Add a postprocess hook to redcarpet's html formatter
|
||||
def postprocess(text)
|
||||
if Additionals.setting?(:legacy_smiley_support)
|
||||
render_inline_smileys(inline_emojify(text))
|
||||
else
|
||||
text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module FormatterTextilePatch
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
base.send(:include, Additionals::Formatter)
|
||||
# Add :inline_emojify to list of textile functions
|
||||
if Additionals.setting?(:legacy_smiley_support)
|
||||
Redmine::WikiFormatting::Textile::Formatter::RULES << :inline_emojify
|
||||
Redmine::WikiFormatting::Textile::Formatter::RULES << :inline_smileys
|
||||
end
|
||||
end
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Additionals::Formatter
|
||||
|
||||
# emojify are always enabled
|
||||
Redmine::WikiFormatting::Textile::Formatter::RULES << :inline_emojify
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,21 +1,18 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module FormattingHelperPatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
base.class_eval do
|
||||
alias_method :heads_for_wiki_formatter_without_additionals, :heads_for_wiki_formatter
|
||||
alias_method :heads_for_wiki_formatter, :heads_for_wiki_formatter_with_additionals
|
||||
end
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
prepend InstanceOverwriteMethods
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def heads_for_wiki_formatter_with_additionals
|
||||
heads_for_wiki_formatter_without_additionals
|
||||
|
||||
module InstanceOverwriteMethods
|
||||
def heads_for_wiki_formatter
|
||||
super
|
||||
return if @additionals_macro_list
|
||||
|
||||
@additionals_macro_list = AdditionalsMacro.all(filtered: Additionals.settings[:hidden_macros_in_toolbar].to_a,
|
||||
@additionals_macro_list = AdditionalsMacro.all(filtered: Additionals.setting(:hidden_macros_in_toolbar).to_a,
|
||||
only_names: true,
|
||||
controller_only: controller_name)
|
||||
|
||||
|
|
|
@ -1,24 +1,39 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module IssuePatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
base.class_eval do
|
||||
alias_method :editable_without_additionals?, :editable?
|
||||
alias_method :editable?, :editable_with_additionals?
|
||||
validate :validate_change_on_closed
|
||||
validate :validate_timelog_required
|
||||
validate :validate_open_sub_issues
|
||||
validate :validate_current_user_status
|
||||
before_validation :auto_assigned_to
|
||||
before_save :change_status_with_assigned_to_change,
|
||||
:autowatch_involved
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
safe_attributes 'author_id',
|
||||
if: proc { |issue, user|
|
||||
issue.new_record? && user.allowed_to?(:change_new_issue_author, issue.project) ||
|
||||
issue.persisted? && user.allowed_to?(:edit_issue_author, issue.project)
|
||||
}
|
||||
included do
|
||||
include InstanceMethods
|
||||
|
||||
alias_method :editable_without_additionals?, :editable?
|
||||
alias_method :editable?, :editable_with_additionals?
|
||||
validate :validate_change_on_closed
|
||||
validate :validate_timelog_required
|
||||
validate :validate_current_user_status
|
||||
before_validation :auto_assigned_to
|
||||
before_save :change_status_with_assigned_to_change,
|
||||
:autowatch_involved
|
||||
|
||||
safe_attributes 'author_id',
|
||||
if: proc { |issue, user|
|
||||
issue.new_record? && user.allowed_to?(:change_new_issue_author, issue.project) ||
|
||||
issue.persisted? && user.allowed_to?(:edit_issue_author, issue.project)
|
||||
}
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def join_issue_status(options = {})
|
||||
sql = "JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{table_name}.status_id"
|
||||
return sql unless options.key?(:is_closed)
|
||||
|
||||
sql << " AND #{IssueStatus.table_name}.is_closed ="
|
||||
sql << if options[:is_closed]
|
||||
" #{connection.quoted_true}"
|
||||
else
|
||||
" #{connection.quoted_false}"
|
||||
end
|
||||
sql
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -48,23 +63,24 @@ module Additionals
|
|||
|
||||
add_autowatcher(User.current)
|
||||
add_autowatcher(author) if (new_record? || author_id != author_id_was) && author != User.current
|
||||
unless assigned_to_id.nil? || assigned_to_id == User.current.id
|
||||
add_autowatcher(assigned_to) if new_record? || assigned_to_id != assigned_to_id_was
|
||||
|
||||
if !assigned_to_id.nil? && assigned_to_id != User.current.id && (new_record? || assigned_to_id != assigned_to_id_was)
|
||||
add_autowatcher(assigned_to)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def log_time_allowed?(user = User.current)
|
||||
!closed? || user.allowed_to?(:log_time_on_closed_issues, project)
|
||||
!status_was.is_closed || user.allowed_to?(:log_time_on_closed_issues, project)
|
||||
end
|
||||
|
||||
def editable_with_additionals?(user = User.current)
|
||||
return false unless editable_without_additionals?(user)
|
||||
return false unless editable_without_additionals? user
|
||||
return true unless closed?
|
||||
return true unless Additionals.setting?(:issue_freezed_with_close)
|
||||
return true unless Additionals.setting? :issue_freezed_with_close
|
||||
|
||||
user.allowed_to?(:edit_closed_issues, project)
|
||||
user.allowed_to? :edit_closed_issues, project
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -85,16 +101,14 @@ module Additionals
|
|||
end
|
||||
|
||||
def new_ticket_message
|
||||
@new_ticket_message = ''
|
||||
message = Additionals.settings[:new_ticket_message]
|
||||
@new_ticket_message << message if message.present?
|
||||
@new_ticket_message ||= Additionals.setting(:new_ticket_message).presence || ''
|
||||
end
|
||||
|
||||
def status_x_affected?(new_status_id)
|
||||
return false unless Additionals.setting?(:issue_current_user_status)
|
||||
return false if Additionals.settings[:issue_assign_to_x].blank?
|
||||
return false if Additionals.setting(:issue_assign_to_x).blank?
|
||||
|
||||
if Additionals.settings[:issue_assign_to_x].include?(new_status_id.to_s)
|
||||
if Additionals.setting(:issue_assign_to_x).include?(new_status_id.to_s)
|
||||
true
|
||||
else
|
||||
false
|
||||
|
@ -105,18 +119,18 @@ module Additionals
|
|||
|
||||
def auto_assigned_to
|
||||
return if !Additionals.setting?(:issue_auto_assign) ||
|
||||
Additionals.settings[:issue_auto_assign_status].blank? ||
|
||||
Additionals.settings[:issue_auto_assign_role].blank? ||
|
||||
Additionals.setting(:issue_auto_assign_status).blank? ||
|
||||
Additionals.setting(:issue_auto_assign_role).blank? ||
|
||||
assigned_to_id.present?
|
||||
|
||||
return unless Additionals.settings[:issue_auto_assign_status].include?(status_id.to_s)
|
||||
return unless Additionals.setting(:issue_auto_assign_status).include?(status_id.to_s)
|
||||
|
||||
self.assigned_to_id = auto_assigned_to_user
|
||||
true
|
||||
end
|
||||
|
||||
def auto_assigned_to_user
|
||||
manager_role = Role.builtin.find_by(id: Additionals.settings[:issue_auto_assign_role])
|
||||
manager_role = Role.builtin.find_by(id: Additionals.setting(:issue_auto_assign_role))
|
||||
groups = autoassign_get_group_list
|
||||
return groups[manager_role].first.id unless groups.nil? || groups[manager_role].blank?
|
||||
|
||||
|
@ -127,10 +141,10 @@ module Additionals
|
|||
def timelog_required?(check_status_id)
|
||||
usr = User.current
|
||||
return false if !Additionals.setting?(:issue_timelog_required) ||
|
||||
Additionals.settings[:issue_timelog_required_tracker].blank? ||
|
||||
Additionals.settings[:issue_timelog_required_tracker].exclude?(tracker_id.to_s) ||
|
||||
Additionals.settings[:issue_timelog_required_status].blank? ||
|
||||
Additionals.settings[:issue_timelog_required_status].exclude?(check_status_id.to_s) ||
|
||||
Additionals.setting(:issue_timelog_required_tracker).blank? ||
|
||||
Additionals.setting(:issue_timelog_required_tracker).exclude?(tracker_id.to_s) ||
|
||||
Additionals.setting(:issue_timelog_required_status).blank? ||
|
||||
Additionals.setting(:issue_timelog_required_status).exclude?(check_status_id.to_s) ||
|
||||
!usr.allowed_to?(:log_time, project) ||
|
||||
usr.allowed_to?(:issue_timelog_never_required, project) ||
|
||||
time_entries.present?
|
||||
|
@ -145,23 +159,15 @@ module Additionals
|
|||
end
|
||||
|
||||
def validate_change_on_closed
|
||||
return true if !closed? ||
|
||||
new_record? ||
|
||||
!Additionals.setting?(:issue_freezed_with_close) ||
|
||||
return true if new_record? ||
|
||||
!status_was.is_closed ||
|
||||
!changed? ||
|
||||
!Additionals.setting?(:issue_freezed_with_close) ||
|
||||
User.current.allowed_to?(:edit_closed_issues, project)
|
||||
|
||||
errors.add :base, :issue_changes_not_allowed
|
||||
end
|
||||
|
||||
def validate_open_sub_issues
|
||||
return true unless Additionals.setting?(:issue_close_with_open_children)
|
||||
|
||||
errors.add :base, :issue_cannot_close_with_open_children if subject.present? &&
|
||||
closing? &&
|
||||
descendants.find { |d| !d.closed? }
|
||||
end
|
||||
|
||||
def validate_current_user_status
|
||||
if (assigned_to_id_changed? || status_id_changed?) &&
|
||||
status_x_affected?(status_id) &&
|
||||
|
@ -174,13 +180,13 @@ module Additionals
|
|||
|
||||
def change_status_with_assigned_to_change
|
||||
return true if !Additionals.setting?(:issue_status_change) ||
|
||||
Additionals.settings[:issue_status_x].blank? ||
|
||||
Additionals.settings[:issue_status_y].blank?
|
||||
Additionals.setting(:issue_status_x).blank? ||
|
||||
Additionals.setting(:issue_status_y).blank?
|
||||
|
||||
if !assigned_to_id_changed? &&
|
||||
status_id_changed? &&
|
||||
(Additionals.settings[:issue_status_x].include? status_id_was.to_s) &&
|
||||
Additionals.settings[:issue_status_y].to_i == status_id
|
||||
(Additionals.setting(:issue_status_x).include? status_id_was.to_s) &&
|
||||
Additionals.setting(:issue_status_y).to_i == status_id
|
||||
self.assigned_to = author
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module IssuePriorityPatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
base.class_eval do
|
||||
alias_method :css_classes_without_additionals, :css_classes
|
||||
alias_method :css_classes, :css_classes_with_additionals
|
||||
end
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
|
||||
alias_method :css_classes_without_additionals, :css_classes
|
||||
alias_method :css_classes, :css_classes_with_additionals
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
|
|
|
@ -1,45 +1,45 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module PrincipalPatch
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
# TODO: find better solution, which not requires overwrite visible
|
||||
# to filter out hide role members
|
||||
scope :visible, lambda { |*args|
|
||||
user = args.first || User.current
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
if user.admin?
|
||||
all
|
||||
included do
|
||||
# TODO: find better solution, which not requires overwrite visible
|
||||
# to filter out hide role members
|
||||
scope :visible, lambda { |*args|
|
||||
user = args.first || User.current
|
||||
|
||||
if user.admin?
|
||||
all
|
||||
else
|
||||
view_all_active = if user.memberships.to_a.any?
|
||||
user.memberships.any? { |m| m.roles.any? { |r| r.users_visibility == 'all' } }
|
||||
else
|
||||
user.builtin_role.users_visibility == 'all'
|
||||
end
|
||||
|
||||
if view_all_active
|
||||
active
|
||||
else
|
||||
view_all_active = if user.memberships.to_a.any?
|
||||
user.memberships.any? { |m| m.roles.any? { |r| r.users_visibility == 'all' } }
|
||||
else
|
||||
user.builtin_role.users_visibility == 'all'
|
||||
end
|
||||
# self and members of visible projects
|
||||
scope = if user.allowed_to?(:show_hidden_roles_in_memberbox, nil, global: true)
|
||||
active.where("#{table_name}.id = ? OR #{table_name}.id IN (SELECT user_id " \
|
||||
"FROM #{Member.table_name} WHERE project_id IN (?))",
|
||||
user.id, user.visible_project_ids)
|
||||
else
|
||||
active.where("#{table_name}.id = ? OR #{table_name}.id IN (SELECT user_id " \
|
||||
"FROM #{Member.table_name} JOIN #{MemberRole.table_name} " \
|
||||
" ON #{Member.table_name}.id = #{MemberRole.table_name}.member_id" \
|
||||
" JOIN #{Role.table_name} " \
|
||||
" ON #{Role.table_name}.id = #{MemberRole.table_name}.role_id" \
|
||||
" WHERE project_id IN (?) AND #{Role.table_name}.hide = ?)",
|
||||
user.id, user.visible_project_ids, false)
|
||||
end
|
||||
|
||||
if view_all_active
|
||||
active
|
||||
else
|
||||
# self and members of visible projects
|
||||
scope = if user.allowed_to?(:show_hidden_roles_in_memberbox, nil, global: true)
|
||||
active.where("#{table_name}.id = ? OR #{table_name}.id IN (SELECT user_id " \
|
||||
"FROM #{Member.table_name} WHERE project_id IN (?))",
|
||||
user.id, user.visible_project_ids)
|
||||
else
|
||||
active.where("#{table_name}.id = ? OR #{table_name}.id IN (SELECT user_id " \
|
||||
"FROM #{Member.table_name} JOIN #{MemberRole.table_name} " \
|
||||
" ON #{Member.table_name}.id = #{MemberRole.table_name}.member_id" \
|
||||
" JOIN #{Role.table_name} " \
|
||||
" ON #{Role.table_name}.id = #{MemberRole.table_name}.role_id" \
|
||||
" WHERE project_id IN (?) AND #{Role.table_name}.hide = ?)",
|
||||
user.id, user.visible_project_ids, false)
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
scope
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,13 +1,50 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module ProjectPatch
|
||||
def self.included(base)
|
||||
base.send(:prepend, InstancOverwriteMethods)
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
prepend InstanceOverwriteMethods
|
||||
include InstanceMethods
|
||||
|
||||
has_many :dashboards, dependent: :destroy
|
||||
end
|
||||
|
||||
module InstancOverwriteMethods
|
||||
module InstanceOverwriteMethods
|
||||
# this change take care of hidden roles and performance issues (includes for hrm, if installed)
|
||||
def users_by_role
|
||||
roles_with_users = super
|
||||
if Redmine::VERSION.to_s >= '4.2'
|
||||
includes = Redmine::Plugin.installed?('redmine_hrm') ? [:roles, { principal: :hrm_user_type }] : %i[roles principal]
|
||||
memberships.includes(includes).each_with_object({}) do |m, h|
|
||||
m.roles.each do |r|
|
||||
next if r.hide && !User.current.allowed_to?(:show_hidden_roles_in_memberbox, project)
|
||||
|
||||
h[r] ||= []
|
||||
h[r] << m.principal
|
||||
end
|
||||
h
|
||||
end
|
||||
else
|
||||
includes = Redmine::Plugin.installed?('redmine_hrm') ? [:roles, { user: :hrm_user_type }] : %i[roles user]
|
||||
members.includes(includes).each_with_object({}) do |m, h|
|
||||
m.roles.each do |r|
|
||||
next if r.hide && !User.current.allowed_to?(:show_hidden_roles_in_memberbox, project)
|
||||
|
||||
h[r] ||= []
|
||||
h[r] << m.user
|
||||
end
|
||||
h
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def users_by_role_old
|
||||
roles_with_users = if Redmine::VERSION.to_s >= '4.2'
|
||||
principals_by_role
|
||||
else
|
||||
super
|
||||
end
|
||||
|
||||
roles_with_users.each do |role_with_users|
|
||||
role = role_with_users.first
|
||||
next unless role.hide
|
||||
|
@ -18,6 +55,30 @@ module Additionals
|
|||
roles_with_users
|
||||
end
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def visible_principals
|
||||
query = ::Query.new(project: self, name: '_')
|
||||
query&.principals
|
||||
end
|
||||
|
||||
def visible_users
|
||||
query = ::Query.new(project: self, name: '_')
|
||||
query&.users
|
||||
end
|
||||
|
||||
# assignable_users result depends on Setting.issue_group_assignment?
|
||||
# this result is not depending on issue settings
|
||||
def assignable_users_and_groups
|
||||
Principal.active
|
||||
.joins(members: :roles)
|
||||
.where(type: %w[User Group],
|
||||
members: { project_id: id },
|
||||
roles: { assignable: true })
|
||||
.distinct
|
||||
.sorted
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
require_dependency 'projects_controller'
|
||||
|
||||
module Additionals
|
||||
module Patches
|
||||
module ProjectsControllerPatch
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
|
||||
before_action :find_dashboard, only: %i[show]
|
||||
|
||||
helper :additionals_routes
|
||||
helper :issues
|
||||
helper :queries
|
||||
helper :additionals_queries
|
||||
helper :additionals_projects
|
||||
helper :dashboards
|
||||
|
||||
include DashboardsHelper
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
private
|
||||
|
||||
def find_dashboard
|
||||
if params[:dashboard_id].present?
|
||||
begin
|
||||
@dashboard = Dashboard.project_only.find(params[:dashboard_id])
|
||||
raise ::Unauthorized unless @dashboard.visible?
|
||||
raise ::Unauthorized unless @dashboard.project.nil? || @dashboard.project == @project
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
return render_404
|
||||
end
|
||||
else
|
||||
@dashboard = Dashboard.default DashboardContentProject::TYPE_NAME, @project
|
||||
end
|
||||
|
||||
@dashboard.content_project = @project
|
||||
resently_used_dashboard_save @dashboard, @project
|
||||
@can_edit = @dashboard&.editable?
|
||||
@dashboard_sidebar = dashboard_sidebar? @dashboard, params
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,17 +3,11 @@ require_dependency 'query'
|
|||
module Additionals
|
||||
module Patches
|
||||
module QueryFilterPatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
end
|
||||
unless method_defined? :[]=
|
||||
def []=(key, value)
|
||||
return unless key == :values
|
||||
|
||||
module InstanceMethods
|
||||
unless method_defined? :[]=
|
||||
def []=(key, value)
|
||||
return unless key == :values
|
||||
|
||||
@value = @options[:values] = value
|
||||
end
|
||||
@value = @options[:values] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module ReportsControllerPatch
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
prepend InstanceOverwriteMethods
|
||||
end
|
||||
|
||||
module InstanceOverwriteMethods
|
||||
def issue_report_details
|
||||
super
|
||||
return if @rows.nil?
|
||||
|
||||
if Setting.issue_group_assignment? && params[:detail] == 'assigned_to'
|
||||
@rows = @project.visible_principals
|
||||
elsif %w[assigned_to author].include? params[:detail]
|
||||
@rows = @project.visible_users
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,10 +1,10 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module RolePatch
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
safe_attributes 'hide'
|
||||
end
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
safe_attributes 'hide'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,19 +1,21 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module TimeEntryPatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
base.class_eval do
|
||||
alias_method :editable_by_without_additionals?, :editable_by?
|
||||
alias_method :editable_by?, :editable_by_with_additionals?
|
||||
validate :validate_issue_allowed
|
||||
end
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
|
||||
alias_method :editable_by_without_additionals?, :editable_by?
|
||||
alias_method :editable_by?, :editable_by_with_additionals?
|
||||
validate :validate_issue_allowed
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def validate_issue_allowed
|
||||
return unless issue_id && issue
|
||||
return if Setting.commit_logtime_enabled? && (issue.updated_on + 3.seconds) > Additionals.now_with_user_time_zone
|
||||
# NOTE: do not use user time zone here, because issue do not use it
|
||||
return if Setting.commit_logtime_enabled? && (issue.updated_on + 5.seconds) > Time.zone.now
|
||||
|
||||
errors.add(:issue_id, :issue_log_time_not_allowed) unless issue.log_time_allowed?
|
||||
end
|
||||
|
|
|
@ -1,14 +1,56 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module UserPatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def admin_column_field
|
||||
Redmine::Plugin.installed?('redmine_sudo') ? 'sudoer' : 'admin'
|
||||
end
|
||||
|
||||
# NOTE: this is a better (performance related) solution as:
|
||||
# authors = users.to_a.select { |u| u.allowed_to? permission, project, global: project.nil? }
|
||||
def with_permission(permission, project = nil)
|
||||
# Clear cache for debuging performance issue
|
||||
# ActiveRecord::Base.connection.clear_query_cache
|
||||
|
||||
role_ids = Role.builtin(false).select { |p| p.permissions.include? permission }
|
||||
role_ids.map!(&:id)
|
||||
|
||||
admin_ids = User.visible.active.where(admin: true).ids
|
||||
|
||||
member_scope = Member.joins(:member_roles, :project)
|
||||
.where(projects: { status: Project::STATUS_ACTIVE },
|
||||
user_id: User.all,
|
||||
member_roles: { role_id: role_ids })
|
||||
.select(:user_id)
|
||||
.distinct
|
||||
|
||||
if project.nil?
|
||||
# user_ids = member_scope.pluck(:user_id) | admin_ids
|
||||
# where(id: user_ids)
|
||||
where(id: member_scope).or(where(id: admin_ids))
|
||||
else
|
||||
# user_ids = member_scope.where(project_id: project).pluck(:user_id)
|
||||
# where(id: user_ids).or(where(id: admin_ids))
|
||||
where(id: member_scope.where(project_id: project)).or(where(id: admin_ids))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def can_be_admin?
|
||||
@can_be_admin ||= Redmine::Plugin.installed?('redmine_sudo') ? (admin || sudoer) : admin
|
||||
end
|
||||
|
||||
def issues_assignable?(project = nil)
|
||||
scope = Principal.joins(members: :roles)
|
||||
.where(users: { id: id }, roles: { assignable: true })
|
||||
.where(users: { id: id },
|
||||
roles: { assignable: true })
|
||||
scope = scope.where(members: { project_id: project.id }) if project
|
||||
scope.exists?
|
||||
end
|
||||
|
|
|
@ -1,9 +1,33 @@
|
|||
module Additionals
|
||||
module Patches
|
||||
module UserPreferencePatch
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
safe_attributes 'autowatch_involved_issue'
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
safe_attributes 'autowatch_involved_issue', 'recently_used_dashboards'
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def recently_used_dashboards
|
||||
self[:recently_used_dashboards]
|
||||
end
|
||||
|
||||
def recently_used_dashboard(dashboard_type, project = nil)
|
||||
r = self[:recently_used_dashboards] ||= {}
|
||||
r = {} unless r.is_a? Hash
|
||||
|
||||
return unless r.is_a?(Hash) && r.key?(dashboard_type)
|
||||
|
||||
if dashboard_type == DashboardContentProject::TYPE_NAME
|
||||
r[dashboard_type][project.id]
|
||||
else
|
||||
r[dashboard_type]
|
||||
end
|
||||
end
|
||||
|
||||
def recently_used_dashboards=(value)
|
||||
self[:recently_used_dashboards] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
require_dependency 'welcome_controller'
|
||||
|
||||
module Additionals
|
||||
module Patches
|
||||
module WelcomeControllerPatch
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include InstanceMethods
|
||||
|
||||
before_action :find_dashboard, only: %i[index]
|
||||
|
||||
helper :additionals_routes
|
||||
helper :issues
|
||||
helper :queries
|
||||
helper :additionals_queries
|
||||
helper :dashboards
|
||||
|
||||
include DashboardsHelper
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
private
|
||||
|
||||
def find_dashboard
|
||||
if params[:dashboard_id].present?
|
||||
begin
|
||||
@dashboard = Dashboard.welcome_only.find(params[:dashboard_id])
|
||||
raise ::Unauthorized unless @dashboard.visible?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
return render_404
|
||||
end
|
||||
else
|
||||
@dashboard = Dashboard.default DashboardContentWelcome::TYPE_NAME
|
||||
end
|
||||
|
||||
resently_used_dashboard_save @dashboard
|
||||
@can_edit = @dashboard&.editable?
|
||||
@dashboard_sidebar = dashboard_sidebar? @dashboard, params
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,65 +0,0 @@
|
|||
require_dependency 'wiki_controller'
|
||||
|
||||
module Additionals
|
||||
module Patches
|
||||
module WikiControllerPatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethods)
|
||||
base.class_eval do
|
||||
alias_method :respond_to_without_additionals, :respond_to
|
||||
alias_method :respond_to, :respond_to_with_additionals
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def respond_to_with_additionals(&block)
|
||||
if @project && @content
|
||||
if @_action_name == 'show'
|
||||
additionals_include_header
|
||||
additionals_include_footer
|
||||
end
|
||||
end
|
||||
respond_to_without_additionals(&block)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def additionals_include_header
|
||||
wiki_header = '' + Additionals.settings[:global_wiki_header].to_s
|
||||
return if wiki_header.empty?
|
||||
|
||||
if Object.const_defined?('WikiExtensionsUtil') && WikiExtensionsUtil.is_enabled?(@project)
|
||||
header = @wiki.find_page('Header')
|
||||
return if header
|
||||
end
|
||||
|
||||
text = "\n"
|
||||
text << '<div id="wiki_extentions_header">'
|
||||
text << "\n\n"
|
||||
text << wiki_header
|
||||
text << "\n\n</div>"
|
||||
text << "\n\n"
|
||||
text << @content.text
|
||||
@content.text = text
|
||||
end
|
||||
|
||||
def additionals_include_footer
|
||||
wiki_footer = '' + Additionals.settings[:global_wiki_footer].to_s
|
||||
return if wiki_footer.empty?
|
||||
|
||||
if Object.const_defined?('WikiExtensionsUtil') && WikiExtensionsUtil.is_enabled?(@project)
|
||||
footer = @wiki.find_page('Footer')
|
||||
return if footer
|
||||
end
|
||||
|
||||
text = @content.text
|
||||
text << "\n\n"
|
||||
text << '<div id="wiki_extentions_footer">'
|
||||
text << "\n\n"
|
||||
text << wiki_footer
|
||||
text << "\n\n</div>"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,25 +4,25 @@ module Additionals
|
|||
module Patches
|
||||
# Patch wiki to include sidebar
|
||||
module WikiPatch
|
||||
def self.included(base)
|
||||
base.send(:include, InstanceMethodsForAdditionalsWiki)
|
||||
base.class_eval do
|
||||
alias_method :sidebar_without_additionals, :sidebar
|
||||
alias_method :sidebar, :sidebar_with_additionals
|
||||
end
|
||||
end
|
||||
end
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Instance methodes for Wiki
|
||||
module InstanceMethodsForAdditionalsWiki
|
||||
def sidebar_with_additionals
|
||||
@sidebar ||= find_page('Sidebar', with_redirect: false)
|
||||
if @sidebar && @sidebar.content
|
||||
sidebar_without_additionals
|
||||
else
|
||||
wiki_sidebar = Additionals.settings[:global_wiki_sidebar].to_s
|
||||
@sidebar ||= find_page(project.wiki.start_page, with_redirect: false)
|
||||
@sidebar.content.text = wiki_sidebar if wiki_sidebar != '' && @sidebar.try(:content)
|
||||
included do
|
||||
include InstanceMethods
|
||||
|
||||
alias_method :sidebar_without_additionals, :sidebar
|
||||
alias_method :sidebar, :sidebar_with_additionals
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def sidebar_with_additionals
|
||||
@sidebar ||= find_page('Sidebar', with_redirect: false)
|
||||
if @sidebar&.content
|
||||
sidebar_without_additionals
|
||||
else
|
||||
wiki_sidebar = Additionals.setting(:global_wiki_sidebar).to_s
|
||||
@sidebar ||= find_page(project.wiki.start_page, with_redirect: false)
|
||||
@sidebar.content.text = wiki_sidebar if wiki_sidebar != '' && @sidebar.try(:content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# asciinema wiki macros
|
||||
module Additionals
|
||||
module WikiMacros
|
||||
Redmine::WikiFormatting::Macros.register do
|
||||
desc 'asciinema embed'
|
||||
|
||||
macro :asciinema do |_obj, args|
|
||||
raise 'The correct usage is {{asciinema(<cast_id>)}}' if args.empty?
|
||||
|
||||
javascript_tag(nil, id: "asciicast-#{args[0]}", src: "//asciinema.org/a/#{args[0]}.js", async: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,70 +0,0 @@
|
|||
# Calendar wiki macros
|
||||
module Additionals
|
||||
module WikiMacros
|
||||
Redmine::WikiFormatting::Macros.register do
|
||||
desc <<-DESCRIPTION
|
||||
Display calendar (only works on wiki pages)
|
||||
|
||||
Examples:
|
||||
|
||||
{{calendar}} show calendar for current date
|
||||
{{calendar(year=2014,month=6)}} show calendar for Juni in year 2014
|
||||
{{calendar(show_weeks=true)}} show calendar with week numbers
|
||||
{{calendar(select=2015-07-12 2015-07-31, show_weeks=true)}} preselect dates and show week numbers
|
||||
{{calendar(select=2016-03-13:2016-03-27)}} preselect dates between 2016/3/13 and 2016/3/27
|
||||
DESCRIPTION
|
||||
|
||||
macro :calendar do |_obj, args|
|
||||
raise 'Only works on wiki page' unless controller_name == 'wiki' && %w[show preview].include?(action_name)
|
||||
|
||||
_args, options = extract_macro_options(args, :show_weeks, :year, :month, :select)
|
||||
options[:show_weeks] = 'false' if options[:show_weeks].blank?
|
||||
options[:year] = Additionals.now_with_user_time_zone.year.to_s if options[:year].blank?
|
||||
options[:month] = Additionals.now_with_user_time_zone.month.to_s if options[:month].blank?
|
||||
options[:month] = options[:month].to_i - 1
|
||||
|
||||
selected = ''
|
||||
selected = Additionals.convert_string2date(options[:select]) if options[:select].present?
|
||||
|
||||
locale = User.current.language.presence || ::I18n.locale
|
||||
# not more then 30 calendars per page are expected
|
||||
id = (0..30).to_a.sort { rand - 0.5 } [1]
|
||||
render partial: 'wiki/calendar_macros',
|
||||
formats: [:html],
|
||||
locals: { options: options, locale: locale, id: id, selected: selected }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.convert_string2date(string)
|
||||
selected = if string.include? ':'
|
||||
convert_string2period(string)
|
||||
else
|
||||
convert_string2dates(string)
|
||||
end
|
||||
selected.join(', ')
|
||||
end
|
||||
|
||||
def self.convert_string2period(string)
|
||||
s = string.split ':'
|
||||
raise 'missing date' if s[0].blank? || s[1].blank?
|
||||
|
||||
tstart = Date.strptime(s[0], '%Y-%m-%d')
|
||||
raise 'invalid start date' if tstart.nil?
|
||||
|
||||
tend = Date.strptime(s[1], '%Y-%m-%d')
|
||||
raise 'invalid start date' if tend.nil?
|
||||
|
||||
(tstart..tend).map { |date| "new Date(#{date.year},#{date.month - 1},#{date.mday})" }
|
||||
end
|
||||
|
||||
def self.convert_string2dates(string)
|
||||
selected = []
|
||||
s = string.split
|
||||
s.each do |d|
|
||||
con = Date.strptime(d, '%Y-%m-%d')
|
||||
selected << "new Date(#{con.year},#{con.month - 1},#{con.mday})" unless con.nil?
|
||||
end
|
||||
selected
|
||||
end
|
||||
end
|
|
@ -21,68 +21,67 @@ module Additionals
|
|||
widget_type = 'chart'
|
||||
else
|
||||
widget_type = options[:type]
|
||||
options.delete(:type)
|
||||
options.delete :type
|
||||
end
|
||||
|
||||
base_url = 'https://widgets.cryptocompare.com/'
|
||||
|
||||
case widget_type
|
||||
when 'chart'
|
||||
url = base_url + 'serve/v2/coin/chart'
|
||||
url = 'serve/v2/coin/chart'
|
||||
when 'news'
|
||||
options[:feedType] = 'CoinTelegraph' if options[:feedType].blank?
|
||||
url = base_url + 'serve/v1/coin/feed'
|
||||
url = 'serve/v1/coin/feed'
|
||||
when 'list'
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v1/coin/list'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :tsym
|
||||
url = 'serve/v1/coin/list'
|
||||
when 'titles'
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v1/coin/tiles'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :tsym
|
||||
url = 'serve/v1/coin/tiles'
|
||||
when 'tabbed'
|
||||
options[:fsyms] = Additionals.crypto_default(options, :fsyms, 'BTC,ETH,LTC')
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:fsym)
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v1/coin/multi'
|
||||
options[:fsyms] = Additionals.crypto_default options, :fsyms, 'BTC,ETH,LTC'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :fsym
|
||||
options.delete :tsym
|
||||
url = 'serve/v1/coin/multi'
|
||||
when 'header', 'header_v1'
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v1/coin/header'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :tsym
|
||||
url = 'serve/v1/coin/header'
|
||||
when 'header_v2'
|
||||
options[:fsyms] = Additionals.crypto_default(options, :fsyms, 'BTC,ETH,LTC')
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:fsym)
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v2/coin/header'
|
||||
options[:fsyms] = Additionals.crypto_default options, :fsyms, 'BTC,ETH,LTC'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :fsym
|
||||
options.delete :tsym
|
||||
url = 'serve/v2/coin/header'
|
||||
when 'header_v3'
|
||||
options[:fsyms] = Additionals.crypto_default(options, :fsyms, 'BTC,ETH,LTC')
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR')
|
||||
options.delete(:fsym)
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v3/coin/header'
|
||||
options[:fsyms] = Additionals.crypto_default options, :fsyms, 'BTC,ETH,LTC'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR'
|
||||
options.delete :fsym
|
||||
options.delete :tsym
|
||||
url = 'serve/v3/coin/header'
|
||||
when 'summary'
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v1/coin/summary'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :tsym
|
||||
url = 'serve/v1/coin/summary'
|
||||
when 'historical'
|
||||
url = base_url + 'serve/v1/coin/histo_week'
|
||||
url = 'serve/v1/coin/histo_week'
|
||||
when 'converter'
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v1/coin/converter'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :tsym
|
||||
url = 'serve/v1/coin/converter'
|
||||
when 'advanced'
|
||||
options[:tsyms] = Additionals.crypto_default(options, :tsyms, 'EUR,USD')
|
||||
options.delete(:tsym)
|
||||
url = base_url + 'serve/v3/coin/chart'
|
||||
options[:tsyms] = Additionals.crypto_default options, :tsyms, 'EUR,USD'
|
||||
options.delete :tsym
|
||||
url = 'serve/v3/coin/chart'
|
||||
else
|
||||
raise 'type is not supported'
|
||||
end
|
||||
|
||||
params = options.map { |k, v| "#{k}=#{v}" }.join('&')
|
||||
render partial: 'wiki/cryptocompare',
|
||||
formats: [:html],
|
||||
locals: { url: url + '?' + options.map { |k, v| "#{k}=#{v}" }.join('&') }
|
||||
locals: { url: "https://widgets.cryptocompare.com/#{url}?#{params}" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -63,7 +63,7 @@ module Additionals
|
|||
format_date(type.to_date)
|
||||
end
|
||||
|
||||
content_tag(:span, date_result, class: 'current-date')
|
||||
tag.span date_result, class: 'current-date'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -37,7 +37,7 @@ module Additionals
|
|||
args, options = extract_macro_options(args, :class, :title, :text, :size, :color, :link)
|
||||
raise 'The correct usage is {{fa(<ICON>, class=CLASS, title=TITLE, text=TEXT, size=SIZE, color=COLOR)}}' if args.empty?
|
||||
|
||||
values = args[0].split('_')
|
||||
values = args[0].split '_'
|
||||
|
||||
classes = []
|
||||
if values.count == 2
|
||||
|
@ -55,14 +55,14 @@ module Additionals
|
|||
content_options[:title] = options[:title] if options[:title].present?
|
||||
content_options[:style] = "color: #{options[:color]}" if options[:color].present?
|
||||
|
||||
text = options[:text].present? ? ' ' + options[:text] : ''
|
||||
text = options[:text].present? ? " #{options[:text]}" : ''
|
||||
|
||||
if options[:link].present?
|
||||
content_tag(:a, href: options[:link]) do
|
||||
content_tag(:i, text, content_options)
|
||||
tag.a href: options[:link] do
|
||||
tag.i text, content_options
|
||||
end
|
||||
else
|
||||
content_tag(:i, text, content_options)
|
||||
tag.i text, content_options
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -60,7 +60,7 @@ module Additionals
|
|||
:waypoints,
|
||||
:zoom)
|
||||
|
||||
raise 'Missing Google Maps Embed API Key. See documentation for more info.' if Additionals.settings[:google_maps_api_key].blank?
|
||||
raise 'Missing Google Maps Embed API Key. See documentation for more info.' if Additionals.setting(:google_maps_api_key).blank?
|
||||
|
||||
width = options[:width].presence || 620
|
||||
height = options[:height].presence || 350
|
||||
|
@ -70,11 +70,11 @@ module Additionals
|
|||
raise 'The correct usage is {{gmap([q=QUERY, mode=MODE, widths=x, height=y])}}'
|
||||
end
|
||||
|
||||
src = "https://www.google.com/maps/embed/v1/#{mode}?key=" + Additionals.settings[:google_maps_api_key]
|
||||
src = "https://www.google.com/maps/embed/v1/#{mode}?key=" + Additionals.setting(:google_maps_api_key)
|
||||
if options[:q].present?
|
||||
src << '&q=' + ERB::Util.url_encode(options[:q])
|
||||
src << "&q=#{ERB::Util.url_encode(options[:q])}"
|
||||
elsif mode == 'search'
|
||||
src << '&q=' + ERB::Util.url_encode(args[0])
|
||||
src << "&q=#{ERB::Util.url_encode(args[0])}"
|
||||
end
|
||||
|
||||
src_options.each do |key|
|
||||
|
@ -82,7 +82,7 @@ module Additionals
|
|||
end
|
||||
src << "&#{mode}=" + ERB::Util.url_encode(options[:way_mode]) if options[:way_mode].present?
|
||||
|
||||
content_tag(:iframe, '', width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true')
|
||||
tag.iframe width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
# Google docs wiki macros
|
||||
module Additionals
|
||||
module WikiMacros
|
||||
Redmine::WikiFormatting::Macros.register do
|
||||
desc <<-DESCRIPTION
|
||||
Google docs macro to include Google documents.
|
||||
|
||||
Syntax:
|
||||
|
||||
{{google_docs(<link> [, width=100%, height=485, edit_link=LINK)}}
|
||||
|
||||
Examples:
|
||||
|
||||
{{google_docs(https://docs.google.com/spreadsheets/d/e/2PACX-1vQL__Vgu0Y0f-P__GJ9kpUmQ0S-HG56ni_b-x4WpWxzGIGXh3X6A587SeqvJDpH42rDmWVZoUN07VGE/pubhtml)}
|
||||
{{google_docs(https://docs.google.com/spreadsheets/d/e/2PACX-1vQL__Vgu0Y0f-P__GJ9kpUmQ0S-HG56ni_b-x4WpWxzGIGXh3X6A587SeqvJDpH42rDmWVZoUN07VGE/pubhtml, width=514, height=422)}
|
||||
DESCRIPTION
|
||||
|
||||
macro :google_docs do |_obj, args|
|
||||
args, options = extract_macro_options(args, :width, :height, :edit_link)
|
||||
|
||||
width = options[:width].presence || '100%'
|
||||
height = options[:height].presence || 485
|
||||
|
||||
raise 'The correct usage is {{google_docs(<link>[, width=x, height=y, edit_link=LINK])}}' if args.empty?
|
||||
|
||||
v = args[0]
|
||||
|
||||
raise '<link> is not a Google document.' unless v.start_with? 'https://docs.google.com/'
|
||||
|
||||
src = v.dup
|
||||
unless src.include? '?'
|
||||
src << if src.include?('edit')
|
||||
'?rm=minimal'
|
||||
else
|
||||
'?widget=true&headers=false'
|
||||
end
|
||||
end
|
||||
|
||||
s = []
|
||||
s << tag.iframe(width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true')
|
||||
if options[:edit_link].present?
|
||||
raise '<edit_link> is not a Google document.' unless options[:edit_link].start_with? 'https://docs.google.com/'
|
||||
|
||||
s << tag.br
|
||||
s << link_to(font_awesome_icon('fab_google-drive', post_text: :label_open_in_google_docs),
|
||||
options[:edit_link],
|
||||
class: 'external')
|
||||
end
|
||||
|
||||
safe_join s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -28,17 +28,15 @@ module Additionals
|
|||
|
||||
src = args[0]
|
||||
if Additionals.valid_iframe_url?(src)
|
||||
s = [content_tag(:iframe,
|
||||
'',
|
||||
width: width,
|
||||
height: height,
|
||||
src: src,
|
||||
frameborder: 0,
|
||||
allowfullscreen: 'true')]
|
||||
s = [tag.iframe(width: width,
|
||||
height: height,
|
||||
src: src,
|
||||
frameborder: 0,
|
||||
allowfullscreen: 'true')]
|
||||
if !options[:with_link].nil? && Additionals.true?(options[:with_link])
|
||||
s << link_to(l(:label_open_in_new_windows), src, class: 'external')
|
||||
end
|
||||
safe_join(s)
|
||||
safe_join s
|
||||
elsif Setting.protocol == 'https'
|
||||
raise 'Invalid url provided to iframe (only full URLs with protocol HTTPS are accepted)'
|
||||
else
|
||||
|
|
|
@ -31,9 +31,9 @@ module Additionals
|
|||
|
||||
return unless page
|
||||
|
||||
content_tag(:span,
|
||||
l(:label_updated_time, time_tag(page.updated_on)).html_safe,
|
||||
class: 'last-updated-at')
|
||||
# TODO: find solution for time_tag without to use html_safe
|
||||
tag.span(l(:label_updated_time, time_tag(page.updated_on)).html_safe, # rubocop:disable Rails/OutputSafety
|
||||
class: 'last-updated-at')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -10,9 +10,8 @@ module Additionals
|
|||
macro :last_updated_by do |obj, args|
|
||||
raise 'The correct usage is {{last_updated_by}}' unless args.empty?
|
||||
|
||||
content_tag(:span,
|
||||
safe_join([avatar(obj.author, size: 14), ' ', link_to_user(obj.author)]),
|
||||
class: 'last-updated-by')
|
||||
tag.span safe_join([avatar(obj.author, size: 14), ' ', link_to_user(obj.author)]),
|
||||
class: 'last-updated-by'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,7 +7,7 @@ module Additionals
|
|||
|
||||
Syntax:
|
||||
|
||||
{{members([PROJECT_NAME, title=My members list, role=ROLE)]}}
|
||||
{{members([PROJECT_NAME, title=My members list, role=ROLE, with_sum=BOOL)]}}
|
||||
|
||||
PROJECT_NAME can be project identifier, project name or project id
|
||||
|
||||
|
@ -16,6 +16,9 @@ module Additionals
|
|||
{{members}}
|
||||
...List all members for all projects (with the current user permission)
|
||||
|
||||
{{members(with_sum=true)}}
|
||||
...List all members for all projects and show title with amount of members
|
||||
|
||||
{{members(the-identifier)}}
|
||||
...A box showing all members for the project with the identifier of 'the-identifier'
|
||||
|
||||
|
@ -29,7 +32,7 @@ module Additionals
|
|||
DESCRIPTION
|
||||
|
||||
macro :members do |_obj, args|
|
||||
args, options = extract_macro_options(args, :role, :title)
|
||||
args, options = extract_macro_options(args, :role, :title, :with_sum)
|
||||
|
||||
project_id = args[0]
|
||||
user_roles = []
|
||||
|
@ -42,28 +45,33 @@ module Additionals
|
|||
project ||= Project.visible.find_by(name: project_id)
|
||||
return if project.nil?
|
||||
|
||||
raw_users = User.active
|
||||
.where(["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id=(?))", project.id])
|
||||
.sorted
|
||||
return if raw_users.nil?
|
||||
principals = project.visible_users
|
||||
return if principals.nil?
|
||||
|
||||
users = []
|
||||
raw_users.each do |user|
|
||||
user_roles[user.id] = user.roles_for_project(project)
|
||||
users << user if options[:role].blank? || Additionals.check_role_matches(user_roles[user.id], options[:role])
|
||||
principals.each do |principal|
|
||||
next unless principal.type == 'User'
|
||||
|
||||
user_roles[principal.id] = principal.roles_for_project(project)
|
||||
users << principal if options[:role].blank? || Additionals.check_role_matches(user_roles[principal.id], options[:role])
|
||||
end
|
||||
else
|
||||
project_ids = Project.visible.collect(&:id)
|
||||
return unless project_ids.any?
|
||||
|
||||
# members of the user's projects
|
||||
users = User.active
|
||||
.where(["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id IN (?))", project_ids])
|
||||
users = User.visible
|
||||
.where(type: 'User')
|
||||
.active
|
||||
.sorted
|
||||
end
|
||||
|
||||
list_title = if options[:with_sum]
|
||||
list_title = options[:title].presence || l(:label_member_plural)
|
||||
list_title + " (#{users.count})"
|
||||
else
|
||||
options[:title]
|
||||
end
|
||||
|
||||
render partial: 'wiki/user_macros', locals: { users: users,
|
||||
user_roles: user_roles,
|
||||
list_title: options[:title] }
|
||||
list_title: list_title }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -69,7 +69,7 @@ module Additionals
|
|||
src << Additionals.meteoblue_flag(options, :spot, true)
|
||||
src << Additionals.meteoblue_flag(options, :pressure, false)
|
||||
|
||||
content_tag(:iframe, '', width: width, height: height, src: src, frameborder: 0)
|
||||
tag.iframe width: width, height: height, src: src, frameborder: 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,25 +28,26 @@ module Additionals
|
|||
|
||||
return if days < 1
|
||||
|
||||
pages = WikiPage
|
||||
.includes(:content)
|
||||
.where(["#{WikiPage.table_name}.wiki_id = ? AND #{WikiContent.table_name}.updated_on > ?",
|
||||
page.wiki_id, User.current.today - days])
|
||||
.order("#{WikiContent.table_name}.updated_on desc")
|
||||
o = ''
|
||||
pages = WikiPage.joins(:content)
|
||||
.where(wiki_id: page.wiki_id)
|
||||
.where("#{WikiContent.table_name}.updated_on > ?", User.current.today - days)
|
||||
.order("#{WikiContent.table_name}.updated_on desc")
|
||||
|
||||
s = []
|
||||
date = nil
|
||||
pages.each do |page_raw|
|
||||
content = page_raw.content
|
||||
updated_on = Date.new(content.updated_on.year, content.updated_on.month, content.updated_on.day)
|
||||
if date != updated_on
|
||||
date = updated_on
|
||||
o << '<b>' + format_date(date) + '</b><br/>'
|
||||
s << tag.strong(format_date(date))
|
||||
s << tag.br
|
||||
end
|
||||
o << link_to(content.page.pretty_title,
|
||||
s << link_to(content.page.pretty_title,
|
||||
controller: 'wiki', action: 'show', project_id: content.page.project, id: content.page.title)
|
||||
o << '<br/>'
|
||||
s << tag.br
|
||||
end
|
||||
content_tag('div', o.html_safe, class: 'recently-updated')
|
||||
tag.div safe_join(s), class: 'recently-updated'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,21 +14,21 @@ module Additionals
|
|||
|
||||
case name[0..1]
|
||||
when 'r/'
|
||||
link_to(font_awesome_icon('fab_reddit', post_text: name),
|
||||
link_to font_awesome_icon('fab_reddit', post_text: name),
|
||||
"https://www.reddit.com/#{name}",
|
||||
class: 'external reddit',
|
||||
title: l(:label_reddit_subject))
|
||||
title: l(:label_reddit_subject)
|
||||
when 'u/'
|
||||
link_to(font_awesome_icon('fab_reddit-square', post_text: name),
|
||||
link_to font_awesome_icon('fab_reddit-square', post_text: name),
|
||||
"https://www.reddit.com/username/#{name[2..-1]}",
|
||||
class: 'external reddit',
|
||||
title: l(:label_reddit_user_account))
|
||||
title: l(:label_reddit_user_account)
|
||||
else
|
||||
name = 'r/' + name
|
||||
link_to(font_awesome_icon('fab_reddit', post_text: name),
|
||||
name = "r/#{name}"
|
||||
link_to font_awesome_icon('fab_reddit', post_text: name),
|
||||
"https://www.reddit.com/#{name}",
|
||||
class: 'external reddit',
|
||||
title: l(:label_reddit_subject))
|
||||
title: l(:label_reddit_subject)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,7 +20,7 @@ module Additionals
|
|||
|
||||
options[:name] = raw_link[(start_pos + 34)..-1] if options[:name].blank?
|
||||
link = raw_link.gsub('http://', 'https://')
|
||||
elsif raw_link[0] =~ /\w/
|
||||
elsif /\w/.match?(raw_link[0])
|
||||
options[:name] = raw_link if options[:name].blank?
|
||||
link = "https://www.redmine.org/projects/redmine/wiki/#{Wiki.titleize(raw_link)}"
|
||||
else
|
||||
|
|
|
@ -26,12 +26,10 @@ module Additionals
|
|||
raise 'The correct usage is {{slideshare(<key>[, width=x, height=y, slide=number])}}' if args.empty?
|
||||
|
||||
v = args[0]
|
||||
src = if slide > 0
|
||||
'//www.slideshare.net/slideshow/embed_code/' + v + '?startSlide=' + slide.to_s
|
||||
else
|
||||
'//www.slideshare.net/slideshow/embed_code/' + v
|
||||
end
|
||||
content_tag(:iframe, '', width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true')
|
||||
src = "//www.slideshare.net/slideshow/embed_code/#{v}"
|
||||
src += "?startSlide=#{slide}" if slide.positive?
|
||||
|
||||
tag.iframe width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,25 +25,28 @@ module Additionals
|
|||
|
||||
user_id = args[0]
|
||||
|
||||
user = User.find_by(id: user_id)
|
||||
user = User.find_by id: user_id
|
||||
user ||= User.find_by(login: user_id)
|
||||
return if user.nil?
|
||||
|
||||
name = if options[:format].blank?
|
||||
user.name
|
||||
else
|
||||
user.name(options[:format].to_sym)
|
||||
user.name options[:format].to_sym
|
||||
end
|
||||
|
||||
s = []
|
||||
s << avatar(user, size: 14) + ' ' if options[:avatar].present? && options[:avatar]
|
||||
if options[:avatar].present? && options[:avatar]
|
||||
s << avatar(user, size: 14)
|
||||
s << ' '
|
||||
end
|
||||
|
||||
s << if user.active?
|
||||
link_to(h(name), user_url(user, only_path: controller_path != 'mailer'), class: user.css_classes)
|
||||
link_to h(name), user_url(user, only_path: controller_path != 'mailer'), class: user.css_classes
|
||||
else
|
||||
h(name)
|
||||
h name
|
||||
end
|
||||
safe_join(s)
|
||||
safe_join s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,11 +32,11 @@ module Additionals
|
|||
|
||||
v = args[0]
|
||||
src = if autoplay
|
||||
'//player.vimeo.com/video/' + v + '?autoplay=1'
|
||||
"//player.vimeo.com/video/#{v}?autoplay=1"
|
||||
else
|
||||
'//player.vimeo.com/video/' + v
|
||||
"//player.vimeo.com/video/#{v}"
|
||||
end
|
||||
content_tag(:iframe, '', width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true')
|
||||
tag.iframe width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,11 +32,11 @@ module Additionals
|
|||
|
||||
v = args[0]
|
||||
src = if autoplay
|
||||
'//www.youtube.com/embed/' + v + '?autoplay=1'
|
||||
"//www.youtube.com/embed/#{v}?autoplay=1"
|
||||
else
|
||||
'//www.youtube-nocookie.com/embed/' + v
|
||||
"//www.youtube-nocookie.com/embed/#{v}"
|
||||
end
|
||||
content_tag(:iframe, '', width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true')
|
||||
tag.iframe width: width, height: height, src: src, frameborder: 0, allowfullscreen: 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,7 +23,7 @@ namespace :redmine do
|
|||
Set settings.
|
||||
|
||||
Example for value:
|
||||
bundle exec rake redmine:additionals:setting_set RAILS_ENV=production name="additionals" setting="external_urls" value="2"
|
||||
bundle exec rake redmine:additionals:setting_set RAILS_ENV=production name="additionals" setting="open_external_urls" value="1"
|
||||
Example for list of value:
|
||||
bundle exec rake redmine:additionals:setting_set RAILS_ENV=production setting="default_projects_modules" value="issue_tracking,time_tracking,wiki"
|
||||
DESCRIPTION
|
||||
|
@ -55,7 +55,7 @@ namespace :redmine do
|
|||
Get settings.
|
||||
|
||||
Example for plugin setting:
|
||||
bundle exec rake redmine:additionals:setting_get RAILS_ENV=production name="additionals" setting="external_urls"
|
||||
bundle exec rake redmine:additionals:setting_get RAILS_ENV=production name="additionals" setting="open_external_urls"
|
||||
Example for redmine setting:
|
||||
bundle exec rake redmine:additionals:setting_get RAILS_ENV=production name="redmine" setting="app_title"
|
||||
Example for redmine setting:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue