├── config ├── routes.rb └── locales │ ├── de.yml │ ├── ru.yml │ ├── en.yml │ └── fr.yml ├── assets ├── images │ └── screenshot.png └── stylesheets │ └── core_fields.css ├── app ├── helpers │ └── core_fields_helper.rb ├── views │ ├── issues │ │ ├── _customized_form.html.erb │ │ ├── edit.js.erb │ │ ├── new.js.erb │ │ └── _form_with_positions.html.erb │ ├── settings │ │ └── _redmine_plugin_customize_core_fields.html.erb │ └── core_fields │ │ ├── index.html.erb │ │ └── edit.html.erb ├── overrides │ └── issues │ │ └── new.rb ├── models │ └── core_field.rb └── controllers │ └── core_fields_controller.rb ├── db └── migrate │ ├── 003_index_foreign_keys_in_core_fields_roles.rb │ ├── 20250701160618_add_missing_indexes.rb │ ├── 004_unique_index_for_identifier_in_core_fields.rb │ ├── 001_create_core_fields_table.rb │ └── 002_create_core_fields_roles.rb ├── spec ├── helpers │ └── core_fields_helper_spec.rb ├── controllers │ └── core_fields_controller_spec.rb └── models │ ├── issue_patch_spec.rb │ ├── core_field_spec.rb │ └── journal_patch_spec.rb ├── lib └── redmine_customize_core_fields │ ├── role_patch.rb │ ├── issue_patch.rb │ ├── project_patch.rb │ ├── journal_patch.rb │ ├── hooks.rb │ └── query_patch.rb ├── init.rb ├── README.md └── .github └── workflows ├── 6_0_7.yml ├── 6_1_0.yml └── master.yml /config/routes.rb: -------------------------------------------------------------------------------- 1 | resources :core_fields, :only => [:index, :edit, :update] 2 | -------------------------------------------------------------------------------- /assets/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanego/redmine_customize_core_fields/HEAD/assets/images/screenshot.png -------------------------------------------------------------------------------- /app/helpers/core_fields_helper.rb: -------------------------------------------------------------------------------- 1 | module CoreFieldsHelper 2 | include ApplicationHelper 3 | 4 | def core_field_title(field) 5 | l("field_#{field}".sub(/_id$/, '')) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /assets/stylesheets/core_fields.css: -------------------------------------------------------------------------------- 1 | #admin-menu a.redmine-customize-core-fields { 2 | background-image: url(../../../images/textfield.png); 3 | } 4 | form .attributes select { max-width: 40em; } 5 | -------------------------------------------------------------------------------- /db/migrate/003_index_foreign_keys_in_core_fields_roles.rb: -------------------------------------------------------------------------------- 1 | class IndexForeignKeysInCoreFieldsRoles < ActiveRecord::Migration[4.2] 2 | def change 3 | add_index :core_fields_roles, :role_id 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250701160618_add_missing_indexes.rb: -------------------------------------------------------------------------------- 1 | class AddMissingIndexes < ActiveRecord::Migration[6.1] 2 | def change 3 | add_index :core_fields_roles, :core_field_id, if_not_exists: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/004_unique_index_for_identifier_in_core_fields.rb: -------------------------------------------------------------------------------- 1 | class UniqueIndexForIdentifierInCoreFields < ActiveRecord::Migration[4.2] 2 | def change 3 | add_index :core_fields, :identifier, unique: true, name: :unique_identifier 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/locales/de.yml: -------------------------------------------------------------------------------- 1 | # German strings go here for Rails i18n 2 | de: 3 | visible_by_all_roles: "Für alle Rollen sichtbar" 4 | override_issue_form: "Ticket-Formular anpassen" 5 | display_custom_fields_first: "Benutzerdefinierte Felder zuerst im Formular anzeigen?" 6 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | project_module_customize_core_fields: Видимость стандартных полей 3 | visible_by_all_roles: Видно всем ролям 4 | override_issue_form: Изменять форму редактирования задачи? 5 | display_custom_fields_first: Отображать настраиваемые поля на форме первыми? 6 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | project_module_customize_core_fields: Core fields visibility 4 | visible_by_all_roles: Visible by all roles 5 | override_issue_form: Customize issue form 6 | display_custom_fields_first: Display custom fields first in form 7 | -------------------------------------------------------------------------------- /spec/helpers/core_fields_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CoreFieldsHelper, type: :helper do 4 | 5 | it "should convert core field identifier to human friendly name" do 6 | field_name = core_field_title("done_ratio") 7 | expect(field_name).to eq "% Done" 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /app/views/issues/_customized_form.html.erb: -------------------------------------------------------------------------------- 1 | <% if Setting['plugin_redmine_customize_core_fields']['override_issue_form'] == 'true' && @issue.project.module_enabled?("customize_core_fields") %> 2 | <%= render :partial => 'issues/form_with_positions', :locals => {:f => f} %> 3 | <% else %> 4 | <%= render :partial => 'issues/form', :locals => {:f => f} %> 5 | <% end %> 6 | -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | # French strings go here for Rails i18n 2 | fr: 3 | project_module_customize_core_fields: Personnaliser les champs standards 4 | visible_by_all_roles: Visible par tous les rôles 5 | override_issue_form: Prendre en compte les positions des champs dans le formulaire des demandes 6 | display_custom_fields_first: Afficher les champs personnalisés en premier dans le formulaire 7 | -------------------------------------------------------------------------------- /lib/redmine_customize_core_fields/role_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'role' 2 | 3 | module RedmineCustomizeCoreFields 4 | module RolePatch 5 | def self.included(base) 6 | base.class_eval do 7 | has_and_belongs_to_many :core_fields, :join_table => "core_fields_roles", :foreign_key => "role_id" 8 | end 9 | end 10 | end 11 | end 12 | 13 | Role.include RedmineCustomizeCoreFields::RolePatch 14 | -------------------------------------------------------------------------------- /db/migrate/001_create_core_fields_table.rb: -------------------------------------------------------------------------------- 1 | class CreateCoreFieldsTable < ActiveRecord::Migration[4.2] 2 | 3 | def self.up 4 | create_table 'core_fields', :force => true do |t| 5 | t.column 'identifier', :string, :null => false 6 | t.column 'position', :integer 7 | t.column 'visible', :boolean, default: true, :null => false 8 | end 9 | end 10 | 11 | def self.down 12 | drop_table :core_fields 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/redmine_customize_core_fields/issue_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'issue' 2 | 3 | module RedmineCustomizeCoreFields 4 | module IssuePatch 5 | def disabled_core_fields(user = User.current) 6 | disabled_core_fields = tracker ? tracker.disabled_core_fields : [] 7 | disabled_core_fields |= project.disabled_core_fields(user) if project.present? 8 | disabled_core_fields 9 | end 10 | end 11 | end 12 | 13 | Issue.prepend RedmineCustomizeCoreFields::IssuePatch 14 | -------------------------------------------------------------------------------- /db/migrate/002_create_core_fields_roles.rb: -------------------------------------------------------------------------------- 1 | class CreateCoreFieldsRoles < ActiveRecord::Migration[4.2] 2 | def self.up 3 | create_table :core_fields_roles, :id => false do |t| 4 | t.column :core_field_id, :integer, :null => false 5 | t.column :role_id, :integer, :null => false 6 | end 7 | add_index :core_fields_roles, [:core_field_id, :role_id], :unique => true, :name => :core_fields_roles_ids 8 | end 9 | 10 | def self.down 11 | drop_table :core_fields_roles 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/redmine_customize_core_fields/project_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'project' 2 | 3 | module RedmineCustomizeCoreFields 4 | module ProjectPatch 5 | def self.included(base) 6 | base.class_eval do 7 | attr_accessor :disabled_core_fields 8 | 9 | def disabled_core_fields(user = User.current) 10 | @disabled_core_fields ||= CoreField.not_visible_identifiers(self, user) 11 | end 12 | 13 | end 14 | end 15 | end 16 | end 17 | 18 | Project.include RedmineCustomizeCoreFields::ProjectPatch 19 | -------------------------------------------------------------------------------- /lib/redmine_customize_core_fields/journal_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineCustomizeCoreFields 2 | module JournalPatch 3 | 4 | def visible_details(user = User.current) 5 | details = super(user) 6 | return details if user.admin? || issue.nil? 7 | 8 | disabled_fields = issue.disabled_core_fields(user) 9 | details.reject { |detail| 10 | detail.property == 'attr' && disabled_fields.include?(detail.prop_key) 11 | } 12 | end 13 | 14 | end 15 | end 16 | 17 | Journal.prepend RedmineCustomizeCoreFields::JournalPatch 18 | -------------------------------------------------------------------------------- /app/views/issues/edit.js.erb: -------------------------------------------------------------------------------- 1 | <% if Setting['plugin_redmine_customize_core_fields']['override_issue_form'] == 'true' && @issue.project.module_enabled?("customize_core_fields") %> 2 | replaceIssueFormWith('<%= escape_javascript(render :partial => 'issues/form_with_positions') %>'); 3 | <% else %> 4 | replaceIssueFormWith('<%= escape_javascript(render :partial => 'issues/form') %>'); 5 | <% end %> 6 | 7 | <% if User.current.allowed_to?(:log_time, @issue.project) %> 8 | $('#log_time').show(); 9 | <% else %> 10 | $('#log_time').hide(); 11 | <% end %> 12 | -------------------------------------------------------------------------------- /app/overrides/issues/new.rb: -------------------------------------------------------------------------------- 1 | Deface::Override.new :virtual_path => 'issues/new', 2 | :name => 'replace_default_new_form', 3 | :replace => "erb[loud]:contains(\"render :partial => 'issues/form', :locals => {:f => f}\")", 4 | :partial => 'issues/customized_form' 5 | 6 | Deface::Override.new :virtual_path => 'issues/_edit', 7 | :name => 'replace_default_form', 8 | :replace => "erb[loud]:contains(\"render :partial => 'form'\")", 9 | :partial => 'issues/customized_form' 10 | -------------------------------------------------------------------------------- /lib/redmine_customize_core_fields/hooks.rb: -------------------------------------------------------------------------------- 1 | module RedmineCustomizeCoreFields 2 | class Hooks < Redmine::Hook::ViewListener 3 | # Add our css/js on each page 4 | def view_layouts_base_html_head(context) 5 | stylesheet_link_tag('core_fields.css', plugin: 'redmine_customize_core_fields') 6 | end 7 | end 8 | 9 | class ModelHook < Redmine::Hook::Listener 10 | def after_plugins_loaded(_context = {}) 11 | require_relative 'issue_patch' 12 | require_relative 'journal_patch' 13 | require_relative 'query_patch' 14 | require_relative 'role_patch' 15 | require_relative 'project_patch' 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/views/settings/_redmine_plugin_customize_core_fields.html.erb: -------------------------------------------------------------------------------- 1 |
| <%=l(:field_name)%> | 7 |<%=l(:visible_by_all_roles)%> | 8 |9 | |
|---|---|---|
| 15 | <%= link_to core_field_title(field), edit_core_field_path(field) %> 16 | | 17 |<%= checked_image field.visible %> | 18 |19 | <%= reorder_handle(field, :url => core_field_path(field), :param => 'core_field') %> 20 | | 21 |
<%= l(:label_no_data) %>
27 | <% end %> 28 | 29 | <%= javascript_tag do %> 30 | $(function() { $("table.core_fields tbody").positionedItems(); }); 31 | <% end %> 32 | -------------------------------------------------------------------------------- /lib/redmine_customize_core_fields/query_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'query' 2 | 3 | module RedmineCustomizeCoreFields 4 | module QueryPatch 5 | def name_matches_disabled_fields? disabled_fields, name 6 | variations = [name, "#{name}_id", name.sub(/^total_/, '')] 7 | (disabled_fields & variations).any? 8 | end 9 | 10 | def available_filters 11 | filters = super 12 | disabled_fields = project.present? ? project.disabled_core_fields : [] 13 | filters.reject { |o| name_matches_disabled_fields? disabled_fields, o.to_s } 14 | end 15 | 16 | %i(groupable inline block available_inline available_block available_totalable).each do |prefix| 17 | define_method "#{prefix}_columns" do 18 | columns = super() 19 | disabled_fields = project.present? ? project.disabled_core_fields : [] 20 | columns.reject { |o| name_matches_disabled_fields? disabled_fields, o.name.to_s } 21 | end 22 | end 23 | end 24 | end 25 | 26 | Query.prepend RedmineCustomizeCoreFields::QueryPatch 27 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | require_relative 'lib/redmine_customize_core_fields/hooks' 3 | 4 | Redmine::Plugin.register :redmine_customize_core_fields do 5 | name 'Redmine Customize Core Fields plugin' 6 | author 'Vincent ROBERT' 7 | description 'This Redmine plugin lets you customize core fields' 8 | version '1.0.0' 9 | url 'https://github.com/nanego/redmine_customize_core_fields' 10 | author_url 'https://github.com/nanego' 11 | requires_redmine_plugin :redmine_base_rspec, :version_or_higher => '0.0.4' if Rails.env.test? 12 | requires_redmine_plugin :redmine_base_deface, :version_or_higher => '0.0.1' 13 | menu :admin_menu, :redmine_customize_core_fields, { :controller => 'core_fields', :action => 'index' }, :after => :custom_fields, :caption => :field_core_fields, html: { class: 'icon' } 14 | project_module :customize_core_fields do 15 | permission :update_core_fields, {} 16 | end 17 | settings :default => { 'override_issue_form' => 'false', 18 | 'display_custom_fields_first' => 'true' }, 19 | :partial => 'settings/redmine_plugin_customize_core_fields' 20 | end 21 | 22 | # Support for Redmine 5 23 | if Redmine::VERSION::MAJOR < 6 24 | class ApplicationRecord < ActiveRecord::Base 25 | self.abstract_class = true 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/models/core_field.rb: -------------------------------------------------------------------------------- 1 | class CoreField < ApplicationRecord 2 | include Redmine::SafeAttributes 3 | 4 | has_and_belongs_to_many :roles, :join_table => "core_fields_roles", :foreign_key => "core_field_id" 5 | acts_as_positioned 6 | 7 | safe_attributes :identifier, :id, :position, :visible, :role_ids 8 | 9 | after_save do |field| 10 | if field.visible_changed? && field.visible 11 | field.roles.clear 12 | end 13 | end 14 | 15 | scope :not_visible, lambda {|project, user = nil| 16 | user ||= User.current 17 | return none if user.admin? or (project.present? and !project.module_enabled?("customize_core_fields")) 18 | chain = where visible: false 19 | if project.present? 20 | chain = chain.where("#{table_name}.id NOT IN (SELECT DISTINCT cfr.core_field_id FROM #{Member.table_name} m" + 21 | " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" + 22 | " INNER JOIN #{table_name_prefix}core_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" + 23 | " WHERE m.user_id = ? AND m.project_id = ?)", 24 | user.id, project.id) 25 | end 26 | chain 27 | } 28 | 29 | def self.not_visible_identifiers(project, user = nil) 30 | not_visible(project, user).pluck(:identifier) 31 | end 32 | 33 | scope :sorted, lambda { order(:position) } 34 | 35 | def to_s 36 | identifier 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /app/views/core_fields/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% back_url = [] 2 | back_url << [l(:field_core_fields), core_fields_path] 3 | back_url << core_field_title(@field.identifier) 4 | %> 5 | <%= title(*back_url) %> 6 | 7 | <%= form_for :core_field, :url => core_field_path(id: @field.identifier), :html => {:method => :put, :id => 'core_field_form'} do |f| %> 8 | 9 |11 | 12 | 17 | 22 | <% Role.givable.sorted.each do |role| %> 23 | 27 | <% end %> 28 | <%= hidden_field_tag 'core_field[role_ids][]', '' %> 29 |
30 |<%= submit_tag l(:button_save) %>
33 | 34 | <% end %> 35 | -------------------------------------------------------------------------------- /app/controllers/core_fields_controller.rb: -------------------------------------------------------------------------------- 1 | class CoreFieldsController < ApplicationController 2 | 3 | CORE_FIELDS_ALL = Tracker::CORE_FIELDS_ALL + %w(status_id).freeze 4 | 5 | layout 'admin' 6 | 7 | before_action :require_admin 8 | before_action :find_core_field, :only => [:edit, :update] 9 | 10 | def index 11 | respond_to do |format| 12 | format.html { 13 | CORE_FIELDS_ALL.each_with_index do |field_identifier, index| 14 | field = CoreField.find_or_create_by!(:identifier => field_identifier) 15 | if field.position.blank? 16 | field.position = index + 1 17 | field.save 18 | end 19 | end 20 | @fields = CoreField.sorted 21 | } 22 | end 23 | end 24 | 25 | def edit 26 | end 27 | 28 | def update 29 | @field.safe_attributes = params[:core_field] 30 | if @field.save 31 | respond_to do |format| 32 | format.html { 33 | flash[:notice] = l(:notice_successful_update) 34 | redirect_back_or_default edit_core_field_path(@field) 35 | } 36 | format.js { head 200 } 37 | end 38 | else 39 | respond_to do |format| 40 | format.html { render :action => 'edit' } 41 | format.js { head 422 } 42 | end 43 | end 44 | end 45 | 46 | private 47 | 48 | def find_core_field 49 | if params[:id].to_i.to_s == params[:id] 50 | @field = CoreField.find_by_id(params[:id]) 51 | render_404 unless @field.present? 52 | else 53 | field_identifier = CORE_FIELDS_ALL.select { |f| f == params[:id] }.first 54 | render_404 unless field_identifier.present? 55 | @field = CoreField.find_by_identifier(field_identifier) if field_identifier.present? 56 | @field = CoreField.create!(:identifier => field_identifier) if @field.nil? && field_identifier.present? 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Redmine plugin - Let's customize core fields 2 | ============ 3 | 4 | This Redmine plugin lets you customize core fields behaviour. 5 | 6 | You will be able to hide or show core fields based on the user roles per project. 7 | 8 | Screenshot 9 | ------------ 10 | 11 |  12 | 13 | Installation 14 | ------------ 15 | 16 | This plugin is compatible with Redmine 2.1+ and has been successfully tested on Redmine 3.2. 17 | 18 | Please apply general instructions for plugins [here](http://www.redmine.org/wiki/redmine/Plugins). 19 | 20 | Note that this plugin now depends on this other plugin: 21 | * **redmine_base_deface** [here](https://github.com/jbbarth/redmine_base_deface) 22 | 23 | First download the source or clone the plugin and put it in the "plugins/" directory of your redmine instance. Note that this is crucial that the directory is named 'redmine_customize_core_fields'! 24 | 25 | Then execute: 26 | 27 | $ bundle install 28 | $ rake redmine:plugins 29 | 30 | And finally restart your Redmine instance. 31 | 32 | Test status 33 | ------------ 34 | 35 | | Plugin branch | Redmine Version | Test Status | 36 | |---------------|-----------------|-------------------| 37 | | master | 6.1.0 | [![6.1.0][1]][5] | 38 | | master | 6.0.7 | [![6.0.7][2]][5] | 39 | | master | master | [![master][4]][5] | 40 | 41 | [1]: https://github.com/nanego/redmine_customize_core_fields/actions/workflows/6_1_0.yml/badge.svg 42 | [2]: https://github.com/nanego/redmine_customize_core_fields/actions/workflows/6_0_7.yml/badge.svg 43 | [3]: https://github.com/nanego/redmine_customize_core_fields/actions/workflows/master.yml/badge.svg 44 | [5]: https://github.com/nanego/redmine_customize_core_fields/actions 45 | 46 | 47 | Contributing 48 | ------------ 49 | 50 | 1. Fork it 51 | 2. Create your feature branch (`git checkout -b my-new-feature`) 52 | 3. Commit your changes (`git commit -am 'Add some feature'`) 53 | 4. Push to the branch (`git push origin my-new-feature`) 54 | 5. Create new Pull Request 55 | -------------------------------------------------------------------------------- /spec/models/issue_patch_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Issue do 4 | fixtures :projects, :users, :roles, :members, :member_roles, :issues, :issue_statuses, 5 | :versions, :trackers, :projects_trackers, :issue_categories, :enabled_modules, :enumerations 6 | 7 | let(:project) { Project.find(1) } 8 | let(:tracker) { project.trackers.first } 9 | let(:issue) { Issue.create!(project: project, tracker: tracker, author_id: 1, subject: 'Test issue') } 10 | let(:admin_user) { User.find(1) } 11 | let(:non_admin_user) { User.find(2) } 12 | 13 | before do 14 | project.enable_module!('customize_core_fields') 15 | end 16 | 17 | describe '#disabled_core_fields' do 18 | context 'without any restrictions' do 19 | it 'returns empty array when no core fields are disabled' do 20 | disabled_fields = issue.disabled_core_fields(non_admin_user) 21 | expect(disabled_fields).to eq([]) 22 | end 23 | end 24 | 25 | context 'with tracker-level restrictions' do 26 | it 'includes tracker disabled core fields' do 27 | # Mock tracker disabled fields 28 | allow(tracker).to receive(:disabled_core_fields).and_return(['assigned_to_id']) 29 | 30 | disabled_fields = issue.disabled_core_fields(non_admin_user) 31 | 32 | expect(disabled_fields).to include('assigned_to_id') 33 | end 34 | end 35 | 36 | context 'with project-level restrictions' do 37 | it 'includes project disabled core fields for user' do 38 | # Create project-level restrictions 39 | CoreField.create!(identifier: 'category_id', visible: false) 40 | 41 | disabled_fields = issue.disabled_core_fields(non_admin_user) 42 | 43 | expect(disabled_fields).to include('category_id') 44 | end 45 | 46 | it 'combines tracker and project restrictions' do 47 | # Mock tracker restriction 48 | allow(tracker).to receive(:disabled_core_fields).and_return(['assigned_to_id']) 49 | 50 | # Create project restriction 51 | CoreField.create!(identifier: 'category_id', visible: false) 52 | 53 | disabled_fields = issue.disabled_core_fields(non_admin_user) 54 | 55 | expect(disabled_fields).to include('assigned_to_id', 'category_id') 56 | expect(disabled_fields.uniq).to eq(disabled_fields) # No duplicates 57 | end 58 | end 59 | 60 | context 'with role-based restrictions' do 61 | it 'excludes fields that user has role access to' do 62 | # Create a role and assign to user 63 | role = Role.create!(name: 'Special Role', permissions: []) 64 | member = Member.find_by(user: non_admin_user, project: project) 65 | member.roles = [role] 66 | member.save! 67 | 68 | # Create core field restricted but accessible to this role 69 | field = CoreField.find_or_create_by!(identifier: 'assigned_to_id', visible: false) 70 | field.role_ids = [role.id] 71 | 72 | disabled_fields = issue.disabled_core_fields(non_admin_user) 73 | 74 | # Should not include the field since user has the required role 75 | expect(disabled_fields).not_to include('assigned_to_id') 76 | end 77 | 78 | it 'includes fields that user does not have role access to' do 79 | # Create a role NOT assigned to user 80 | other_role = Role.create!(name: 'Other Role', permissions: []) 81 | 82 | # Create core field restricted to other role only 83 | field = CoreField.find_or_create_by!(identifier: 'assigned_to_id', visible: false) 84 | field.role_ids = [other_role.id] 85 | 86 | disabled_fields = issue.disabled_core_fields(non_admin_user) 87 | 88 | # Should include the field since user doesn't have the required role 89 | expect(disabled_fields).to include('assigned_to_id') 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/models/core_field_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CoreField do 4 | 5 | fixtures :roles, :projects, 6 | :trackers, :issue_statuses, 7 | :issues, :members, :users, :member_roles, :roles 8 | 9 | describe 'create and destroy' do 10 | it 'creates a record for the given core field' do 11 | field = CoreField.new(:identifier => 'assigned_to_id') 12 | expect(field.save).to eq true 13 | expect(field.visible).to eq true 14 | end 15 | 16 | it 'destroys a record' do 17 | field = CoreField.create(:identifier => 'assigned_to_id') 18 | expect(field.destroy).to eq field 19 | end 20 | end 21 | 22 | describe 'core fields visibility' do 23 | it 'tests visibility scope with admin user' do 24 | CoreField.delete_all 25 | fields = [ 26 | CoreField.create!(identifier: 'assigned_to_id', :visible => true), 27 | CoreField.create!(identifier: 'project_id', :visible => false), 28 | CoreField.create!(identifier: 'category_id', :visible => false, :role_ids => [1, 2]), 29 | CoreField.create!(identifier: 'tracker_id', :visible => false, :role_ids => [2, 3]), 30 | ] 31 | user = User.first # user_id: 1 32 | User.current = user 33 | project = Project.find_by_id(1) 34 | project.enable_module! 'customize_core_fields' 35 | expect(CoreField.not_visible(project).order("id").to_a).to eq [] 36 | end 37 | 38 | it 'tests visibility scope with non admin user' do 39 | CoreField.delete_all 40 | fields = [ 41 | CoreField.create!(identifier: 'assigned_to_id', :visible => true), 42 | CoreField.create!(identifier: 'project_id', :visible => false), 43 | CoreField.create!(identifier: 'category_id', :visible => false, :role_ids => [1, 2]), 44 | CoreField.create!(identifier: 'tracker_id', :visible => false, :role_ids => [2, 3]), 45 | ] 46 | membership = Member.first # user_id: 2, project_id: 1, roles: [1] 47 | User.current = membership.user 48 | project = membership.project 49 | project.enable_module! 'customize_core_fields' 50 | expect(CoreField.not_visible(project).order("id").to_a).to eq [fields[1], fields[3]] 51 | end 52 | 53 | it 'tests visibility with anonymous user' do 54 | CoreField.delete_all 55 | fields = [ 56 | CoreField.create!(identifier: 'assigned_to_id', :visible => true), 57 | CoreField.create!(identifier: 'project_id', :visible => false), 58 | CoreField.create!(identifier: 'category_id', :visible => false, :role_ids => [1, 2]), 59 | CoreField.create!(identifier: 'tracker_id', :visible => false, :role_ids => [2, 3]), 60 | ] 61 | User.current = User.anonymous 62 | project = Project.find_by_id(1) 63 | project.enable_module! 'customize_core_fields' 64 | expect(CoreField.not_visible(project).order("id").to_a).to eq [fields[1], fields[2], fields[3]] 65 | end 66 | 67 | it 'tests visibility with disabled module' do 68 | CoreField.delete_all 69 | fields = [ 70 | CoreField.create!(identifier: 'assigned_to_id', :visible => true), 71 | CoreField.create!(identifier: 'project_id', :visible => false), 72 | CoreField.create!(identifier: 'category_id', :visible => false, :role_ids => [1, 2]), 73 | CoreField.create!(identifier: 'tracker_id', :visible => false, :role_ids => [2, 3]), 74 | ] 75 | User.current = User.anonymous 76 | project = Project.find_by_id(1) 77 | project.disable_module! 'customize_core_fields' 78 | expect(CoreField.not_visible(project).order("id").to_a).to eq [] 79 | end 80 | end 81 | 82 | describe 'update the table core_fields_roles in case of cascade deleting' do 83 | it "when delete a role" do 84 | role_test = Role.create!(:name => 'Test') 85 | CoreField.create!(identifier: 'project_id', :visible => true, :role_ids => [1, role_test.id]) 86 | expect(ActiveRecord::Base.connection.execute('select * from core_fields_roles').count).to eq(2) 87 | role_test.destroy 88 | expect(ActiveRecord::Base.connection.execute('select * from core_fields_roles').count).to eq(1) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/models/journal_patch_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require_relative '../../lib/redmine_customize_core_fields/journal_patch' 4 | 5 | describe Journal do 6 | fixtures :projects, :users, :roles, :members, :member_roles, :issues, :issue_statuses, 7 | :versions, :trackers, :projects_trackers, :issue_categories, :enabled_modules, 8 | :enumerations, :attachments, :workflows, :custom_fields, :custom_values, 9 | :custom_fields_projects, :custom_fields_trackers, :time_entries, 10 | :journals, :journal_details 11 | 12 | let(:project) { Project.find(1) } 13 | let(:tracker) { project.trackers.first } 14 | let(:issue) { Issue.create!(project: project, tracker: tracker, author_id: 1, subject: 'Test issue') } 15 | let(:admin_user) { User.find(1) } 16 | let(:non_admin_user) { User.find(2) } 17 | let(:journal) { Journal.create!(journalized: issue, user: admin_user) } 18 | 19 | before do 20 | project.enable_module!('customize_core_fields') 21 | end 22 | 23 | describe '#visible_details' do 24 | context 'with admin user' do 25 | it 'shows all details regardless of core field configuration' do 26 | # Create some core field restrictions 27 | CoreField.find_or_create_by!(identifier: 'assigned_to_id', visible: false) 28 | 29 | # Create journal details 30 | journal.details.create!(property: 'attr', prop_key: 'assigned_to_id', old_value: '', value: '2') 31 | journal.details.create!(property: 'attr', prop_key: 'subject', old_value: 'Old', value: 'New') 32 | journal.details.create!(property: 'cf', prop_key: '1', old_value: '', value: 'test') 33 | 34 | visible_details = journal.visible_details(admin_user) 35 | 36 | expect(visible_details.size).to eq(3) 37 | expect(visible_details.map(&:prop_key)).to include('assigned_to_id', 'subject', '1') 38 | end 39 | end 40 | 41 | context 'with non-admin user' do 42 | context 'when core fields are not restricted' do 43 | it 'shows all details when no restrictions are configured' do 44 | # No core field restrictions 45 | 46 | # Create journal details 47 | journal.details.create!(property: 'attr', prop_key: 'assigned_to_id', old_value: '', value: '2') 48 | journal.details.create!(property: 'attr', prop_key: 'subject', old_value: 'Old', value: 'New') 49 | journal.details.create!(property: 'cf', prop_key: '1', old_value: '', value: 'test') 50 | 51 | visible_details = journal.visible_details(non_admin_user) 52 | 53 | expect(visible_details.size).to eq(3) 54 | expect(visible_details.map(&:prop_key)).to include('assigned_to_id', 'subject', '1') 55 | end 56 | end 57 | 58 | context 'when core fields are restricted' do 59 | it 'filters out restricted core fields for non-admin users' do 60 | # Create core field restrictions for non-admin users 61 | field = CoreField.find_or_create_by!(identifier: 'assigned_to_id', visible: false) 62 | field.role_ids = [] 63 | field.save! 64 | 65 | # Create journal details 66 | journal.details.create!(property: 'attr', prop_key: 'assigned_to_id', old_value: '', value: '2') 67 | journal.details.create!(property: 'attr', prop_key: 'subject', old_value: 'Old', value: 'New') 68 | journal.details.create!(property: 'cf', prop_key: '1', old_value: '', value: 'test') 69 | 70 | visible_details = journal.visible_details(non_admin_user) 71 | 72 | # Should filter out 'assigned_to_id' but keep 'subject' and custom field 73 | expect(visible_details.size).to eq(2) 74 | expect(visible_details.map(&:prop_key)).to include('subject', '1') 75 | expect(visible_details.map(&:prop_key)).not_to include('assigned_to_id') 76 | end 77 | 78 | it 'keeps custom fields even when core fields are restricted' do 79 | # Create core field restrictions 80 | CoreField.find_or_create_by!(identifier: 'assigned_to_id', visible: false) 81 | CoreField.find_or_create_by!(identifier: 'subject', visible: false) 82 | 83 | # Create journal details including custom field 84 | journal.details.create!(property: 'attr', prop_key: 'assigned_to_id', old_value: '', value: '2') 85 | journal.details.create!(property: 'cf', prop_key: '1', old_value: '', value: 'test') 86 | journal.details.create!(property: 'relation', prop_key: 'relates', old_value: '', value: '3') 87 | 88 | visible_details = journal.visible_details(non_admin_user) 89 | 90 | # Should keep custom field and relation but filter core field 91 | expect(visible_details.size).to eq(2) 92 | expect(visible_details.map(&:prop_key)).to include('1', 'relates') 93 | expect(visible_details.map(&:prop_key)).not_to include('assigned_to_id') 94 | end 95 | end 96 | end 97 | 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /.github/workflows/6_0_7.yml: -------------------------------------------------------------------------------- 1 | name: Tests 6.0.7 2 | 3 | env: 4 | PLUGIN_NAME: redmine_customize_core_fields 5 | REDMINE_VERSION: 6.0.7 6 | RAILS_ENV: test 7 | 8 | on: 9 | push: 10 | pull_request: 11 | 12 | jobs: 13 | test: 14 | name: ${{ github.workflow }} ${{ matrix.db }} ruby-${{ matrix.ruby }} 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | ruby: ['3.3'] 20 | db: ['postgres'] 21 | fail-fast: false 22 | 23 | services: 24 | postgres: 25 | image: postgres:13 26 | env: 27 | POSTGRES_DB: redmine 28 | POSTGRES_USER: postgres 29 | POSTGRES_PASSWORD: postgres 30 | ports: 31 | - 5432:5432 32 | options: >- 33 | --health-cmd pg_isready 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | 38 | steps: 39 | - name: Checkout Redmine 40 | uses: actions/checkout@v4 41 | with: 42 | repository: redmine/redmine 43 | ref: ${{ env.REDMINE_VERSION }} 44 | path: redmine 45 | 46 | - name: Update package archives 47 | run: sudo apt-get update --yes --quiet 48 | 49 | - name: Install package dependencies 50 | run: > 51 | sudo apt-get update && sudo apt-get install --yes --quiet 52 | build-essential 53 | cmake 54 | libicu-dev 55 | libpq-dev 56 | ghostscript 57 | gsfonts 58 | 59 | - name: Set up chromedriver 60 | uses: nanasess/setup-chromedriver@master 61 | - run: | 62 | export DISPLAY=:99 63 | chromedriver --url-base=/wd/hub & 64 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional 65 | 66 | - name: Allow imagemagick to read PDF files 67 | run: | 68 | echo '29 | <%= f.check_box :is_private, :no_label => true %> 30 | 31 |
32 | <% end %> 33 | 34 | <% when 'project_id' %> 35 | 36 | <% projects = projects_for_select(@issue) %> 37 | <% if (@issue.safe_attribute?('project_id') || @issue.project_id_changed?) && (@project.nil? || projects.length > 1 || @issue.copy?) %> 38 |<%= f.select :project_id, project_tree_options_for_select(projects, :selected => @issue.project), { :required => true }, 39 | :onchange => "updateIssueFrom('#{escape_javascript update_issue_form_path(@project, @issue)}', this)" %>
40 | <% end %> 41 | 42 | <% when 'tracker_id' %> 43 | 44 | <% if @issue.safe_attribute?('tracker_id') || (@issue.persisted? && @issue.tracker_id_changed?) %> 45 |<%= f.select :tracker_id, trackers_options_for_select(@issue), { :required => true }, 46 | :onchange => "updateIssueFrom('#{escape_javascript update_issue_form_path(@project, @issue)}', this)", 47 | :title => @issue.tracker.description %>
48 | <% end %> 49 | 50 | <% when 'subject' %> 51 | 52 | <% if @issue.safe_attribute? 'subject' %> 53 |<%= f.text_field :subject, :size => 80, :maxlength => 255, :required => true %>
54 | <% end %> 55 | 56 | <% when 'description' %> 57 | 58 | <% if @issue.safe_attribute? 'description' %> 59 |60 | <%= f.label_for_field :description, :required => @issue.required_attribute?('description') %> 61 | <%= link_to_function content_tag(:span, l(:button_edit), :class => 'icon icon-edit'), '$(this).hide(); $("#issue_description_and_toolbar").show()' unless @issue.new_record? %> 62 | <%= content_tag 'span', :id => "issue_description_and_toolbar", :style => (@issue.new_record? ? nil : 'display:none') do %> 63 | <%= f.text_area :description, 64 | :cols => 60, 65 | :rows => [[10, @issue.description.to_s.length / 50].max, 20].min, 66 | :accesskey => accesskey(:edit), 67 | :class => 'wiki-edit', 68 | :no_label => true %> 69 | <% end %> 70 |
71 | <%= wikitoolbar_for 'issue_description', preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) %> 72 | <% end %> 73 | 74 | <% when 'status_id' %> 75 | 76 | <% if @issue.safe_attribute?('status_id') && @allowed_statuses.present? %> 77 |<%= f.select :status_id, (@allowed_statuses.collect { |p| [p.name, p.id] }), { :required => true }, 78 | :onchange => "updateIssueFrom('#{escape_javascript update_issue_form_path(@project, @issue)}', this)" %>
79 | <%= hidden_field_tag 'was_default_status', @issue.status_id, :id => nil if @issue.status == @issue.default_status %> 80 | <% else %> 81 |<%= @issue.status %>
82 | <% end %> 83 | 84 | <% when 'priority_id' %> 85 | 86 | <% if @issue.safe_attribute? 'priority_id' %> 87 |<%= f.select :priority_id, (@priorities.collect { |p| [p.name, p.id] }), { :required => true } %>
88 | <% end %> 89 | 90 | <% when 'assigned_to_id' %> 91 | 92 | <% if @issue.safe_attribute? 'assigned_to_id' %> 93 |94 | <%= f.select :assigned_to_id, principals_options_for_select(@issue.assignable_users, @issue.assigned_to), :include_blank => true, :required => @issue.required_attribute?('assigned_to_id') %> 95 | <% if @issue.assignable_users.include?(User.current) %> 96 | 97 | <% end %> 98 |
99 | <% end %> 100 | 101 | <% when 'category_id' %> 102 | 103 | <% if @issue.safe_attribute?('category_id') && @issue.project.issue_categories.any? %> 104 |<%= f.select :category_id, (@issue.project.issue_categories.collect { |c| [c.name, c.id] }), { :include_blank => true, :required => @issue.required_attribute?('category_id') }, :onchange => ("updateIssueFrom('#{escape_javascript update_issue_form_path(@project, @issue)}', this)" if @issue.new_record?) %> 105 | <%= link_to(l(:label_issue_category_new), 106 | new_project_issue_category_path(@issue.project), 107 | :remote => true, 108 | :method => 'get', 109 | :title => l(:label_issue_category_new), 110 | :tabindex => 200, 111 | :class => 'icon-only icon-add' 112 | ) if User.current.allowed_to?(:manage_categories, @issue.project) %>
113 | <% end %> 114 | 115 | <% when 'fixed_version_id' %> 116 | 117 | <% if @issue.safe_attribute?('fixed_version_id') && @issue.assignable_versions.any? %> 118 |<%= f.select :fixed_version_id, version_options_for_select(@issue.assignable_versions, @issue.fixed_version), :include_blank => true, :required => @issue.required_attribute?('fixed_version_id') %> 119 | <%= link_to(l(:label_version_new), 120 | new_project_version_path(@issue.project), 121 | :remote => true, 122 | :method => 'get', 123 | :title => l(:label_version_new), 124 | :tabindex => 200, 125 | :class => 'icon-only icon-add' 126 | ) if User.current.allowed_to?(:manage_versions, @issue.project) %> 127 |
128 | <% end %> 129 | 130 | <% when 'parent_issue_id' %> 131 | 132 | <% if @issue.safe_attribute? 'parent_issue_id' %> 133 |<%= f.text_field :parent_issue_id, :size => 10, :required => @issue.required_attribute?('parent_issue_id') %>
134 | <%= javascript_tag "observeAutocompleteField('issue_parent_issue_id', '#{escape_javascript auto_complete_issues_path(:project_id => @issue.project, :scope => Setting.cross_project_subtasks, :status => @issue.closed? ? 'c' : 'o', :issue_id => @issue.id)}')" %> 135 | <% end %> 136 | 137 | <% when 'start_date' %> 138 | 139 | <% if @issue.safe_attribute? 'start_date' %> 140 |141 | <%= f.date_field(:start_date, :size => 10, :required => @issue.required_attribute?('start_date')) %> 142 | <%= calendar_for('issue_start_date') %> 143 |
144 | <% end %> 145 | 146 | <% when 'due_date' %> 147 | 148 | <% if @issue.safe_attribute? 'due_date' %> 149 |150 | <%= f.date_field(:due_date, :size => 10, :required => @issue.required_attribute?('due_date')) %> 151 | <%= calendar_for('issue_due_date') %> 152 |
153 | <% end %> 154 | 155 | <% when 'estimated_hours' %> 156 | 157 | <% if @issue.safe_attribute? 'estimated_hours' %> 158 |<%= f.hours_field :estimated_hours, :size => 3, :required => @issue.required_attribute?('estimated_hours') %> <%= l(:field_hours) %>
159 | <% end %> 160 | 161 | <% when 'done_ratio' %> 162 | 163 | <% if @issue.safe_attribute?('done_ratio') && Issue.use_field_for_done_ratio? %> 164 |<%= f.select :done_ratio, ((0..10).to_a.collect { |r| ["#{r * 10} %", r * 10] }), :required => @issue.required_attribute?('done_ratio') %>
165 | <% end %> 166 | 167 | <% end %> 168 | 169 | <% end %> 170 | 171 |