From 62a054249fcd963c344cca14781f5f8974a3b121 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 15:11:32 +0530 Subject: [PATCH 01/41] Implement role-based permissions for editing and deleting custom entities --- app/views/custom_entities/show.html.erb | 7 ++++++- init.rb | 7 +++++++ .../custom_entities_controller_patch.rb | 15 ++++++++++++++ lib/custom_tables/record_restrict_patch.rb | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 lib/custom_tables/custom_entities_controller_patch.rb create mode 100644 lib/custom_tables/record_restrict_patch.rb diff --git a/app/views/custom_entities/show.html.erb b/app/views/custom_entities/show.html.erb index e3142c3..bd38924 100644 --- a/app/views/custom_entities/show.html.erb +++ b/app/views/custom_entities/show.html.erb @@ -2,7 +2,12 @@ <%= call_hook(:view_custom_table_table_field_action_menu) %> <%= link_to sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(@custom_entity, back_url: custom_entity_path(@custom_entity)), remote: true, class: 'icon icon-edit' %> <%= link_to sprite_icon('issue-edit',l(:button_new_comment)), new_note_custom_entity_path(@custom_entity), remote: true, class: 'icon icon-issue-edit' %> - <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon icon-del' %> + + <% allowed_roles = ['Administrator', 'Manager'] %> + <% if User.current.roles.any? { |r| allowed_roles.include?(r.name) } %> + <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon icon-del' %> + <% end %> + <%= title [l(:label_custom_tables), custom_tables_path], diff --git a/init.rb b/init.rb index 91f6e24..fdedeb2 100644 --- a/init.rb +++ b/init.rb @@ -24,3 +24,10 @@ end Dir[File.join(File.dirname(__FILE__), '/lib/custom_tables/**/*.rb')].each { |file| require_dependency file } + +require File.expand_path('../lib/custom_tables/custom_entities_controller_patch', __FILE__) + +Rails.configuration.to_prepare do + require_dependency 'custom_entities_controller' + CustomEntitiesController.include(CustomTables::CustomEntitiesControllerPatch) +end \ No newline at end of file diff --git a/lib/custom_tables/custom_entities_controller_patch.rb b/lib/custom_tables/custom_entities_controller_patch.rb new file mode 100644 index 0000000..f2c6405 --- /dev/null +++ b/lib/custom_tables/custom_entities_controller_patch.rb @@ -0,0 +1,15 @@ +module CustomTables + module CustomEntitiesControllerPatch + def self.included(base) + base.class_eval do + before_action :set_custom_permissions, only: [:index, :show] + + private + + def set_custom_permissions + @can_edit_or_delete = User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } + end + end + end + end +end diff --git a/lib/custom_tables/record_restrict_patch.rb b/lib/custom_tables/record_restrict_patch.rb new file mode 100644 index 0000000..55c6fe0 --- /dev/null +++ b/lib/custom_tables/record_restrict_patch.rb @@ -0,0 +1,20 @@ +module CustomTables + module RecordRestrictPatch + def self.included(base) + base.class_eval do + before_update :restrict_modifications + before_destroy :restrict_modifications + end + end + + private + + def restrict_modifications + allowed_roles = ['Administrator', 'Manager'] # You can change roles here + unless User.current.admin? || User.current.roles.any? { |r| allowed_roles.include?(r.name) } + errors.add(:base, 'You are not allowed to modify or delete this entry.') + throw :abort + end + end + end +end From 2dacaff94044897e135fd49d46e3dff5fe28efaa Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 15:19:15 +0530 Subject: [PATCH 02/41] Add authorization check to prevent unauthorized deletion of custom entities --- app/controllers/custom_entities_controller.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index 5bb28f2..437fe64 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -16,6 +16,8 @@ class CustomEntitiesController < ApplicationController accept_api_auth :show, :create, :update, :destroy + before_action :prevent_unauthorized_delete, only: [:destroy] + before_action :authorize_global before_action :find_custom_entity, only: [:show, :edit, :update, :add_belongs_to, :new_note] before_action :find_custom_entities, only: [:context_menu, :bulk_edit, :bulk_update, :destroy, :context_export] @@ -57,6 +59,14 @@ def new_note end end + def prevent_unauthorized_delete + allowed_roles = ['Administrator', 'Manager'] + unless User.current.admin? || User.current.roles.any? { |r| allowed_roles.include?(r.name) } + render_403 + end + end + + def create @custom_entity = CustomEntity.new(author: User.current, custom_table_id: params[:custom_entity][:custom_table_id]) @custom_entity.safe_attributes = params[:custom_entity] From 4100e8c2f2d925d700fd9cd8c59d09f1aa0f36a9 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 15:24:35 +0530 Subject: [PATCH 03/41] Refactor destroy action authorization to restrict access to administrators and managers --- app/controllers/custom_entities_controller.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index 437fe64..7e9609e 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -16,7 +16,8 @@ class CustomEntitiesController < ApplicationController accept_api_auth :show, :create, :update, :destroy - before_action :prevent_unauthorized_delete, only: [:destroy] + before_action :restrict_destroy_to_admin_and_manager, only: [:destroy] + before_action :authorize_global before_action :find_custom_entity, only: [:show, :edit, :update, :add_belongs_to, :new_note] @@ -59,14 +60,13 @@ def new_note end end - def prevent_unauthorized_delete + def restrict_destroy_to_admin_and_manager allowed_roles = ['Administrator', 'Manager'] unless User.current.admin? || User.current.roles.any? { |r| allowed_roles.include?(r.name) } render_403 end end - - + def create @custom_entity = CustomEntity.new(author: User.current, custom_table_id: params[:custom_entity][:custom_table_id]) @custom_entity.safe_attributes = params[:custom_entity] From 16678bf1baf4589f9573e332112168078bdeb3e5 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 15:29:11 +0530 Subject: [PATCH 04/41] Refactor authorization check for delete action to include admin role --- app/views/custom_entities/show.html.erb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/custom_entities/show.html.erb b/app/views/custom_entities/show.html.erb index bd38924..85cffc5 100644 --- a/app/views/custom_entities/show.html.erb +++ b/app/views/custom_entities/show.html.erb @@ -3,11 +3,11 @@ <%= link_to sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(@custom_entity, back_url: custom_entity_path(@custom_entity)), remote: true, class: 'icon icon-edit' %> <%= link_to sprite_icon('issue-edit',l(:button_new_comment)), new_note_custom_entity_path(@custom_entity), remote: true, class: 'icon icon-issue-edit' %> - <% allowed_roles = ['Administrator', 'Manager'] %> - <% if User.current.roles.any? { |r| allowed_roles.include?(r.name) } %> + <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon icon-del' %> <% end %> - + + <%= title [l(:label_custom_tables), custom_tables_path], From 4a039b19675c7a52d81e43bae2a5069e36b74c35 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 15:31:07 +0530 Subject: [PATCH 05/41] Remove delete action link for non-admin users from custom entity view --- app/views/custom_entities/show.html.erb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/views/custom_entities/show.html.erb b/app/views/custom_entities/show.html.erb index 85cffc5..bc0b85e 100644 --- a/app/views/custom_entities/show.html.erb +++ b/app/views/custom_entities/show.html.erb @@ -3,9 +3,7 @@ <%= link_to sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(@custom_entity, back_url: custom_entity_path(@custom_entity)), remote: true, class: 'icon icon-edit' %> <%= link_to sprite_icon('issue-edit',l(:button_new_comment)), new_note_custom_entity_path(@custom_entity), remote: true, class: 'icon icon-issue-edit' %> - <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> - <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon icon-del' %> - <% end %> + From c51ffba170e4300456d4ecef47c9e2385cc2707b Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 15:47:34 +0530 Subject: [PATCH 06/41] Implement role-based access control for delete actions in custom entities and tables --- app/views/custom_entities/context_menu.html.erb | 5 ++++- app/views/custom_entities/show.html.erb | 3 +-- app/views/custom_tables/_query_form.html.erb | 4 +++- app/views/custom_tables/context_menu.html.erb | 6 +++++- app/views/custom_tables/index.html.erb | 4 +++- app/views/issues/_query_custom_table.html.erb | 4 +++- app/views/table_fields/_index.html.erb | 5 ++++- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/views/custom_entities/context_menu.html.erb b/app/views/custom_entities/context_menu.html.erb index 33c433a..385e53c 100644 --- a/app/views/custom_entities/context_menu.html.erb +++ b/app/views/custom_entities/context_menu.html.erb @@ -17,5 +17,8 @@ <%= call_hook(:view_context_menu_custom_entities_export, { ids: @custom_entity_ids, back_url: @back, params: params, class_name: class_name }) %> -
  • <%= context_menu_link sprite_icon('del', l(:button_delete)), custom_entities_path(ids: @custom_entity_ids, back_url: @back), method: :delete, data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del', disabled: !@can[:delete] %>
  • + + <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> +
  • <%= context_menu_link sprite_icon('del', l(:button_delete)), custom_entities_path(ids: @custom_entity_ids, back_url: @back), method: :delete, data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del', disabled: !@can[:delete] %>
  • + <% end %> diff --git a/app/views/custom_entities/show.html.erb b/app/views/custom_entities/show.html.erb index bc0b85e..ef7282a 100644 --- a/app/views/custom_entities/show.html.erb +++ b/app/views/custom_entities/show.html.erb @@ -3,8 +3,7 @@ <%= link_to sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(@custom_entity, back_url: custom_entity_path(@custom_entity)), remote: true, class: 'icon icon-edit' %> <%= link_to sprite_icon('issue-edit',l(:button_new_comment)), new_note_custom_entity_path(@custom_entity), remote: true, class: 'icon icon-issue-edit' %> - - + <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon icon-del' %> diff --git a/app/views/custom_tables/_query_form.html.erb b/app/views/custom_tables/_query_form.html.erb index 51ff10e..78b6617 100644 --- a/app/views/custom_tables/_query_form.html.erb +++ b/app/views/custom_tables/_query_form.html.erb @@ -44,7 +44,9 @@ <%= link_to sprite_icon('bullet-go', l(:label_open_issues)), custom_entity_path(entity), title: l(:label_open_issues), class: Redmine::VERSION::MAJOR > 5 ? 'icon-only' : 'icon-only icon-arrow-right' %> <%= link_to sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(entity, edit_query: true, back_url: back_url), remote: true, title: l(:button_edit), class: 'icon-only icon-edit' %> - <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(entity, custom_table_id: @custom_table, project_id: params[:project_id], back_url: back_url), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon-only icon-del' %> + <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> + <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(entity, custom_table_id: @custom_table, project_id: params[:project_id], back_url: back_url), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon-only icon-del' %> + <% end %> <% end %> diff --git a/app/views/custom_tables/context_menu.html.erb b/app/views/custom_tables/context_menu.html.erb index fe6a035..4ff7c75 100644 --- a/app/views/custom_tables/context_menu.html.erb +++ b/app/views/custom_tables/context_menu.html.erb @@ -2,7 +2,11 @@ <% if @custom_table %>
  • <%= context_menu_link l(:button_show), custom_table_path(@custom_table), class: 'icon-show' %>
  • <%= context_menu_link l(:button_edit), edit_custom_table_path(@custom_table, edit_query: true, back_url: params[:back_url]), class: 'icon-edit', disabled: !@can[:edit] %>
  • -
  • <%= context_menu_link l(:button_delete), custom_table_path(@custom_table), method: :delete, class: 'icon-del', data: {confirm: l(:text_are_you_sure)}, disabled: !@can[:delete] %>
  • + + <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> +
  • <%= context_menu_link l(:button_delete), custom_table_path(@custom_table), method: :delete, class: 'icon-del', data: {confirm: l(:text_are_you_sure)}, disabled: !@can[:delete] %>
  • + <% end %> + <% else %> <% end %> diff --git a/app/views/custom_tables/index.html.erb b/app/views/custom_tables/index.html.erb index afce624..0a74f23 100644 --- a/app/views/custom_tables/index.html.erb +++ b/app/views/custom_tables/index.html.erb @@ -30,7 +30,9 @@ <%= raw @query.inline_columns.map {|column| " #{render_custom_table_content(column, table)}"}.join %> <%= link_to sprite_icon('settings', l(:button_edit)), edit_custom_table_path(table), title: l(:label_settings), class: 'icon-only icon-settings' %> - <%= link_to sprite_icon('del', l(:button_delete)), custom_table_path(table, back_url: custom_tables_path), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon-only icon-del' %> + <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> + <%= link_to sprite_icon('del', l(:button_delete)), custom_table_path(table, back_url: custom_tables_path), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon-only icon-del' %> + <% end %> <% end -%> diff --git a/app/views/issues/_query_custom_table.html.erb b/app/views/issues/_query_custom_table.html.erb index cc02731..81a6b4a 100644 --- a/app/views/issues/_query_custom_table.html.erb +++ b/app/views/issues/_query_custom_table.html.erb @@ -26,7 +26,9 @@ <% end %> <% if User.current.allowed_to?(:manage_custom_tables, nil, global: true) %> <%= link_to sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(entity, edit_query: true, back_url: back_url), remote: true, title: l(:button_edit), class: 'icon-only icon-edit' %> - <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(entity, custom_table_id: custom_table, back_url: back_url), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon-only icon-del' %> + <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> + <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(entity, custom_table_id: custom_table, back_url: back_url), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon-only icon-del' %> + <% end %> <% end %> diff --git a/app/views/table_fields/_index.html.erb b/app/views/table_fields/_index.html.erb index 9164bf5..1d86cef 100644 --- a/app/views/table_fields/_index.html.erb +++ b/app/views/table_fields/_index.html.erb @@ -21,7 +21,10 @@ <%= reorder_handle(custom_field, url: custom_field_path(custom_field), param: 'custom_field') %> <%= link_to l(:button_edit), edit_table_field_path(id: custom_field, back_url: back_url), remote: true, title: l(:button_edit), class: 'icon-only icon-edit' %> - <%= delete_link table_field_path(custom_field, custom_table_id: @custom_table.id, back_url: back_url), class: 'icon-only icon-del' %> + + <% if User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } %> + <%= delete_link table_field_path(custom_field, custom_table_id: @custom_table.id, back_url: back_url), class: 'icon-only icon-del' %> + <% end %> <% end; reset_cycle %> From 2504ca1035c04931b6b3c87a707de5ebe2e2c61f Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 15:54:44 +0530 Subject: [PATCH 07/41] Enhance authorization checks for destroy actions to include context menu and bulk update --- app/controllers/custom_entities_controller.rb | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index 7e9609e..2683ae4 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -16,8 +16,7 @@ class CustomEntitiesController < ApplicationController accept_api_auth :show, :create, :update, :destroy - before_action :restrict_destroy_to_admin_and_manager, only: [:destroy] - + before_action :restrict_destroy_to_admin_and_manager, only: [:destroy, :context_menu, :bulk_update] before_action :authorize_global before_action :find_custom_entity, only: [:show, :edit, :update, :add_belongs_to, :new_note] @@ -60,12 +59,25 @@ def new_note end end - def restrict_destroy_to_admin_and_manager - allowed_roles = ['Administrator', 'Manager'] - unless User.current.admin? || User.current.roles.any? { |r| allowed_roles.include?(r.name) } - render_403 + def destroy + unless User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } + Rails.logger.warn "❌ DELETE BLOCKED in destroy: #{User.current.login}" + return render_403 + end + + custom_table = @custom_entities.first.custom_table + @custom_entities.destroy_all + + respond_to do |format| + format.html { + flash[:notice] = l(:notice_successful_delete) + redirect_back_or_default custom_table_path(custom_table) + } + format.api { render_api_ok } end end + + def create @custom_entity = CustomEntity.new(author: User.current, custom_table_id: params[:custom_entity][:custom_table_id]) @@ -144,7 +156,7 @@ def context_menu @custom_entity_ids = @custom_entities.map(&:id).sort can_edit = @custom_entities.detect{|c| !c.editable?}.nil? - can_delete = @custom_entities.detect{|c| !c.deletable?}.nil? + can_delete = User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } @can = {:edit => can_edit, :delete => can_delete} @back = back_url From cdad81e734801628945e73089240046ed61ab936 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 16:07:00 +0530 Subject: [PATCH 08/41] updateinguser column creation --- app/controllers/custom_entities_controller.rb | 6 ++++++ app/models/custom_entity.rb | 4 ++++ db/migrate/004_add_audit_fields_to_custom_entities.rb | 8 ++++++++ 3 files changed, 18 insertions(+) create mode 100644 db/migrate/004_add_audit_fields_to_custom_entities.rb diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index 2683ae4..668e488 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -83,6 +83,11 @@ def create @custom_entity = CustomEntity.new(author: User.current, custom_table_id: params[:custom_entity][:custom_table_id]) @custom_entity.safe_attributes = params[:custom_entity] + + @custom_entity.updated_by = User.current # track updater + @custom_entity.updated_at = Time.current # ensure timestamp if not automatically handled + + if @custom_entity.save flash[:notice] = l(:notice_successful_create) respond_to do |format| @@ -110,6 +115,7 @@ def edit def update @custom_entity.init_journal(User.current) @custom_entity.safe_attributes = params[:custom_entity] + @custom_entity.updated_by = User.current # track who updated if @custom_entity.save flash[:notice] = l(:notice_successful_update) diff --git a/app/models/custom_entity.rb b/app/models/custom_entity.rb index feffb69..acd379b 100644 --- a/app/models/custom_entity.rb +++ b/app/models/custom_entity.rb @@ -8,6 +8,10 @@ class CustomEntity < CustomTables::ActiveRecordClass.base has_one :project, through: :issue has_many :custom_fields, through: :custom_table + belongs_to :author, class_name: 'User', optional: true + belongs_to :updated_by, class_name: 'User', optional: true + + safe_attributes 'custom_table_id', 'author_id', 'custom_field_values', 'custom_fields', 'parent_entity_ids', 'sub_entity_ids', 'issue_id', 'external_values' diff --git a/db/migrate/004_add_audit_fields_to_custom_entities.rb b/db/migrate/004_add_audit_fields_to_custom_entities.rb new file mode 100644 index 0000000..ea495e9 --- /dev/null +++ b/db/migrate/004_add_audit_fields_to_custom_entities.rb @@ -0,0 +1,8 @@ +class AddAuditFieldsToCustomEntities < ActiveRecord::Migration[4.2] + def change + add_column :custom_entities, :author_id, :integer + add_column :custom_entities, :updated_by_id, :integer + add_foreign_key :custom_entities, :users, column: :author_id + add_foreign_key :custom_entities, :users, column: :updated_by_id + end +end From 141f2b0e72e87195b747a28c560024367370f8db Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 16:12:13 +0530 Subject: [PATCH 09/41] DuplicateColumn issue fixed --- ...004_add_audit_fields_to_custom_entities.rb | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/db/migrate/004_add_audit_fields_to_custom_entities.rb b/db/migrate/004_add_audit_fields_to_custom_entities.rb index ea495e9..c5cb36e 100644 --- a/db/migrate/004_add_audit_fields_to_custom_entities.rb +++ b/db/migrate/004_add_audit_fields_to_custom_entities.rb @@ -1,8 +1,20 @@ class AddAuditFieldsToCustomEntities < ActiveRecord::Migration[4.2] def change - add_column :custom_entities, :author_id, :integer - add_column :custom_entities, :updated_by_id, :integer - add_foreign_key :custom_entities, :users, column: :author_id - add_foreign_key :custom_entities, :users, column: :updated_by_id + unless column_exists?(:custom_entities, :author_id) + add_column :custom_entities, :author_id, :integer + end + + unless column_exists?(:custom_entities, :updated_by_id) + add_column :custom_entities, :updated_by_id, :integer + end + + # Add foreign keys only if not already present + unless foreign_key_exists?(:custom_entities, :users, column: :author_id) + add_foreign_key :custom_entities, :users, column: :author_id + end + + unless foreign_key_exists?(:custom_entities, :users, column: :updated_by_id) + add_foreign_key :custom_entities, :users, column: :updated_by_id + end end end From 448ad9e89a8dbf7533031d01195e938a1f45ff77 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 16:27:00 +0530 Subject: [PATCH 10/41] created restrict_destroy_to_admin_and_manager method --- app/controllers/custom_entities_controller.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index 668e488..ab4defc 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -77,6 +77,14 @@ def destroy end end + def restrict_destroy_to_admin_and_manager + allowed_roles = ['Administrator', 'Manager'] + unless User.current.admin? || User.current.roles.any? { |r| allowed_roles.include?(r.name) } + Rails.logger.warn "🚫 Delete blocked for #{User.current.login}" + render_403 + end + end + def create From d458171c2ce0383c5708a9da198e008f80d7cfbb Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 16:29:45 +0530 Subject: [PATCH 11/41] commented 1 destroy --- app/controllers/custom_entities_controller.rb | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index ab4defc..7912fd1 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -84,7 +84,7 @@ def restrict_destroy_to_admin_and_manager render_403 end end - + def create @@ -141,18 +141,18 @@ def update end end - def destroy - custom_table = @custom_entities.first.custom_table - @custom_entities.destroy_all - - respond_to do |format| - format.html { - flash[:notice] = l(:notice_successful_delete) - redirect_back_or_default custom_table_path(custom_table) - } - format.api { render_api_ok } - end - end + #def destroy + # custom_table = @custom_entities.first.custom_table + # @custom_entities.destroy_all +# + # respond_to do |format| + # format.html { + # flash[:notice] = l(:notice_successful_delete) + # redirect_back_or_default custom_table_path(custom_table) + # } + # format.api { render_api_ok } + # end + #end def add_belongs_to @custom_field = CustomEntityCustomField.find(params[:custom_field_id]) From 283daef015cafd3bdcd34a209302a191d1e02d9f Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 16:34:46 +0530 Subject: [PATCH 12/41] dertroy update --- app/controllers/custom_entities_controller.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index 7912fd1..8ccb221 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -60,10 +60,7 @@ def new_note end def destroy - unless User.current.admin? || User.current.roles.any? { |r| ['Administrator', 'Manager'].include?(r.name) } - Rails.logger.warn "❌ DELETE BLOCKED in destroy: #{User.current.login}" - return render_403 - end + Rails.logger.info "✅ ALLOWED: #{User.current.login} deleting CustomEntities: #{@custom_entities.map(&:id).join(', ')}" custom_table = @custom_entities.first.custom_table @custom_entities.destroy_all @@ -77,15 +74,19 @@ def destroy end end + def restrict_destroy_to_admin_and_manager allowed_roles = ['Administrator', 'Manager'] - unless User.current.admin? || User.current.roles.any? { |r| allowed_roles.include?(r.name) } - Rails.logger.warn "🚫 Delete blocked for #{User.current.login}" + user_roles = User.current.roles.map(&:name) + + unless User.current.admin? || user_roles.any? { |r| allowed_roles.include?(r) } + Rails.logger.warn "🚫 DELETE BLOCKED: #{User.current.login}, roles: #{user_roles.join(', ')}" render_403 end end + def create @custom_entity = CustomEntity.new(author: User.current, custom_table_id: params[:custom_entity][:custom_table_id]) From 947721a58b2ff723636f50f901e36be794d2490b Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 17:35:52 +0530 Subject: [PATCH 13/41] update delete --- app/views/custom_entities/context_menu.html.erb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/views/custom_entities/context_menu.html.erb b/app/views/custom_entities/context_menu.html.erb index 385e53c..bbd6e4e 100644 --- a/app/views/custom_entities/context_menu.html.erb +++ b/app/views/custom_entities/context_menu.html.erb @@ -18,7 +18,17 @@ + + <% if @can[:delete] %> +
  • <%= context_menu_link sprite_icon('del', l(:button_delete)), + custom_entities_path(ids: @custom_entity_ids, back_url: @back), + method: :delete, + data: {confirm: l(:text_are_you_sure)}, + class: 'icon icon-del' %>
  • + <% end %> + From 2543085f6a078be63e1d57037360931ee59283a7 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 17:57:03 +0530 Subject: [PATCH 14/41] updated --- app/models/custom_entity.rb | 89 +++++++++---------- app/models/followup.rb | 9 ++ config/initializers/custom_tables.rb | 19 ++++ ...004_add_audit_fields_to_custom_entities.rb | 25 ++++-- .../20251030130000_create_followups_table.rb | 18 ++++ 5 files changed, 109 insertions(+), 51 deletions(-) create mode 100644 app/models/followup.rb create mode 100644 config/initializers/custom_tables.rb create mode 100644 db/migrate/20251030130000_create_followups_table.rb diff --git a/app/models/custom_entity.rb b/app/models/custom_entity.rb index acd379b..9a37886 100644 --- a/app/models/custom_entity.rb +++ b/app/models/custom_entity.rb @@ -3,26 +3,32 @@ class CustomEntity < CustomTables::ActiveRecordClass.base include CustomTables::ActsAsJournalize belongs_to :custom_table - belongs_to :author, class_name: 'User', foreign_key: 'author_id' belongs_to :issue - has_one :project, through: :issue - has_many :custom_fields, through: :custom_table + has_one :project, through: :issue + has_many :custom_fields, through: :custom_table - belongs_to :author, class_name: 'User', optional: true - belongs_to :updated_by, class_name: 'User', optional: true + # ✅ Audit tracking relationships + belongs_to :author, class_name: 'User', foreign_key: 'author_id', optional: true + belongs_to :updated_by, class_name: 'User', foreign_key: 'updated_by_id', optional: true - - safe_attributes 'custom_table_id', 'author_id', 'custom_field_values', 'custom_fields', 'parent_entity_ids', - 'sub_entity_ids', 'issue_id', 'external_values' + safe_attributes( + 'custom_table_id', 'author_id', 'updated_by_id', + 'custom_field_values', 'custom_fields', + 'parent_entity_ids', 'sub_entity_ids', + 'issue_id', 'external_values' + ) acts_as_customizable + acts_as_watchable delegate :main_custom_field, to: :custom_table - acts_as_watchable - self.journal_options = {} + # ✅ Automatically set audit fields + before_create :set_author + before_save :set_updated_by + def name if new_record? custom_table.name @@ -34,7 +40,7 @@ def name def editable?(user = User.current) return true if user.admin? || custom_table.is_for_all - user.allowed_to?(:edit_issues, issue.project) + user.allowed_to?(:edit_issues, issue.try(:project)) end def visible?(user = User.current) @@ -46,51 +52,31 @@ def deletable?(user = nil) editable? end - def leaf? - false - end - - def is_descendant_of?(p) - false - end - - def each_notification(users, &block) - end - - def notified_users - [] - end - - def notified_mentions - [] - end + def leaf?; false; end + def is_descendant_of?(p); false; end - def attachments - [] - end + def each_notification(users, &block); end + def notified_users; []; end + def notified_mentions; []; end + def attachments; []; end def available_custom_fields custom_fields.sorted.to_a end - def created_on - created_at - end - - def updated_on - updated_at - end + def created_on; created_at; end + def updated_on; updated_at; end def value_by_external_name(external_name) - custom_field_values.detect {|v| v.custom_field.external_name == external_name}.try(:value) + custom_field_values.detect { |v| v.custom_field.external_name == external_name }.try(:value) end def external_values=(values) - custom_field_values.each do |custom_field_value| - key = custom_field_value.custom_field.external_name + custom_field_values.each do |cf_value| + key = cf_value.custom_field.external_name next unless key.present? if values.has_key?(key) - custom_field_value.value = values[key] + cf_value.value = values[key] end end @custom_field_values_changed = true @@ -99,9 +85,22 @@ def external_values=(values) def to_h values = {} custom_field_values.each do |value| - values[value.custom_field.external_name] = value.value if value.custom_field.external_name.present? + if value.custom_field.external_name.present? + values[value.custom_field.external_name] = value.value + end end - values["id"] = id + values['id'] = id values end + + private + + # 🧠 Auto-assign author/updated_by fields + def set_author + self.author_id ||= User.current.id if User.current + end + + def set_updated_by + self.updated_by_id = User.current.id if User.current + end end diff --git a/app/models/followup.rb b/app/models/followup.rb new file mode 100644 index 0000000..5e6080a --- /dev/null +++ b/app/models/followup.rb @@ -0,0 +1,9 @@ +class Followup < ActiveRecord::Base + before_create :set_creator + + private + + def set_creator + self.created_by_id ||= User.current.id if User.current + end +end diff --git a/config/initializers/custom_tables.rb b/config/initializers/custom_tables.rb new file mode 100644 index 0000000..2e74ca2 --- /dev/null +++ b/config/initializers/custom_tables.rb @@ -0,0 +1,19 @@ +# config/initializers/custom_tables.rb +# ------------------------------------ +# Custom table definition for Customer Follow-up tracking +# Works with Redmine 5.1 and custom_tables plugin + +CustomTables::Config.register_table(:followups, { + label: 'Customer Follow-up', + columns: [ + { name: 'call_customer', type: 'string', label: 'Call Customer' }, + { name: 'next_followup_date', type: 'date', label: 'Next Follow-up Date' }, + { name: 'followup_count', type: 'integer', label: 'Follow-up Count', default: 0 }, + { name: 'send_quote', type: 'boolean', label: 'Send Quote', default: false }, + { name: 'renewal_completed', type: 'boolean', label: 'Renewal Completed', default: false }, + { name: 'tagging_completed', type: 'boolean', label: 'Tagging Completed', default: false }, + { name: 'description', type: 'text', label: 'Description', input: :textarea, rows: 6, cols: 80 }, + { name: 'created_by_id', type: 'user', label: 'Created By', readonly: true }, + { name: 'created_at', type: 'datetime', label: 'Created On', readonly: true } + ] +}) diff --git a/db/migrate/004_add_audit_fields_to_custom_entities.rb b/db/migrate/004_add_audit_fields_to_custom_entities.rb index c5cb36e..081ddeb 100644 --- a/db/migrate/004_add_audit_fields_to_custom_entities.rb +++ b/db/migrate/004_add_audit_fields_to_custom_entities.rb @@ -1,20 +1,33 @@ +# db/migrate/20251030131500_add_audit_fields_to_custom_entities.rb +# +# Safely adds audit tracking fields to custom_entities table +# Compatible with Redmine 5.x (Rails 6.1) using Migration[4.2] + class AddAuditFieldsToCustomEntities < ActiveRecord::Migration[4.2] def change + # Add author_id if missing unless column_exists?(:custom_entities, :author_id) add_column :custom_entities, :author_id, :integer end + # Add updated_by_id if missing unless column_exists?(:custom_entities, :updated_by_id) add_column :custom_entities, :updated_by_id, :integer end - # Add foreign keys only if not already present - unless foreign_key_exists?(:custom_entities, :users, column: :author_id) - add_foreign_key :custom_entities, :users, column: :author_id - end + # Safely add foreign key constraints (only if method exists) + if respond_to?(:foreign_key_exists?) + unless foreign_key_exists?(:custom_entities, :users, column: :author_id) + add_foreign_key :custom_entities, :users, column: :author_id, on_delete: :nullify + end - unless foreign_key_exists?(:custom_entities, :users, column: :updated_by_id) - add_foreign_key :custom_entities, :users, column: :updated_by_id + unless foreign_key_exists?(:custom_entities, :users, column: :updated_by_id) + add_foreign_key :custom_entities, :users, column: :updated_by_id, on_delete: :nullify + end end + + # Optional indexes for faster lookups + add_index :custom_entities, :author_id unless index_exists?(:custom_entities, :author_id) + add_index :custom_entities, :updated_by_id unless index_exists?(:custom_entities, :updated_by_id) end end diff --git a/db/migrate/20251030130000_create_followups_table.rb b/db/migrate/20251030130000_create_followups_table.rb new file mode 100644 index 0000000..24e0485 --- /dev/null +++ b/db/migrate/20251030130000_create_followups_table.rb @@ -0,0 +1,18 @@ + +class CreateFollowupsTable < ActiveRecord::Migration + def change + create_table :followups do |t| + t.string :call_customer + t.date :next_followup_date + t.integer :followup_count, default: 0 + t.boolean :send_quote, default: false + t.boolean :renewal_completed, default: false + t.boolean :tagging_completed, default: false + t.text :description + t.integer :created_by_id + t.timestamps null: false + end + + add_index :followups, :created_by_id + end +end From 07fa44b0919d248a7a52c73de71967b77f3e2035 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 17:58:27 +0530 Subject: [PATCH 15/41] added version --- db/migrate/20251030130000_create_followups_table.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20251030130000_create_followups_table.rb b/db/migrate/20251030130000_create_followups_table.rb index 24e0485..75b94b2 100644 --- a/db/migrate/20251030130000_create_followups_table.rb +++ b/db/migrate/20251030130000_create_followups_table.rb @@ -1,5 +1,5 @@ -class CreateFollowupsTable < ActiveRecord::Migration +class CreateFollowupsTable < ActiveRecord::Migration[4.2] def change create_table :followups do |t| t.string :call_customer From 03e320873d1501bf4d3ba1fef1bbd5913de31f62 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 18:02:14 +0530 Subject: [PATCH 16/41] model updated --- app/models/followup.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/models/followup.rb b/app/models/followup.rb index 5e6080a..06c42b5 100644 --- a/app/models/followup.rb +++ b/app/models/followup.rb @@ -1,5 +1,8 @@ class Followup < ActiveRecord::Base + belongs_to :creator, class_name: 'User', foreign_key: 'created_by_id' + before_create :set_creator + validates :call_customer, presence: true private From 20de46268e56638c79685af8f51a309da07b73b9 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 18:11:35 +0530 Subject: [PATCH 17/41] added followup --- init.rb | 6 +++- .../patches/followups_loader_patch.rb | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 lib/custom_tables/patches/followups_loader_patch.rb diff --git a/init.rb b/init.rb index fdedeb2..d8d0513 100644 --- a/init.rb +++ b/init.rb @@ -30,4 +30,8 @@ Rails.configuration.to_prepare do require_dependency 'custom_entities_controller' CustomEntitiesController.include(CustomTables::CustomEntitiesControllerPatch) -end \ No newline at end of file +end + + +#require File.expand_path('../lib/custom_tables/patches/followups_loader_patch', __FILE__) +require_dependency File.expand_path('lib/custom_tables/patches/followups_loader_patch', __dir__) diff --git a/lib/custom_tables/patches/followups_loader_patch.rb b/lib/custom_tables/patches/followups_loader_patch.rb new file mode 100644 index 0000000..b497e25 --- /dev/null +++ b/lib/custom_tables/patches/followups_loader_patch.rb @@ -0,0 +1,31 @@ +# lib/custom_tables/patches/followups_loader_patch.rb +# Forces registration of Customer Follow-up table after plugin load + +Rails.configuration.to_prepare do + begin + if defined?(CustomTables::Config) + unless CustomTables::Config.tables.key?(:followups) + CustomTables::Config.register_table(:followups, { + label: 'Customer Follow-up', + columns: [ + { name: 'call_customer', type: 'string', label: 'Call Customer' }, + { name: 'next_followup_date', type: 'date', label: 'Next Follow-up Date' }, + { name: 'followup_count', type: 'integer', label: 'Follow-up Count', default: 0 }, + { name: 'send_quote', type: 'boolean', label: 'Send Quote', default: false }, + { name: 'renewal_completed', type: 'boolean', label: 'Renewal Completed', default: false }, + { name: 'tagging_completed', type: 'boolean', label: 'Tagging Completed', default: false }, + { name: 'description', type: 'text', label: 'Description', + input: :textarea, rows: 6, cols: 80 }, + { name: 'created_by_id', type: 'user', label: 'Created By', readonly: true }, + { name: 'created_at', type: 'datetime', label: 'Created On', readonly: true } + ] + }) + Rails.logger.info '[CustomTables] Customer Follow-up table registered' + end + else + Rails.logger.warn '[CustomTables] CustomTables::Config not defined yet' + end + rescue => e + Rails.logger.error "[CustomTables] Failed to register Followups: #{e.message}" + end +end From b88d178323da039531594de59e04eac1d3bfeae3 Mon Sep 17 00:00:00 2001 From: arean82 Date: Thu, 30 Oct 2025 18:12:48 +0530 Subject: [PATCH 18/41] followups patched --- .../patches/followups_loader_patch.rb | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/lib/custom_tables/patches/followups_loader_patch.rb b/lib/custom_tables/patches/followups_loader_patch.rb index b497e25..04055d1 100644 --- a/lib/custom_tables/patches/followups_loader_patch.rb +++ b/lib/custom_tables/patches/followups_loader_patch.rb @@ -1,31 +1,42 @@ # lib/custom_tables/patches/followups_loader_patch.rb -# Forces registration of Customer Follow-up table after plugin load +# Safely registers the Customer Follow-up table after plugin load -Rails.configuration.to_prepare do - begin - if defined?(CustomTables::Config) - unless CustomTables::Config.tables.key?(:followups) - CustomTables::Config.register_table(:followups, { - label: 'Customer Follow-up', - columns: [ - { name: 'call_customer', type: 'string', label: 'Call Customer' }, - { name: 'next_followup_date', type: 'date', label: 'Next Follow-up Date' }, - { name: 'followup_count', type: 'integer', label: 'Follow-up Count', default: 0 }, - { name: 'send_quote', type: 'boolean', label: 'Send Quote', default: false }, - { name: 'renewal_completed', type: 'boolean', label: 'Renewal Completed', default: false }, - { name: 'tagging_completed', type: 'boolean', label: 'Tagging Completed', default: false }, - { name: 'description', type: 'text', label: 'Description', - input: :textarea, rows: 6, cols: 80 }, - { name: 'created_by_id', type: 'user', label: 'Created By', readonly: true }, - { name: 'created_at', type: 'datetime', label: 'Created On', readonly: true } - ] - }) - Rails.logger.info '[CustomTables] Customer Follow-up table registered' +module CustomTables + module Patches + module FollowupsLoaderPatch + def self.load_followup_table + Rails.configuration.to_prepare do + begin + if defined?(CustomTables::Config) + unless CustomTables::Config.tables.key?(:followups) + CustomTables::Config.register_table(:followups, { + label: 'Customer Follow-up', + columns: [ + { name: 'call_customer', type: 'string', label: 'Call Customer' }, + { name: 'next_followup_date', type: 'date', label: 'Next Follow-up Date' }, + { name: 'followup_count', type: 'integer', label: 'Follow-up Count', default: 0 }, + { name: 'send_quote', type: 'boolean', label: 'Send Quote', default: false }, + { name: 'renewal_completed', type: 'boolean', label: 'Renewal Completed', default: false }, + { name: 'tagging_completed', type: 'boolean', label: 'Tagging Completed', default: false }, + { name: 'description', type: 'text', label: 'Description', + input: :textarea, rows: 6, cols: 80 }, + { name: 'created_by_id', type: 'user', label: 'Created By', readonly: true }, + { name: 'created_at', type: 'datetime', label: 'Created On', readonly: true } + ] + }) + Rails.logger.info '[CustomTables] Customer Follow-up table registered' + end + else + Rails.logger.warn '[CustomTables] CustomTables::Config not defined yet' + end + rescue => e + Rails.logger.error "[CustomTables] Failed to register Followups: #{e.message}" + end + end end - else - Rails.logger.warn '[CustomTables] CustomTables::Config not defined yet' end - rescue => e - Rails.logger.error "[CustomTables] Failed to register Followups: #{e.message}" end end + +# Trigger registration immediately +CustomTables::Patches::FollowupsLoaderPatch.load_followup_table From 12f9d86b932d83f86b0e4577c9f77bc18ab4aa4a Mon Sep 17 00:00:00 2001 From: arean82 Date: Mon, 3 Nov 2025 17:02:58 +0530 Subject: [PATCH 19/41] updated --- app/models/custom_entity.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/models/custom_entity.rb b/app/models/custom_entity.rb index 9a37886..013377a 100644 --- a/app/models/custom_entity.rb +++ b/app/models/custom_entity.rb @@ -64,7 +64,18 @@ def available_custom_fields custom_fields.sorted.to_a end - def created_on; created_at; end + #def created_on + ## created_at; + # respond_to?(:created_at) ? created_at : updated_at + #end + def created_on + # Use updated_at as fallback if created_at doesn't exist + if respond_to?(:created_at) && created_at.present? + created_at + else + updated_at + end + end def updated_on; updated_at; end def value_by_external_name(external_name) From b67851bb764f1479a98f2af4579a567016ec9bf0 Mon Sep 17 00:00:00 2001 From: arean82 Date: Mon, 3 Nov 2025 17:48:31 +0530 Subject: [PATCH 20/41] updated --- app/controllers/custom_entities_controller.rb | 30 ++++++++--- app/controllers/custom_tables_controller.rb | 24 +++++++-- app/controllers/table_fields_controller.rb | 16 ++++++ .../custom_tables_permission_helper.rb | 21 ++++++++ .../custom_entities/context_menu.html.erb | 14 ++--- app/views/custom_entities/show.html.erb | 52 ++++++++++++++----- app/views/custom_tables/_settings.html.erb | 21 ++++++++ init.rb | 8 ++- 8 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 app/helpers/custom_tables_permission_helper.rb create mode 100644 app/views/custom_tables/_settings.html.erb diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index 8ccb221..da455c6 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -1,3 +1,5 @@ +#custom_entities_controller.rb + class CustomEntitiesController < ApplicationController layout 'admin' self.main_menu = false @@ -16,7 +18,7 @@ class CustomEntitiesController < ApplicationController accept_api_auth :show, :create, :update, :destroy - before_action :restrict_destroy_to_admin_and_manager, only: [:destroy, :context_menu, :bulk_update] + before_action :check_destroy_permission, only: [:destroy, :context_menu, :bulk_update] before_action :authorize_global before_action :find_custom_entity, only: [:show, :edit, :update, :add_belongs_to, :new_note] @@ -59,6 +61,7 @@ def new_note end end + def destroy Rails.logger.info "✅ ALLOWED: #{User.current.login} deleting CustomEntities: #{@custom_entities.map(&:id).join(', ')}" @@ -75,17 +78,30 @@ def destroy end - def restrict_destroy_to_admin_and_manager - allowed_roles = ['Administrator', 'Manager'] - user_roles = User.current.roles.map(&:name) - - unless User.current.admin? || user_roles.any? { |r| allowed_roles.include?(r) } - Rails.logger.warn "🚫 DELETE BLOCKED: #{User.current.login}, roles: #{user_roles.join(', ')}" + + def check_destroy_permission + unless custom_tables_user_has_full_access? + Rails.logger.warn "🚫 DESTROY BLOCKED: #{User.current.login}, needs full access" render_403 end end + def context_menu + if (@custom_entities.size == 1) + @custom_entity = @custom_entities.first + end + @custom_entity_ids = @custom_entities.map(&:id).sort + can_edit = @custom_entities.detect{|c| !c.editable?}.nil? + # Use the new permission helper + can_delete = custom_tables_user_has_full_access? + @can = {:edit => can_edit, :delete => can_delete} + @back = back_url + + @safe_attributes = @custom_entities.map(&:safe_attribute_names).reduce(:&) + + render :layout => false + end def create diff --git a/app/controllers/custom_tables_controller.rb b/app/controllers/custom_tables_controller.rb index 5a1cb0d..e304ef2 100644 --- a/app/controllers/custom_tables_controller.rb +++ b/app/controllers/custom_tables_controller.rb @@ -1,3 +1,5 @@ +# custom_tables_controller.rb + class CustomTablesController < ApplicationController layout 'admin' self.main_menu = false @@ -13,12 +15,18 @@ class CustomTablesController < ApplicationController helper :settings helper :custom_tables_pdf + # Add permission helper + helper :custom_tables_permission + before_action :find_custom_table, only: [:edit, :update, :show, :destroy, :setting_tabs] before_action :authorize_global before_action :find_custom_tables, only: [:context_menu] before_action :setting_tabs, only: :edit before_action :export_custom_entities, only: :show + # ADD: Check permissions for table operations + before_action :check_manage_permission, only: [:new, :create, :edit, :update, :destroy, :context_menu] + accept_api_auth :show, :index, :create, :update, :destroy def index @@ -140,9 +148,9 @@ def context_menu end @custom_tables_ids = @custom_tables.map(&:id).sort - can_edit = @custom_tables.detect{|c| !c.editable?}.nil? - can_delete = @custom_tables.detect{|c| !c.deletable?}.nil? - @can = {:edit => can_edit, :delete => can_delete} + # Use permission helper + has_full_access = custom_tables_user_has_full_access? + @can = { edit: has_full_access, delete: has_full_access } @back = back_url @safe_attributes = @custom_tables.map(&:safe_attribute_names).reduce(:&) @@ -150,6 +158,16 @@ def context_menu render layout: false end + private + + def check_manage_permission + unless custom_tables_user_has_full_access? + Rails.logger.warn "🚫 MANAGE PERMISSION REQUIRED: #{User.current.login} attempted #{action_name}" + render_403 + return false + end + end + def setting_tabs @setting_tabs = [ {name: 'general', partial: 'custom_tables/edit', label: :label_general}, diff --git a/app/controllers/table_fields_controller.rb b/app/controllers/table_fields_controller.rb index 04855ef..e37ed60 100644 --- a/app/controllers/table_fields_controller.rb +++ b/app/controllers/table_fields_controller.rb @@ -1,3 +1,5 @@ +#table_fields_controller.rb + class TableFieldsController < CustomFieldsController layout 'admin' self.main_menu = false @@ -6,9 +8,13 @@ class TableFieldsController < CustomFieldsController helper :custom_tables helper :queries include QueriesHelper + # Add permission helper + helper :custom_tables_permission before_action :authorize_global before_action :build_new_custom_field, only: [:new, :create] + # ADD: Check permissions + before_action :check_manage_permission, except: [:show, :index] def new @custom_table = CustomTable.find(params[:custom_table_id]) @@ -81,4 +87,14 @@ def build_new_custom_field @custom_field.safe_attributes = params[:custom_field] end + private + + def check_manage_permission + unless custom_tables_user_has_full_access? + Rails.logger.warn "🚫 MANAGE PERMISSION REQUIRED: #{User.current.login} attempted #{action_name}" + render_403 + return false + end + end + end \ No newline at end of file diff --git a/app/helpers/custom_tables_permission_helper.rb b/app/helpers/custom_tables_permission_helper.rb new file mode 100644 index 0000000..2545a92 --- /dev/null +++ b/app/helpers/custom_tables_permission_helper.rb @@ -0,0 +1,21 @@ +# app/helpers/custom_tables_permission_helper.rb +module CustomTablesPermissionHelper + def custom_tables_user_has_full_access?(user = User.current) + settings = Setting.plugin_custom_tables + + # If custom permissions are disabled, use your existing role-based logic + unless settings['enable_custom_permissions'] + allowed_roles = ['Administrator', 'Manager'] + user_roles = user.roles.map(&:name) + return user.admin? || user_roles.any? { |r| allowed_roles.include?(r) } + end + + # Custom permission logic + return true if user.admin? + + allowed_group_ids = settings['allowed_groups'] || [] + return false if allowed_group_ids.empty? + + user.groups.any? { |group| allowed_group_ids.include?(group.id.to_s) } + end +end \ No newline at end of file diff --git a/app/views/custom_entities/context_menu.html.erb b/app/views/custom_entities/context_menu.html.erb index bbd6e4e..55669ea 100644 --- a/app/views/custom_entities/context_menu.html.erb +++ b/app/views/custom_entities/context_menu.html.erb @@ -1,7 +1,8 @@ +# app/views/custom_entities/context_menu.html.erb <% class_name = @custom_entities.first.class.name %>
      <% if @custom_entity %> - <% if User.current.admin? %> + <% if custom_tables_user_has_full_access? %>
    • <%= context_menu_link sprite_icon('bullet-go', l(:label_open_issues)), custom_entity_path(@custom_entity), class: Redmine::VERSION::MAJOR > 5 ? 'icon' : 'icon icon-arrow-right' %>
    • <% end %>
    • <%= context_menu_link sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(@custom_entity, edit_query: true, back_url: params[:back_url]), @@ -18,17 +19,12 @@
    - - <% if @can[:delete] %> + + <% if @can[:delete] && custom_tables_user_has_full_access? %>
  • <%= context_menu_link sprite_icon('del', l(:button_delete)), custom_entities_path(ids: @custom_entity_ids, back_url: @back), method: :delete, data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
  • <% end %> - - + \ No newline at end of file diff --git a/app/views/custom_entities/show.html.erb b/app/views/custom_entities/show.html.erb index ef7282a..7143b59 100644 --- a/app/views/custom_entities/show.html.erb +++ b/app/views/custom_entities/show.html.erb @@ -24,23 +24,49 @@ <%= render_half_width_custom_fields_rows(@custom_entity) %> + + <% @queries_scope.each do |query| %> -
    -
    - <%= link_to l(:label_new), new_custom_entity_path( - custom_table_id: query[:custom_table_id], - back_url: custom_entity_path(@custom_entity), - custom_entity: {custom_field_values: query[:selected_custom_values]}), remote: true, class: 'icon icon-add' %> - <%= call_hook(:view_custom_table_sub_table_action_menu) %> -
    -

    <%= query[:name] %>

    - <% if query[:custom_entities].present? %> -
    - <%= render partial: 'custom_tables/query_form', locals: {query: query[:query], entities: query[:custom_entities], back_url: custom_entity_path(@custom_entity)} %> -
    +
    +
    + <%= call_hook(:view_custom_table_table_field_action_menu) %> + <%= link_to sprite_icon('edit', l(:button_edit)), + edit_custom_entity_path(@custom_entity, back_url: custom_entity_path(@custom_entity)), + remote: true, + class: 'icon icon-edit' %> + + <%= link_to sprite_icon('issue-edit', l(:button_new_comment)), + new_note_custom_entity_path(@custom_entity), + remote: true, + class: 'icon icon-issue-edit' %> + <% if custom_tables_user_has_full_access? %> + <%= link_to sprite_icon('del', l(:button_delete)), + custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), + data: { confirm: l(:text_are_you_sure) }, + method: :delete, + title: l(:button_delete), + class: 'icon icon-del' %> <% end %> +
    <% end %> + + <% if @journals.present? %>
    diff --git a/app/views/custom_tables/_settings.html.erb b/app/views/custom_tables/_settings.html.erb new file mode 100644 index 0000000..4a97a23 --- /dev/null +++ b/app/views/custom_tables/_settings.html.erb @@ -0,0 +1,21 @@ +# app/views/custom_tables/_settings.html.erb +
    +

    + <%= content_tag :label, l(:label_enable_custom_permissions) %> + <%= check_box_tag 'settings[enable_custom_permissions]', 1, @settings['enable_custom_permissions'] %> +
    + <%= l(:text_enable_custom_permissions_help) %> +

    + +

    + <%= content_tag :label, l(:label_allowed_groups) %> + <%= select_tag 'settings[allowed_groups][]', + options_from_collection_for_select(Group.sorted, :id, :name, @settings['allowed_groups']), + multiple: true, + size: 10, + style: 'width: 300px;', + class: 'custom-tables-select' %> +
    + <%= l(:text_allowed_groups_help) %> +

    +
    \ No newline at end of file diff --git a/init.rb b/init.rb index d8d0513..df5e9ec 100644 --- a/init.rb +++ b/init.rb @@ -1,3 +1,4 @@ +# init.rb Redmine::Plugin.register :custom_tables do name 'Custom Tables plugin' author 'Ivan Marangoz' @@ -7,6 +8,10 @@ url 'https://github.com/frywer/custom_tables' author_url 'https://github.com/frywer' + # Add settings configuration + settings default: { 'allowed_groups' => [], 'enable_custom_permissions' => false }, + partial: 'custom_tables/settings' + permission :manage_custom_tables, { custom_entities: [:new, :edit, :create, :update, :destroy, :context_menu, :bulk_edit, :bulk_update], }, global: true @@ -32,6 +37,5 @@ CustomEntitiesController.include(CustomTables::CustomEntitiesControllerPatch) end - #require File.expand_path('../lib/custom_tables/patches/followups_loader_patch', __FILE__) -require_dependency File.expand_path('lib/custom_tables/patches/followups_loader_patch', __dir__) +require_dependency File.expand_path('lib/custom_tables/patches/followups_loader_patch', __dir__) \ No newline at end of file From e26fca7abffe37ea1848078e97751038911609bf Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 10:11:19 +0530 Subject: [PATCH 21/41] updated permissions --- app/controllers/custom_entities_controller.rb | 39 ++++++++++++------- app/controllers/custom_tables_controller.rb | 21 +++++++++- app/controllers/table_fields_controller.rb | 19 +++++++++ .../custom_tables_permission_helper.rb | 4 +- config/locales/en.yml | 7 ++++ init.rb | 9 +++++ lib/custom_tables/permission_module.rb | 23 +++++++++++ 7 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 lib/custom_tables/permission_module.rb diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index da455c6..ba6d52e 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -15,9 +15,12 @@ class CustomEntitiesController < ApplicationController helper :sort include SortHelper helper :custom_tables_pdf + # Add permission helper + helper :custom_tables_permission accept_api_auth :show, :create, :update, :destroy + # Use the permission method before_action :check_destroy_permission, only: [:destroy, :context_menu, :bulk_update] before_action :authorize_global @@ -77,8 +80,7 @@ def destroy end end - - + # Use the module method directly def check_destroy_permission unless custom_tables_user_has_full_access? Rails.logger.warn "🚫 DESTROY BLOCKED: #{User.current.login}, needs full access" @@ -93,7 +95,7 @@ def context_menu @custom_entity_ids = @custom_entities.map(&:id).sort can_edit = @custom_entities.detect{|c| !c.editable?}.nil? - # Use the new permission helper + # Use the module method directly can_delete = custom_tables_user_has_full_access? @can = {:edit => can_edit, :delete => can_delete} @back = back_url @@ -158,18 +160,25 @@ def update end end - #def destroy - # custom_table = @custom_entities.first.custom_table - # @custom_entities.destroy_all -# - # respond_to do |format| - # format.html { - # flash[:notice] = l(:notice_successful_delete) - # redirect_back_or_default custom_table_path(custom_table) - # } - # format.api { render_api_ok } - # end - #end + + def custom_tables_user_has_full_access?(user = User.current) + settings = Setting.plugin_custom_tables || {} + + # If custom permissions are disabled, use your existing role-based logic + unless settings['enable_custom_permissions'] + allowed_roles = ['Administrator', 'Manager'] + user_roles = user.roles.map(&:name) + return user.admin? || user_roles.any? { |r| allowed_roles.include?(r) } + end + + # Custom permission logic + return true if user.admin? + + allowed_group_ids = settings['allowed_groups'] || [] + return false if allowed_group_ids.empty? + + user.groups.any? { |group| allowed_group_ids.include?(group.id.to_s) } + end def add_belongs_to @custom_field = CustomEntityCustomField.find(params[:custom_field_id]) diff --git a/app/controllers/custom_tables_controller.rb b/app/controllers/custom_tables_controller.rb index e304ef2..8e0df72 100644 --- a/app/controllers/custom_tables_controller.rb +++ b/app/controllers/custom_tables_controller.rb @@ -1,4 +1,4 @@ -# custom_tables_controller.rb +# app/controllers/custom_tables_controller.rb class CustomTablesController < ApplicationController layout 'admin' @@ -168,6 +168,25 @@ def check_manage_permission end end + def custom_tables_user_has_full_access?(user = User.current) + settings = Setting.plugin_custom_tables || {} + + # If custom permissions are disabled, use your existing role-based logic + unless settings['enable_custom_permissions'] + allowed_roles = ['Administrator', 'Manager'] + user_roles = user.roles.map(&:name) + return user.admin? || user_roles.any? { |r| allowed_roles.include?(r) } + end + + # Custom permission logic + return true if user.admin? + + allowed_group_ids = settings['allowed_groups'] || [] + return false if allowed_group_ids.empty? + + user.groups.any? { |group| allowed_group_ids.include?(group.id.to_s) } + end + def setting_tabs @setting_tabs = [ {name: 'general', partial: 'custom_tables/edit', label: :label_general}, diff --git a/app/controllers/table_fields_controller.rb b/app/controllers/table_fields_controller.rb index e37ed60..9466ab1 100644 --- a/app/controllers/table_fields_controller.rb +++ b/app/controllers/table_fields_controller.rb @@ -67,6 +67,25 @@ def update end end + def custom_tables_user_has_full_access?(user = User.current) + settings = Setting.plugin_custom_tables || {} + + # If custom permissions are disabled, use your existing role-based logic + unless settings['enable_custom_permissions'] + allowed_roles = ['Administrator', 'Manager'] + user_roles = user.roles.map(&:name) + return user.admin? || user_roles.any? { |r| allowed_roles.include?(r) } + end + + # Custom permission logic + return true if user.admin? + + allowed_group_ids = settings['allowed_groups'] || [] + return false if allowed_group_ids.empty? + + user.groups.any? { |group| allowed_group_ids.include?(group.id.to_s) } + end + def destroy table = @custom_field.custom_table @custom_field.destroy diff --git a/app/helpers/custom_tables_permission_helper.rb b/app/helpers/custom_tables_permission_helper.rb index 2545a92..fc3ff8f 100644 --- a/app/helpers/custom_tables_permission_helper.rb +++ b/app/helpers/custom_tables_permission_helper.rb @@ -1,7 +1,9 @@ # app/helpers/custom_tables_permission_helper.rb module CustomTablesPermissionHelper + #include CustomTables::PermissionModule + def custom_tables_user_has_full_access?(user = User.current) - settings = Setting.plugin_custom_tables + settings = Setting.plugin_custom_tables || {} # Added || {} for safety # If custom permissions are disabled, use your existing role-based logic unless settings['enable_custom_permissions'] diff --git a/config/locales/en.yml b/config/locales/en.yml index 3316ac0..4282b69 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -31,3 +31,10 @@ en: text_missing_permission_manage_custom_tables: No permission to manage custom tables! field_name: Name field_created_on: Created on + + label_custom_tables_settings: "Custom Tables Settings" + label_enable_custom_permissions: "Enable Custom Group Permissions" + label_allowed_groups: "Groups with Full Access" + text_enable_custom_permissions_help: "When enabled, only selected groups will have full permissions (edit, delete). When disabled, only Administrators and Managers have full access." + text_allowed_groups_help: "Select user groups that should have full permissions (edit, delete tables and entities). All other users will only be able to view and add records." + diff --git a/init.rb b/init.rb index df5e9ec..9648edf 100644 --- a/init.rb +++ b/init.rb @@ -34,6 +34,15 @@ Rails.configuration.to_prepare do require_dependency 'custom_entities_controller' + require_dependency 'custom_tables_controller' + require_dependency 'table_fields_controller' + + # Include the permission module in all controllers + [CustomEntitiesController, CustomTablesController, TableFieldsController].each do |controller| + controller.include(CustomTables::PermissionModule) unless controller.include?(CustomTables::PermissionModule) + end + + # Also include in the existing patch CustomEntitiesController.include(CustomTables::CustomEntitiesControllerPatch) end diff --git a/lib/custom_tables/permission_module.rb b/lib/custom_tables/permission_module.rb new file mode 100644 index 0000000..01e9839 --- /dev/null +++ b/lib/custom_tables/permission_module.rb @@ -0,0 +1,23 @@ +# lib/custom_tables/permission_module.rb +module CustomTables + module PermissionModule + def custom_tables_user_has_full_access?(user = User.current) + settings = Setting.plugin_custom_tables || {} + + # If custom permissions are disabled, use your existing role-based logic + unless settings['enable_custom_permissions'] + allowed_roles = ['Administrator', 'Manager'] + user_roles = user.roles.map(&:name) + return user.admin? || user_roles.any? { |r| allowed_roles.include?(r) } + end + + # Custom permission logic + return true if user.admin? + + allowed_group_ids = settings['allowed_groups'] || [] + return false if allowed_group_ids.empty? + + user.groups.any? { |group| allowed_group_ids.include?(group.id.to_s) } + end + end +end \ No newline at end of file From 3d06d5f61661e6cb89f03c77055e74b888c0b7e0 Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 10:17:40 +0530 Subject: [PATCH 22/41] removed duplicate contextual divs --- app/views/custom_entities/show.html.erb | 53 +++++++------------------ 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/app/views/custom_entities/show.html.erb b/app/views/custom_entities/show.html.erb index 7143b59..c1e7a78 100644 --- a/app/views/custom_entities/show.html.erb +++ b/app/views/custom_entities/show.html.erb @@ -3,8 +3,9 @@ <%= link_to sprite_icon('edit', l(:button_edit)), edit_custom_entity_path(@custom_entity, back_url: custom_entity_path(@custom_entity)), remote: true, class: 'icon icon-edit' %> <%= link_to sprite_icon('issue-edit',l(:button_new_comment)), new_note_custom_entity_path(@custom_entity), remote: true, class: 'icon icon-issue-edit' %> - <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon icon-del' %> - + <% if custom_tables_user_has_full_access? %> + <%= link_to sprite_icon('del', l(:button_delete)), custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), data: {confirm: l(:text_are_you_sure)}, method: :delete, title: l(:button_delete), class: 'icon icon-del' %> + <% end %>
    <%= title [l(:label_custom_tables), custom_tables_path], @@ -24,49 +25,23 @@ <%= render_half_width_custom_fields_rows(@custom_entity) %> - - <% @queries_scope.each do |query| %>
    - <%= call_hook(:view_custom_table_table_field_action_menu) %> - <%= link_to sprite_icon('edit', l(:button_edit)), - edit_custom_entity_path(@custom_entity, back_url: custom_entity_path(@custom_entity)), - remote: true, - class: 'icon icon-edit' %> - - <%= link_to sprite_icon('issue-edit', l(:button_new_comment)), - new_note_custom_entity_path(@custom_entity), - remote: true, - class: 'icon icon-issue-edit' %> - <% if custom_tables_user_has_full_access? %> - <%= link_to sprite_icon('del', l(:button_delete)), - custom_entity_path(@custom_entity, custom_table_id: @custom_entity.custom_table), - data: { confirm: l(:text_are_you_sure) }, - method: :delete, - title: l(:button_delete), - class: 'icon icon-del' %> - <% end %> + <%= link_to l(:label_new), new_custom_entity_path( + custom_table_id: query[:custom_table_id], + back_url: custom_entity_path(@custom_entity), + custom_entity: {custom_field_values: query[:selected_custom_values]}), remote: true, class: 'icon icon-add' %> + <%= call_hook(:view_custom_table_sub_table_action_menu) %>
    +

    <%= query[:name] %>

    + <% if query[:custom_entities].present? %> +
    + <%= render partial: 'custom_tables/query_form', locals: {query: query[:query], entities: query[:custom_entities], back_url: custom_entity_path(@custom_entity)} %> +
    + <% end %> <% end %> - - <% if @journals.present? %>
    From da81ebb343e9a4eeabf6cbebdd6bf4658fda3d87 Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 10:21:18 +0530 Subject: [PATCH 23/41] updated --- app/helpers/custom_tables_pdf_helper.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/helpers/custom_tables_pdf_helper.rb b/app/helpers/custom_tables_pdf_helper.rb index 5bc8959..1682f8f 100644 --- a/app/helpers/custom_tables_pdf_helper.rb +++ b/app/helpers/custom_tables_pdf_helper.rb @@ -93,7 +93,8 @@ def custom_entity_to_pdf(custom_entity, assoc={}) pdf.SetFontStyle('B',11) pdf.RDMMultiCell(190 - i, 5, "#{custom_entity.custom_table.name} - #{custom_entity.name}") pdf.SetFontStyle('',8) - pdf.RDMMultiCell(190, 5, "#{format_time(custom_entity.created_at)} - #{custom_entity.author}") + #pdf.RDMMultiCell(190, 5, "#{format_time(custom_entity.created_at)} - #{custom_entity.author}") + pdf.RDMMultiCell(190, 5, "#{format_time(custom_entity.created_on)} - #{custom_entity.author}") pdf.ln left = [] From 7608ed5110d4bcf46d9542fbca815377eea78238 Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 10:22:56 +0530 Subject: [PATCH 24/41] pdf changes --- app/helpers/custom_tables_pdf_helper.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/helpers/custom_tables_pdf_helper.rb b/app/helpers/custom_tables_pdf_helper.rb index 1682f8f..cfc099b 100644 --- a/app/helpers/custom_tables_pdf_helper.rb +++ b/app/helpers/custom_tables_pdf_helper.rb @@ -93,7 +93,8 @@ def custom_entity_to_pdf(custom_entity, assoc={}) pdf.SetFontStyle('B',11) pdf.RDMMultiCell(190 - i, 5, "#{custom_entity.custom_table.name} - #{custom_entity.name}") pdf.SetFontStyle('',8) - #pdf.RDMMultiCell(190, 5, "#{format_time(custom_entity.created_at)} - #{custom_entity.author}") + + # FIXED: Use created_on instead of created_at pdf.RDMMultiCell(190, 5, "#{format_time(custom_entity.created_on)} - #{custom_entity.author}") pdf.ln From 7fe60958210b1ba6f183bb9a493dd047c328a0ea Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 10:39:51 +0530 Subject: [PATCH 25/41] removed unwanted header --- app/views/custom_tables/_settings.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/custom_tables/_settings.html.erb b/app/views/custom_tables/_settings.html.erb index 4a97a23..68cbf38 100644 --- a/app/views/custom_tables/_settings.html.erb +++ b/app/views/custom_tables/_settings.html.erb @@ -1,4 +1,4 @@ -# app/views/custom_tables/_settings.html.erb +

    <%= content_tag :label, l(:label_enable_custom_permissions) %> From 5f85c76e1cf8065aed27d6487c3b17f938dbf42f Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 10:51:09 +0530 Subject: [PATCH 26/41] view issue --- app/views/custom_tables/_settings.html.erb | 1 - config/locales/en.yml | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/views/custom_tables/_settings.html.erb b/app/views/custom_tables/_settings.html.erb index 68cbf38..117657e 100644 --- a/app/views/custom_tables/_settings.html.erb +++ b/app/views/custom_tables/_settings.html.erb @@ -1,4 +1,3 @@ -

    <%= content_tag :label, l(:label_enable_custom_permissions) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 4282b69..c4a23f4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,6 +1,4 @@ -# English strings go here for Rails i18n en: - # my_label: "My label" label_id: ID label_custom_tables: Custom tables label_glad_custom_tables: Custom tables @@ -31,10 +29,10 @@ en: text_missing_permission_manage_custom_tables: No permission to manage custom tables! field_name: Name field_created_on: Created on - + + # NEW STRINGS: label_custom_tables_settings: "Custom Tables Settings" label_enable_custom_permissions: "Enable Custom Group Permissions" label_allowed_groups: "Groups with Full Access" text_enable_custom_permissions_help: "When enabled, only selected groups will have full permissions (edit, delete). When disabled, only Administrators and Managers have full access." - text_allowed_groups_help: "Select user groups that should have full permissions (edit, delete tables and entities). All other users will only be able to view and add records." - + text_allowed_groups_help: "Select user groups that should have full permissions (edit, delete tables and entities). All other users will only be able to view and add records." \ No newline at end of file From 0368fc7d73b2a2f33ee67b45011e7b2f507e2616 Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 11:04:37 +0530 Subject: [PATCH 27/41] updated permission --- Images/custom_table_permission.png | Bin 0 -> 59036 bytes custom_tables.jpg => Images/custom_tables.jpg | Bin .../custom_tables_v2.png | Bin init.rb | 8 ++++---- 4 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 Images/custom_table_permission.png rename custom_tables.jpg => Images/custom_tables.jpg (100%) rename custom_tables_v2.png => Images/custom_tables_v2.png (100%) diff --git a/Images/custom_table_permission.png b/Images/custom_table_permission.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c2caa15e59786d541f2436986b0410db04ffcb GIT binary patch literal 59036 zcmeFZc|4Tu7eB5=Dxsc2k+ew&ktKUlWZ#WlCF|JtZKzZ#TlTW=%M6AXyBQ@r*=Mp2 z*=I1eF&H!Ry`|^*{C@v_|NhR47x#8u=Q`K9&UN1JbME<|qou-hn&UJb9UYVElSg`V zbo7RFbVub*o&e5BCx1TpbHr0m{qOL9;&(aKXlw2Z}iZs zzWj0g=*f#u=1Fa-U!*)m<20_0f5-3orG|YW>;|J20);M3%`A}Rs^Ie5p4De0QV6^R z7Df3ZfTX~uscDY!Glx8c=>Ay6tJ8bOQ8l(_#m5YPc$JRs3=9M}QdbYZ%p7);@4M;W zr|8adC<03P^I1Ih8v6I&K8E9lfA7d-ISD;_c=`eNS%U+;0H2rgWwjG7)rhlyj-Fn< z7p8PFa!Vnl2R*ZZoU)uYH_nj5FSwOhHkt|w3VPhX{LkNR+<4sUCvZ62=^f#$7^+>x zNRZj1M{llQmyOFK|I_!|GD~5rt>X4yL8jDZncBU5e4sfwN-EJ;hRdznyl_K&X;Pl= zo?0kWj5u+K*?)R>=2G^#8_M>x?J*ytqKqCrdIbM=XO(qtuvRwQ_U|y7SX)w-{D4Oh z)zz@ZMvLY!7LbNU#5*R=e8tccCMlWRsZyTGyjrO`fr0f=+zO?iKVNKVZFO9m`gSeU zWLzMX#cuPC>AAn72c2>Kv^v`nx3`!ReN)xCEo%7FHKhm+F6n`t(khoCU(ev(u)zw^&%ZhjMpd-kRv=jcBKpQwz`A zKV4JP(lP=bNlr-_?(gq+<_OUyon90MdYHaic(4HI=uX@u)TOShtiCL6LHLR8qoRMG9=4kw)z!bY*zN&@-^`13e@tq9pL!;Tww&T_#*s^}vk0ti)Z>9{&Ej`oL&<2zehLXsB@-~w0wprNp-~j5 zFOJ; z&c48>%By{Q2Zf8q&zwh#ZWTOhez!DKq>t>qMld|f$C$2XGU748eU4}+o_ z?(XgetbJoZJpK0iC2f9T0S^zMfFD4F@f8<^QpQI0YkUOJ1qJ)Yh*)K8?W4C=9K5R1 zYpW+3siU-_jnMPB_}q4fJ^w)?IS+YmuR&uEbPcgH6n&afJR`h^igh044uNgVYv>VB ztA_$V+$il|@~DlEi8)u}iH(krneD5;_MD9?g)u%zrJUJ3p&%(*0aky^Hz#9xyjDMs zS340<3IzTTs^4)V96{?!@9q+OTMO;puzhx76(->SC>$I68(0x{RM&mMyGbPQdZ(i{ zNdJn_ml$Mi-dzxC@Iq1Ds1>P*UQL8+q_EK3HBeRm%64?K+2s(Dha4i)OueJM%3}Yc z!nyu2WW6ANHziG0X=8{)0*@?k!Tg<&>#n-bR@Ds6qHM0fcclwUA4T5@Zt-M_kT6Ne zuS?Dj>$-eedNw>022*!-cHRTDTAE}PJW^*y8uyG2Cc>=vBqWT^otyB~XzYFd;zh1^ zQZH^8HaswJJ*@d!ntndC<9!DSmxA5TNAC8x;;_VlO5ev7Lyxrc;sJDu;u?K*5g5uU$t;Lr3$55*A|;H0uIu z3kY)=A?o3b)lcdynALq;Ejn(P7KK4({EP)6*Ijg{e}m%J%0hx13kO%C!o(l1D`S(i z9vH@6zkc1TM;v26QRi)1X&-ZQ_h@c7o8Um5H~!%Cai+rb5-OhWL8T~a_ocjbP(ZZ@ z%m$^}c1~PEtgxJgvCQYPgMpIL%m{oWAUiK?n|ElSFY{d@wQEzMe<1T+>y2sD5bjYp z2M5Pcw9=vdef({q)jM3OJ;SrX&kK8&rh4gPu`7DD*umwv{MG&97lu5@r$YBj}Z!2^~ zQF(J;DP6v8JpUo6(1Yd&|EO)WQKQQtyJOn3qigtn3c5gh*K=`!%j)ZI_}7DnD*c+N zXPy7d(SzF(DVj>}&h&pcuSj)PaD10OYEqIdtT|Nnq-e&zVpPjZt>4<4+^oWboX!Aj?R~&D5R?(v06M0Xrgp`o=+=!c1Yq&f88F-JA1My_iHCw9Hz^=G z*<1BR!6dOk`U<-p+I1yajy4dsX79<@&>p+>wl!M#VC z0(<*Ml(cCYuY8{;y?%W8FAl$Do#Ee{-W197K?YuFp2v>HP7S3|vRN$Y#1P=GeuT+o6-EysS zn2=~37AAqigE!SG6krJUnYI?#o-fZvM7Gx0B(B!^VeGl4@oH3(hGCo_s$ZZ-|H9X? z^_dG+6dCLWo%h(J-e!DprarMPIvlhqfLgaJM<*1%2#;wT+y59mGocI0{{B?`YP+$S z1g6qU(c;6E4#*Fb>?`6X{nLt(gQ%pkQVhG0ZVYUb#1I|C%=<|}BuJ7nDB-R4alhM_ zxD%=zuNRvflDE65jzH;u{IagUB0pZL6vn)yLw?q{o7Q_Dh^ygd z8leLIKR%Zt^@f2RuhDI3n_)H4(qwsrG)PgqA2!r$>#9(j?ldqZ{OPo;?KE4jbP)@0 zLv~ATZD)@~akV@*)cGS@kpD+0h+~izf*DfJcdW29aT>B1$*oT!m8Cm$9+e}yy@peN zSIfiazA;Q+BE&nEYzZo@C5l<56G2JXl3RowHdLPbpx(_1pBpN1H{ z4fdpwBA#fcv^%V3aDNqZ5BVK@1G}j2u}~~RfcenCP?2<)$Aoau^T`DtRE)wXW_5I5 za{NfW!fsw*V!5!#s_EvH>Ojq?exczNPTL2y;~05!htX-D`U&UN4DC(Jl*`p2pYu1> zDWK|Fnd-3>AIS@K3U=nbsp5&}|9B_5Yu?`8wKCGu(b{d{r&S# z>Rn0ykRlk&Q&L>KRz^k!VngwO?|qwZLoEmIU=Zlv;r12ebzz0&)r@P-J_^)1;d*YY zlOW22K9Eg`Pe=$jwKIe4rDPQGt8(NC7gHZ_8&bpa1K5ocZq`;(iN|7d+b?a#j|rYk z%`3W;p5cfcbB=c&f{jV@sDdVFTywsR6Dr_VIp0NoaJeBmty6@jTWui1Awx2lqa^Uk z=V=;BH}(P~Er!w3jEVS+#ma!B8?fF;|P? zPs%6>{Npjg}_LiUPJUD0H@ly3P^1mOz&0*4D@J%}8C1 z4^MjY`~PTP6-T|qoo2_GoB^SZ8^)cMC`2MHi6KEoEFtpB1J4?%EE$6n6Kt19if|~u zVBDPy__ut?2LCE!%8Z2id5jBXmArUFbst3w2$o1b6W&rEgTWaKj2lpK4^ z$B$nV-M&nv_O;-BH0&N)FKRXGw z2n#i57?ikmYy64eABjGlD-3`CzOOG!NjzZ7{;`S*th7{%J9sB6>WUl`2r`UQGBXZ< z>Hz=>;|?waOboc`0Dv9f%D$^?q|*!PR)FXGHSSV9W_mHvBAmFyr(jf)I}}oawn|F5 zRhqK>Sc_bnTqXbfPEEZD{krnJ%}(1%_|~h3E66=oC}nbQuv<-Z4zA5Lpn!>1Hu7(;jJ$!9N;IrX@Z>e5Fz1buGp9r}+6DnOU&z`?|zOUuqO!+&fO;>k+0I*If>U z&d-dOX#~=?xc9uC-up8TecFKj>gwu_QUj~9&b_g<9KCz}`onYQ(mMtqr=GjR`?&XJ zObk9{I-)da1R>LV={v$bkukZ^i1iG{t+D3xjq$fuF$FamR`mqXDE8d1uQToS{8TEf zeNNy@R+tBzxbOEg0clzGZD{_@_WrWG-1FlG!rE~Ow4Po*y;vXG11VaA1@9ekts#nK zct+5$-XVnF>$Gh+%O@(Tr>mnLjA~Kx{HkJAS{%ARi*TI3rzR;|r)lkJ%hzE*^VnQc2`4VvU6)%RSyeCL zV86X6D*$dEgJV8_{#M8DT^ii*rTUOS>a%)n11~3HZIQ?Yj;7z1-WjxhUAu02(ikYf_fItlHC60qcG!Cdk3Aa zQ>q-fpTXzFzhcBqgzs^w4EbeROp->JKGqt2IJSE8k6qKf>9^ zSwK7VGjvT4lyv@V)qrvBz0BnQV`Fr5TSxvcskeVA9XdM5ga6m?Uu{C3jx`1XPVeO3 zCxGOXlq(_o?*DSYN!MRPo8GGA zA9mkl6Oqa9<^VYGJt_$Whn3c;(YCZ{xd4l_p8c;uuN??tXIB77H#9m5?S-s*0%u}x zGI2_1{7?UMbbgN?J=&?tQHkc~D_t(qkKUGcPxHalMTj^ym$5 z1Wr%^r8xZVw9SPJ0y)Jabp_aXsc=>^ol;Re^BH4rp6J_>lAzk!=)&Bebr`4c(+7-N z3QQ%S)K77@&8kokokp=&pU(3=B%0`w&I3g2V1c$-c&~gR?v$+$cHYnw{2i{v6YY1@ z;>g$y9%LP3SIfv^wuub zwPNs0<8m#~)AI6d$Lcef&W)FO^rD^SrnuWi#m(H6D^>Z>ep^eMih|PW#-d0SS2DSa zjdf2{xnOZxH0MEQ?8^K~<|>y=oJKpY!s#*;mD z@LTUZ^_b*{&Zd)@ZUA{V ziPmB&--hM&J|#9f2eoZR_IuFotX$zMs<_k9H+J!GFjK?5io>?G%&HvcVX9~gNM%8> zK{-!kGNK99(B#WvVE-6~uyx~9w@f-6Q5EIIQ(Y%E!QSnR)@xHY6}dHr@ilw3l`391 z#$9F5$vGK;A|FwIKwJjoA0FvhB^@8z|59Pu6JX>1K8Zt|q6KhykEv|H@%oK`*>hbU zCx!)j{WXUK-wHr5T?M;ay`vpe4>Oaq3Db}&`DZYe|ukJDIFKWu^SXpYQhTP@|3C(p96z3B7 zBp%ndCskSZ?$%6!A0*f`M6-Dt_2xEpbuQn5n11Ki7?%xy;4fnx{ek!}ias8%7K~$L z3+U<@Tu-su)g(o<*e-2$$$3*IBw!}3LzFq+E}0Zm-B&Zb{71}0Yk6m9XXUs|8Lo46 zCN`Ikg^b&4HKNFR!V}DHcP#|J zPfW`Lv~89TA?0d~HS~YqsVBY^c9wX|h+GbT0yQ%iqi$}rhPslFlqUo6!kmh&@eX@I zRyhIJ##jB+=6N3I=R+n>UX&=x8avgBccG~+ZZkA|Yq4Fy+Ck^7rD^k^%3m_XiKMq< zjcLA1Z;<2GSnrtluTxzcmgV`YynQOvr*#0o7m}vO*6vIG{^Dn{1XllWm|Lqr#QDK= zE=}Gl9$uAwdCnA;ikPl*NkuH1Bf5sKsxF!0pMbtbfgfk_ULALq!(7#4lb!3J6VLx> zra&qVY+7BO5DMcvZ@6*dR0%$N`|35myLgZN8hJaJ6T?GimEEctj^KlmkEMURd7H0z zs9#>qBvS?AIv{EQKjS&k?-$AGDu@qRXa9ZmP@a=GK%Rv`_eK=f6dfJ=6;W{;a9HU) z^b(1Huf5~7qcxc%Q&POGA>}(hx#D@3s-a%A=R5r(?%k4)%G8#Js$Rw9XA@vT{QTBnS(xA-sZZNATU7%1vQu@( z6$_u1ymi&6r9Lh2XBu+Ro0aj#n!>4@>-XT0v0@M#X)EGzUZ#R-n-mQl^jy-7@&fb6 z0xjJP3&@w3)T)NM5DR9wMj=)|QMFzZy(%Oe0}=l9QLs>_r@MCa%b4}f>g8MjB-;R7 zPw0EvzSzuLZN@Ew2Wfs2jpLU;t#n+FGq(-y(EsEO;=vl%5jHexS#Br?QO8$LUIy(? zm{J!PH0h1j>&VkhcLsLqlQ>@ybX<~h?)(yG3R*Ih1oAolCVod2_*fWhnFGJ31iDzB093B4YMfna%gkD0@i>MnkPF8nao_cpq9 z#Lzse>H_W@_BnL&`rroBvl8t?jlJ}A?>J%DmD-X|G?+69MD*qD)p$V^y3Co)O&Y+n zK33JlX~xuRjDPNSv#EO#3a#C)tt%a`pD(hT7>ryRaTMrA-vrxCsXYt$^2MrQ)$+=u z`ru*XZmU8S;(rtKrDuwZPMb>$5&k{lW@#wRXrMD?&;*TmW`EnWFf;SPPfJdT!ftn; zw)^bU(JYHxe}mYUN8oxtU%%euFwjuDY%O^Dn(8yd60h_zKf#q|Ztgyre`CO7=1L~D z=B>S@8=~UI+obS2^&dZee8Dn9KV!0eot1TDSbz9ks}ed}ehH0anK>5FoD4k_G%1s6 zrjJmxiF)Yl7^!b=4l^~I5gXJk9KT7Rs!1`b$tq&D=uI>KNvIh>WVV+Us}-D9Ggq^k z>O;)E9UbD&6TTe_G5>P;ZORb;jAx=<0AxDfXr{tlqbL5x!pqa@=CKWJ#HhCW?_7gg z-3Hj(-JNSsL+I1LX4gh~TyTlC73zz}{o~9rBfL2rk;13)tn@`h%lPD2fp3H8&mQxF z_V+bL(u}g0Cijv~!G#Y3=fQs~9L!ZYdusk^2n*R8@zMz{1xwfjsqk4J|%3cf@Enql2NnsUuBiF~X~ zwU+;m=qRh8P5}NEz0@OLCL0f9?IFYKDqnUrn2ThWH-9Aji#D3*&}BKMB_9X7Yfbcw z@&N&i2zQUZNq~A>x|II-F+abDy=^LjK~?{>fq_9T{Zi}@R75Jju7sfyYl?+=M|din`XzXG$pl|%m~#5S8gxuT}+8idmF^s}rCY=SQ(yhge?nisMgvd;5&1a{Ya@whOV z&80Z%|JgFz=28;vu^}^S0QRd*@Q8tV>Tgps3R!R^Fi_Vpwa5w^4n1m_J4cBSh#@{U zT)1E$=d=7-EA@UBkV63|xs1)ELw2EQt{A9Y;o{-~fY3nMvu3g9KTd)Z%7`jDtPf~q zCk%T7_GkxHEx=fquE-h#SqQ@eEOGkJaGF?o`1#d|_5jwT9*{f+;39(K?{h5xME#E^LVq~VJJjF*tmMR$s4ws&kT~ZD=&aBa zOnQ%gn0OzC)_0$NnYqQQonETu>7U-r5R3#nYd0-X+>e3r1IRb`d0y|RkFcp*O2+*EDURw-E z7R$pW569?MB+@3;ZbV;dz92p@(%o;I)j39j4(G<(4Lbt?Q3 z@Zw!yx(_;-@4kc=q63{w0rC-MjDdr{eii=uWej$g1PTtG4IegCGyaB?WDf@GKYyN$ zr~&=UBal9ft)Q!%$F{b|Tn{Er5%Y>Dt=8%3>G)IWB^o9A;N=a2O3`M2|AZ09!ui>Z#x+RRzQ4m zGPE&}5trgc0Gll(W7Gef%H{%G6vR#cTIVf`h zuIm7kgH75{Ac2Pe)IZTSRl5Q4tlWQ}LFWK~@`!KO~-o9<*)U-vj74$w{ zB%qowm%scAP=Gyp;Cd2nxbQ7~%SUem`{+62Nzti?|H=OnsBXh;Ee%ThZ{+^RIUhJS zdB_1lgU+82AF{W=yIDy7H0!%<1N6J?wZbPb{9S2j)bs#A)F$gwV(n^Q<~pt1=|tbr z2OU63mrir-u|JpUc&A2nvLa%ALVT8AUu;AvSgJD)Bosd?;qL^QJ*&E9n3xik058$M zscp=DpY|^WbLQy*!~F5%0N_S#Dn>2<9Mu{&ODT+BTG|x2g3f<8W2$TaB9v`)@N$^H z#n7XPq&fJKw3~T_$5(OH*b4^%aA~CBw})vx9$c|F$;KBil99QrCCVq3sXoI6+Uwjm z1XRG9ZEKg$m{6q#{rjbSC2WbTzAtP_Vn*JI9tKyM!Z9S~cqkp*> z{xADScd#ZK_cywv{nj$C1TKrr&CT%v^ilO>0PLU?D=Dd*L) zzZka#4g*`)`Z^EV%7Gx}6{3SB!#7(5AQ2kgJcdSoud`>K$1g*-8J-Aq$*G?-Ugi@J z?;J!_*_X2UV*==f=>E3?0F4skGWH(bQ5#jP*;!_F?t<95NG?uI+ZCu)y2hGXmg0a;u<5hlylhjm(=@}H1%baFCqawR|aYcnbkV>kitc58Ig-{nla8}egtF2J( z{RaT?_;thy_yOEqr366%SOSYxw*=h$|L2r%iW&;@m?30)OYLwabE4aV7$dMq=}BVm zFB=x>(=A8*hO8i}iA8H29mhd3u6PF_>$E$MTlef`IP;ch{*i1!5!l)eazcfLw-44j z4hBabr0i;H?jLv`z#4%X&_4e&g>`?(t+%C3>^eu1L_`gHJBv{5(!{48Wsos(gjf0AB!pC+-gUoPsCRJl!&?s@?pgmn$Z~RnPM)32POuIw z!Xk<&NPXR3VYnEi!~WaRT&R`0d5w$7`cyhB>;pb}liZYXG0B9?sw{DD>6qi}NA7L#%$t_w@LA_K@RmZMJ z3SMOtpSsw3A7Snf+xO2wmb;24q=-u5Gs?TxtHkQ^S_XR;NBA31y3DQsITmjtejHZaKU)tJ?7*x7NxWb$oMQQ-#Y*8(Gx^zhB)A3ES!{ zjgwwy6#WBn%#naeegZHKwcTJvQqYvpu5XzhvdMd=s*rD8Cf%v^`^dl^b`Pzj8Qizj z+i)kfB;Rlzp=TEFh6>3*(?Fk!yGu-0{mdi-D_$_VqQ_{gvYTF3*5S&$QPpf=S(d%~ zm;j!guaDUI-K`qMAmwaY+sDU)zSI(X+mXBJ`HK_ zYz5bRPtGneb&)0vd{4=PpsNPZbIc{$Yqldo@kU=TYfQxMcNpKh%?x8lVqHbx{JZK;Ow9$)e11%3bWoG=HLd z00XdRdOA$dkCV#88^aS5Tis^NpQ)jDjyqtWvNQCmgT$wWLq8yqj$eXM!k!{ht8PE>7NM{Vc)m2DB zM8tq4A-pIAE^n2hL$Yc3MS_Yn@~l7A9A zOzlt4C%*H0HN5kq-*Jg8fvI<^B^`;%p=}&L)%KW%w)O2iikzGa@i&09j5&UhC8mac7(|Jh znG)^_7M*=(+4|4Ec|9y--hYJyQqiqi)`)9{NCjS7RR>%IC%fa`Z#~wZ%xoIk=$$ug zCA1RKYnS0|i$o3V)9*N$n%6YS6Em>Z5_s^Pfat?yN`=ziqF>S>3YajV_t zPv$?ilMKzeiT_6C1!MSgdNwr^H4ODLV98?jca8IByaa`|j$el8_i+Mc)nrY~?}4{! zcZYkr0xdsa*Bcr-IK7DRu1N|^UUm7mij$hhYz^aVIh`B6X<>J7DcoN8ejHoF<%%u- z{?yeE2b*P>yJK9CB)`M6Ug6Z(S(A6yBaop>Ug}m}R|dLh&b!&ANgN14}G3{GzqsLD&xPJvHvpRaFu^w{Dq+2O650!G?wm!0S>{0b7NM!d9g$ z@;eVQD9d&TzxC%pGOOE^>1E6R5{EHGr!!FyVqz9LI}@46YWauLd_dc65f0A!{{0~Z zC?_0b30^w}Z22ZI;~HR}GPjxNR6|r=%RF0?4a7UK-0^XDhJ!bUKydU2tB56U@(8H3 zO5zWKOtq``B>*oqyeUQQIgU6x@C0BC}%ugna5N^|SvnK>|NOs69jH@fuy!PK)r? zC>XvZV72=Zp~ekli*hZ(FYh`Ebb?Z>8!EB4g~tn|@;AI6vyRSyF+ea%UdGrv#Hc$k zM)W=&sVglX2|7C!`!9Jgb-LW9i%ncy9LSZbF)=ZX)_KE#RxNVyzYoam7>nfil01`G zj!}af+Wt<8<D>na zrKdVzMcrVxv9klOj@Je(W`%9B(AHS?$S7K1Pnrzqo^7{|g+rZj zMAU%#O+P`wtrwpsL35`v1CHjz#upkso?IRbLa@%=mMlA;VP0RL^}&)Pe2UKe`~L!B zfMw`cpbRP$ef_GZ5_MS@NORsS0)nG37H)K+pjoAT|8*<>Os|QgU~|0BPQeU;4~A~7 zxtP~w%sqVa<^1R-!h7Crn0x1m+DF4=4&oc(CjNwkF9o!eTCv{g5lKEtZD+=R@|2vf z;f^MqNEsue?(Aj$i;P`HM@PRy8UBO-ZndW*SNqZE{2xCaf!*PbU~ocmvZLT?1przW z85DM2Z7+}H0I?E)D>_$vx(<1O0__YmljL}22#pki0gzI0505ywXojc6o*1k(#BYUm zU?Fqx$LlWYHTr@A4){@J|9T|2(_GM#4V)z`1!ShAHh}aK`BKe}5!|vQF_BH`GV_Oj zL+TMgB=_=AsAxXX*T)CQU*B^W;WQwwhDStftsLu16EEy^JtRDv=-AJl%Q^_XrZay~PyGFn9WiDa#3xl_qOH(q!cZ~?xk6TS4&HNcUY+c( zWt3{AmXljp14Q=E%SqN-4Si(vDP!yn5mZu8VBZSps+kusvVR=i`^lkUVb{ODfoo{K z0JOM-+|MSF?s5O5oGQ@z?1QSw4rz0(xr<_;tT&E_pt>L>+{iv_Svv}4-?F1L6YNh- zC!XE$WTta_1uRgv)jPDiWz-z^VLEJkd{-$%X;;?;NIw&NW?1g zQQENh1m(FfEpwHjz8WUP1F}kS_W=zAS+bK!3sdt2&2_Dkt7LO!ljFZ%-V$z*{yLKg71g}kGzt1PQ^5Gsznju_-1L-Wreb)pX?Tz$8`6>gc~8dg88e(*&BLZ6FjmF zPk-W)C*27CR0g7=bE$N8yl2g4J8#RRmz?>Ng*O5N-@n|AKo_1<8FKvbin&VdhDL@j zfufU9XB=`rA}tFpSO;QXT1?6fO@VpMwsI1SSV-H}W&XvsLC!I&E9(!#^cU3bJ-iXM zLumd+!snj5amV}YfX&XrWxA=2AK_5Ct(ID&oOEB0GtZ5csXndyp9=b+dzM@7m%Gv7Y!$7AJDt8=+(y`N=T)E_3GA zBiRa81fXJ_ff1o8M6Z!&Ha?6OmeJt?lb=W@zx9f*)dceTMR~hz4)SlN16}o9pZU!Y zA6=tc`z6e9UWQ?&fwh6ww8^xL=?;ptk*2-GZxqQ?E>89z!A$rDA6R7L`3R(D@8YjM z2t#U;f48NarFHM7NWkXcXlDHu;*zx{Ms#iWl>Nvcgn1)kHQ4)zzdZVIa4p#o_-$oJje>XPpJ1<5V(wTzU9!B$=}?+L*_oJkpBLz|m; zT4S8@xL=MkbxFGwamvTyA&sjkXr+!4h2?QB8_zfucnqcPpcfbBTA)VOt-oh(gJ_Z_-E>l{f~?$ga0WI(EGI0<$dH1!M2&Yl z@)g0+43^}pAugt4Z)D$vG*EUI#xcLE!glJLO!lShkXDKz!pHC_w8?+AWV-i)Op(5? z<$Y^zzU3sNSBaFhkXB4-Aq|N-&^PFM;==*CV#N=HO5_>6TsAi z>$zx`$1tV7@U1Gi1;Nq*dfqAG)q(-u5;fqdg}{UMqQF8dmto$F4Y!^}PKYUQ8Xs+& z!@f=;1@$zL214W$H?Cs(t5I!Kr3}|5Td8-ogqQf|saAdXD}BCGl#>@>1gcrtGj+Hc=~An)^c%*M@vbwN7s#!LDo5)<243Hg&(wg29SYzws`40~@A8LXsO9m+R4u z!Fdj{`QYjLe8skZevh6oPnlY{w6^@Y)O`*6dVtpCqwso*avdCYz!7gA5^BJn#5arTVCF3@k8+WC`V$Tc+ z1Sw>anUcV>UAz^|QShi+%msG#dTyXH3Tu~7JGRJfJ)?|kZmdTH63#_(2u3yvJyzLX zHR^h1lHV3(kh<=^b=#VM?3)o+e^`KZ%jw>5`pF%g4MFa!kz0~FeI2fL!@<>dJzrS` zD!-?$+e~xjsCG?TOK%s5YLBtn>efa)vVhy(h}ezb^Um<^7bgoZ%5{G{2`R{VU+n-WpR*8gJL{CwN6JC%No%d>HzU zllM*yqliIDiz^qA_~+v+G;eNLO-wrZTvfR{2cK%4B5Cp(?Q^KmnxqjX?@5?h?!*|2 zfM@@4gv+*hoIEx#Zi*!J$15I zvYwFziOe=i;=T|pGRrr7u9?8!K3hIBk)T%~n&aBL_quVSnvF}|*7WrYr&Q(S(w_Ia zIh46c{%^r{0q1cI%|Y|cc2eA%>1VN;`5e#Wd8g5g&A;wP?z_Q#2$QfI=?a(QJzl_l z0;z5%ceq%#1N&yAA}{jvPE^Rs+?oy&#!mUajIj2@m=KFMm-z14<)7G)Bi&lqni$dS z?RAA?o-UuUiK+xVQ*|IY!)y6hhAhg=HRv|iX64ehox$yVEce@;ioqN0*dyJqsZk8< zYZkvDBK;p(kBn5j`rep09nA1%C(7tO`JYw9R;}WBWwe7*nT>M5ee6@LLst62r`{KZ zgnQQ2v{BMF1<$@rl9?56ctK5N44!z6?V7^&WFebhLkMq3<45R(c!1Yvx1u?3E$VY4 zvm~P~hICx!EQnEo8W6K++&b4HBL|;A)O_!l=02V*8M{z?CG|0RLKt~%V!9+GOx~vC zsl8NCvDsPst-^C0?$)I=<8ax*dgW-2tQ(UpM+KQ(f~9v9BwAY?a!K6q*dY(&Hsx00 z{p|;m_TSqT9LV8;-m|Sm6Zzx*erOmFxW=$ei0FlkB4vuf5)v-CcF>ffpBUSS;a@c| z(nh4h!EKmD{-U`AgHLkytyenqpej~aNifeYkmVDa^){?_71&$C-On|r20%y({ zI?ES^rbreFwFHivPh7l7<=ymj9hmX8Dr|CgCOOe8i60|LWu;XFGs&~|am4iq@rVJwPkwA?z( zM~rDuFoP>+p5$97xXlJZ4?Y+U%=?g`j&|!RCy!DG?UJ`fIpO}e)k2JJ%k8mSS?ko5jVZhT)L`v_fB#>J@4L+*&3nJ})+_jC@qL!Njjgh7cFU0+>Wjlg-Jg_zk z*7U{>e!$P;CRLl|AOBS$7x4L8;B$vZTV{l5-q3vY`BcYaxVWA~nil z)aEjw@jS+1wJ}aib{wnXO-p0;7MyL&SAuR;!m$ChuKO|*U(DI}k-MYX1G!*rT=bl> z43@&&0GbESFz?BnciR|PyXFWFHsiLy`{zFlVR;)o-_yo@evP~-#pF+8TH*2xTU`!* z%EKZGv1{;s!WG)KxYx@Ac6v|9fAOXC@%x1r%b$F(6@6k(`KxvJ{2HfYy~@;FgT%Ca zIV3myAc#pBQ8kQjpt5Xhs>49k-Zixh2U%Y`9AZUSq|PCVM!GiCNT8&?2Aq3yoDxKs zOz_8~<2)QT3?zA^D zYgM& zi4Jpj3qSrgBHB3HT!&XHCqI^(kS5ADJh{2gu4r*)pSo4O2|?{zJifn2>ouKHI|m5! z997uKc^5)xByH`?MB{-siuVq9t-ZYfx!u_~(;By|RV3NvDC_}Cu`u-co&sH}tF>J! z>Lt_%-y#{c*gEAYEo`0djW&ue5}FgD#)PT&GU+GifzUH`(ROylwyD*0W3?lc-=`b1 zWKI@t!!`p^zNj5#magWh&ejY>Q^F71iY1`e&fZ>-scEuu#KkP&YY6Z_&S?nv_KvMc z*C3^OpOsjk1{(xQLe#3*)IMkYc&y4l0j<`Q5UO!lm-n*b?7>iMp+4T;d_d^~z{COV zVSc8`5P^bw+;kYXk&R7WzQ&jAgUmEgKi~QDrxC#LT)%PS*xoOFBj{4f^GfJ#mD76(9T_9u5i!sB>u~8jY;@^dyU`hwLt-wYPIZKP~c z0eAQ^m^wFFoX1RC5Vb28O`Yppv;85>U)g3Re$}kXK%KKe<|SkFzf=sJZN-R~>Z4a>HHf{0rMq$>~*q^h8R^aKc1r4u0ZYG`&5P)d;ATOjn%o4Nt%HHp-S^e$aO z``(3RpL2fqj`5xA@DCN!R^IiL`OG;Vqdv6lA%h!o-ugd>Q`gkb13iNAE|u&82K&>I z7IB?@1e6Ql6U;=^Y^Bauts6mu+$brp;^~Sgt^>VQUzu=qyn>MXF!i=eSiIS$$%py^%rrOh7)^w_PM@2oh3qO9*eb(496d(^H{iDmLEfG=cCSEta}C;m2jIh$bgcu^kURXD(>yR7i0;{f~PgkWjvy-&}N zc1KjNaIi_bs{?#9>e@qpXk6VIEvD~zkGyW7#Cy0IVcy&QYj!LaNWXf(u1e~Vgn#AsQ$rqdoALLK3fewmeW0%Bs=oR$=xWoOcgGsd!$Z+uhLdW~XcuA)m*YSEUX%TQRJ5oIrUn}?U!IZ=| z^rFvv;mk8E#7Y=-9X6&B)yU#&pl?h6mnQierL}hj13ZJ41l$z>Mgp|2OOHj#JH82%NeAL=~GmD)GAurJOdPGs0k+39$6{RQd4*Mih&DQUW^y*5S9R`j zZ;l9fov2yCg)paL!ug-?fA%l9{Mq8^l9|-rXIR;XOHUjn*x?0YljV=gum?=@JPUa% zEC~Bm-PU`A)t;3I8qc@y6h+gO(DoL%GN=d)f)8HZR$^V287F?3DE1uROt6mVDH!Us zjp>g#!0uD(DbZe?k?XEsF0OxEH8$Ijl!4>WBKb`Jor91q<6eekQ~%EKFwXWt^rsDG zzR)^F;J=6~H(Gmh#LU#xaws=YEQEZ+MeVtvYel-`r|O|X5!vV5-NZ1f@}amyuUXeY zla-N1RhR|N(|fI`{VitOv$v*3#d?b{Ze}`pVFDFb?ponfqak+r(`QvfbVPciq?zf! z<@AYKKbOZN10i&K@1{E& zlExAl6_Bp1A91rt`eEX`vNCnOGv2Z}soWYAZhnN7RiZhnYG)?WQ_a^I49}Q7h0V~| zHKe{0OMFIWMX{IvGyM`&gx-p)d{qhw@)xEA23cY@Ls{eAe>Th+cH~aTMX2T;S##nr!hA z_r#*6Q^;cuwztFMt5$REv>r|GOC4Ng;IQ)|mh7wb++0U&cIeFkXB`x~k_h1o=U|!E zO|o>#KeiNdGBwk6ejt-wwLfk5m=Hrvz9>QlM*Xrz@%zxiGLcLrT9?_~ zhMtr%6YLMYqS)`84j#olG28119t;W;>xtv(nTupzf_Jf294Ct4G|^npoat3F*oa`U%tlPBxvhmL?oACQjKTQpSG@zmnsHhG+?wq+z6A+pS){H2lo~Sg zQLF%Jv922g(Yw#qbelgp2-rHb9mFLqv_uAs^K~_9sYGVS(TvaLx+|xlrcKfIg>K2{ zHZ0dlWcF@JZ^MgsN$1bx9Z^%iVwF$yF7TaAIOu?*nk>*sQs_ctbT%S!7 z)B?+qr1#dAp)g|qE|}~V&mKK}W1F(^;LCxjK@!Ef3O&#bb6FCQS~%KATF~mA>|HV( zA3Z?$@xj!}I9ZRAB1pNGCb+gQU>%05J5OUoP`2(%r@keN!Z&*La&<~LrWxH*6|LN+ z7+(!>(A*yzyx$#L$dRf7$K4_>DsO!>R0<6fy-qA|)*}5tNljzXZip?mT{U+fNB z+^Vr~*y6S=t;^#z67KpUKieRcn2y zYK&-fYf0n5g8hzGMqm)9q7Ukh)MIUWn#Hb6=BN;Y72dWQ(kh(=-Au(ix+BR{*KEi!095sELb6eDlVG>XOW-OcP^K z7~5ColqBv^V0=h51I(GE-WMX~319L>T*1x~M0|4KPlRCk6(hshsz@RKvf zthi{XGeWGL3-$O`0BM(0Q#`e1=%=+1U8&JWJzO>TjkovLkLr*8@N%Dr(#LuNLvQAZ;hdF8z#fsbF!~3{IU$-54uUWJ9x5**jLrN+( zJu|G5a&1+F`Y7CXcD+>um*T3~wtbWf`e5d%aL!EgPcw%laECILY#tlNCFYW|`cryd zrKCec@=}q?r6ykJIzq(^C=A9%M=k4KpW)%*>C&lQI)j-~HtxE8Tq>o^X<|CScq?hh zd(#%h%tW3nFCFppsi{e6IcLO_RTk%Q*jv`)^VBT%4N^<0J#5FZd&e<6GFltE*{4Er z9;oD!MQ^W&>>RNu6FU4FC^T`YM+I7WuOgm*GWG$&LM&A}g8HNj%f0m>_gNW!>I-RV zH*NXICRGG}c4RH6yMn&z?~K=ZfIqR4U1OyuCV~c$(x+iL3HKmPP@rcv4WY1&=(?4T zmY_PTg^>yuYFNS_MCvbKtl&mNJg`=QmHUQjfCf{&voxw3pYUvlL7g7rtt=gI2rWdD zECaQ_xufKdFT1ym%WEdCxW-lWl^a&jVG(ZyPmv45J^~g5L?fz~a;U`rGi%ze{0|eS zWz{FI+)-rH#SJ-eW>t~^dRy|v5a~w;bk8CW3Dr?b_Q^)g%3$tMS3&kLovcB%tEpP{ z-8qLWI7i(nwxVLCRH88HX&XlHGe2tdx@Rd}MYa?j1%>Aq@G7fjxVCDiC_vXMm>FYF zW7+m3J#%@ks}0?(Gy+A_)7C0+l1NZqQoACmQ#KcBjF%_c$$+T3+90!|0jL2aLGMMz zxjuq2(7;oN)~hY%E~(!RBoR2LtNGd+q|8$F+^-ty2P_e_MRc5S#)nzn_d_*)lW2kA zGC}D~oF^cvu5I*5$RDo$W>rpNap`-#M6iCN%-BL}5aAr}X6%_9_kA9fp{}4?b`V9~=c)pSJVX)b?z*BVkyCWuq>}L@A%D~*v$lW10pEY(%8&Ek z^k%ue40-F{2VrdW*J}$`CDUiSotOLa<#N>uu}o!Q#kF3Bg)W9BZyo-=8eOsZ=v>wR zoCAqa13{Mr_l! z-kYGOQXZ^@h3_|w;%tw}iY;5DZCD}cCkR?v*i~|Q74mRAh%d`QmSDoiUh@Zcl0Mp$ z-3pWuGXhT}<$#~CI1at&jKqr*4`8dpjh-=o`pq8o6I*(4K&%^fjOn)g`ju?oGiT z$86l=&%g*Y+$(BO7Y{rM({_IZ?{+{^^)tNMx^y_BAsC_%rkuvtA*~cAPL8nc)ec)Y z^o`>Wv)13mx)NztmWF=a$zxocO8B_J?7X$j-9z}?w0OQyR_{Shw~F4<+Um|_%LyFYA;`pMN#`IV2QSA>k&o$YrJrI(vACYD zPEKmE+NTo=Ng?$Mdjuk2-z5bU8gs0TWj))2FgP`}sq4!+j#hE`YttzMk_7&HVaFJd zdNgu>I>uV4SXlrk3zwY@2f)I3OQZ?@n@M4Bwmj3Gyi?`XL@GSgak&WGXx)q;PJRy| zsYK4`AFj0lQag)4%?0Imhu0NHQWQ(x=%~C}msb?Ds(U}1Fcx> zqbQqow)I!HtMHk6C$2G}MXfXTKENe0VFlj9RbyvxksAyqgW92h9d7V7SFFmjvUor8 z+eP*A`Q^ciM|IdDl!l*`2kpvBAR>z?j3WW6!<6SltedVDkbQhT4`c(S?@>5UQ2PQJ zjj~XIj@R)xBad=?=Hd5wX*_#Ds`d=$PccbXg4~mX8Fbr!3@&oq!AJb8DgOnqXiDkA zo8!dqwy~hZ_Z}s?0R3ZalUaKUJPlo#WdL(*yB=ZIWHgVr(*ZZ%`1{KVADUXMW1_md zccd5Jw2EEWdz^jUWx#l#R({|wgT(p}%~Awq2$3cI zf_`X{83KXB>80^GMW;!mN>4Suwbctv4=lvN!M|_lne20H|2O4tbmVbKoW!j-Xb1x&C!Ml?%K7_T}G!ic?2)nkNb3w=uRrF9R6khhvsTe~| z?q`#G1%J-z)YMd9zChGclS+knca9RV)pHgyn~`yz)7)d@AR#-3IP-v7{6GGxw?u?S`4!~79#^_n-1rws9j`sD0ncZJpw`qRELDR*V zaxa`!3PYMj#1i(rmyW0dK}vT?&90gP*kcN z(x-sJ1_)8QfR3w{A?&gRQ7ARLQP}8g__@atK0#Wk%QHn+oPuHw@NatlW%IfQeaQEG zI@Szft!SPm#NKa;gKAeAddT`%PhTHc_9kAw8#Fl*EJJDx<%0eiEVH8vRAbh*wun1V zVfP_5&7->g{pZj0nPeU{Py}`+{kHtGDkdh$z*Y-iHwdWv&%lIdtaZ$B3WUi5Ci>EU zs+QYsDygZJFo7emhnoNzS=jscmi+UG-||+>+Gwa6jzB=jP&zQi~YsrSF#aSRO8Ap(RccM+k(^ zp@eeqqKx5lGw5(0I=cMT7mwhY(BmZe2aR(eP?VTa;qrR)_y@X)1OKuo0g?s7P@Pqx zMOC`fRJ!pcL)Q4N1Fy;jo43l4%4|b#IuF~fFo_jqataty)=4T&q`m=npF*~=IA5HV z6)o9sBqZx&1g5-$gZQ20hkBS6yuw% ze!7KwQXv5NzBanXe~2bV?U$!B&HiiAt!Zs-sxHpXmZWSXR4urGBQZtNM-#J=g+M}> z2!0BU8@i>Hf$v27@uRl5MJCQV9y~FX-7|eb3~m{Rt|AWW!QZCGGCnFw>Ik*sQT&yX zG4Vw8#8f%b!|;4(UkfYWU$Xv&)g5mXe}d1y$rEVxpyM_{|)3X!7oq?v8WDyQE~L{iDm0U>~1TQ5WJ86`owo&~Ys4+4N9I<4qHTm~u0l9GUIEE32ZH_Cuvx8(s>S?d$t+h3F zM{)Y#v&|5WKJm0~F^wbuKmo0#)K^ljy?i4T zBvefVB3x;LY-LhrfhGy5A&CWi2!Z_OtJsq#Q&dn6{kpU~m64i#G^t zp4U1Dy*7=nA4qD9X^%}(!-Nvot$vKLA|kJ8xkd7*R-)3TMX_1+$SbV{3J*W2kMx+J zQZ`aS&g3C(!99d5BJMTg-U#8Z)0KooO@~oU!*RNzd(TOu3-LW0GYlS?zS*x&4{Ob# zE z_&Q7u*U#!Gfgu&}Ai~>pZgq3o9@ONv(8iuLA0pl!pS+X$+yDi;+lJ#oNO4P<$4$py zGfu6i=75q0%WS-tBWRrWQcS41pt^Q9i`4ka-TSB*r%}V9o{SWiX3Zkb_LU@_-18QK za_u52_ulbTp{s>}5NsrD5NWa!Kh`S_a=1zw_byCTyn=1d-GJsIME+)04~juFhZS(P zuRCTWbZORWiL74yhY|XnSXJ$CdIB=DOOywZ(pO=07rNePabdU!8|IK z?X!Ne9_j^j$*#$k4$k|wW&z);$#$Asg=MWZ@(=%K1NXnbkpQUy@cIe5LjD;*eh6vh z6oN0{XL2gq^cZ|o{>!uhDpkR+2f+T-21p*P?d(W**)b`gw*#0y4&*cNaL=_~i&ZzD zb+@L*#?BnWitJX}eR9A2u^!Mkl^H}yW6IN1V{x;yHiM4M{E#cwN+VbN6TG)Q0eu>;n|}+MCjlpWsMBE(ZEMo#_rJp}V5rYn2;;UFw6FU;f4JrgRvsvv z;=Hd8*!|GFRqA^!J*1vpS=ilK@La58hS?3=fZ95-nvKAnHS{j2Px2*DqMq%|f^?_l zl661^@Ev>RG|DJ7-5mg?fL4#e1DA?HX}1~Pm%g3~B%8>>TdXbyyoY}pYmK;9d!{)& z#i7^&7Mxx#FOnEV#~+ydQwzMreqL@GaJl#7CClAvGI(&s;Xp#GuA&kMM3k#CyUQ}= zAV1OfJu66`b-|wv#Y3vwGh-+s!L~2i9IS-kw)IQT#$VMZ!>CWf6MsD4fj};DnECo&r(LY# z#Po~6Tx@1O@2PbBTa)?jvkqmTIoZ29_RnsBVI;Ij!XnN?0V=3gfQ!2V6dzv)1Q0v^ z8fT3uaFj$wF?9LU?ZT-B6p7XS6@PA0 zl=qxH5$&mOHst^OF#l1ZZ69!o^7&-TNldN8q&<+3fHl@jI`MvxGK5OA{+|C#I$qsm ze&(!$2h_5CyLhZCSurVek3A&JNgrI_fz`AVVFWZC{Zhx^nS3 zy1&RO6!IVhL+9z|taf?7TR`oPkHfVQnN44?4B3KOnH&mvgsTMj%h#q=lH7r4@{Ca?ti5sj3vB48yg=tEH?8a!aT{>os3c z@lMW_P)O`M-w)XK7pnkK53rAbCVW3%ZhxN33<{;@9Q}c@;rYBH@_dDlZ=fny(Lf^Lxz=xSjd0>L71F z6M}j={62#_|GpUbdf9H+%R+N(z^2fdnU4eKo;0=OV+Wch*=t3huSk57Y|)U)ZWjWC zIYjNSWP=&IuP1+N!XD5xH#K#E_8hQ4QqY(geJB)jD4sa+@aePqx~sj2bfJ-vIJV7{ z8&lic3UUx!To0HaJ>v#2;Pkvbz}3;E<YvZgjqk7b9jKw)-fwvIGbN>MoTAbz8)4%?cXED z3^1SFPo@8Jp<7t_*Z_avXIvQT7X#kMfZnoEJpXMqmAk8)tu@5JbGXAD!!0IXc#L2b zoFWxC2U+Dc9yAq@49meoF*PbnoG55zlvbsZ=3{hfv0&U;;zE?Bwh=8C~>@EX|hfs=xYj%+t_km9{jSI&L-GQ9IfTM@L4 zZ!nxUo1|!ox6lg!LfL&cB$K`h4@SwmRD|fSPnGSscOtM{ zPl{X4TG*Kqj0+MpHJ@^9D>Vyc$4iF-}5eCH>)%k(n(m z;*O*rET7{dr?}%s;DJ|&H(*@d0u_S>Mgm?Xu1=$mTQ$i>IV=fnlh{K!^dip7wboa2)Abu{Ilv$lMh9SYq= z)Sng+K7rj6xZw4WzaKWh|7whts`5*++G2%c!b5fIwXD%mFDP!Pl-agq>^GtwqHWx${nVXfJFfd30 zjau9i2#Cw_r+HJ6dq`k*8cYuvm;7QvIa<`6J_&R}h>Kn!nRZi;Ip6}FCZ1h4R8wEa zz~U}WhDfvuuC7EBaOO7Dnw4Q*@f2dAr$;oK>$$x+ooyJneBCx`O1uVVhQ#qL?RVHF zj|^7@TLq416*V~>%cQM>BCV{YU5WngBKvOQ+L|kri=e~ey}L=t0{TdF^+p<0tS>yC zxVg7X1NatQ6%{4*Jrfir44sc@QR?JtEQ!NiQE6)J#$6Xs5d_vLAC&dP%yLNP8?G;~ zd!747|8Kr9-6u*-qW*bt%m|g-3%U4e7ab|(W1pE#RmhmBkotd~mlVZEy-R?Ox0huKr<-r(^mO&oExs31dEWw%6Uv`+HT=%^mh zc7;AGPtml-5f3O}7JhUCJW&O;^1e_(sjzuoY7bF7_zCi50$>Ovf`WRa zu*FJ`mYUT-sYCgA6bi1Zo56~%?CdiTqD!--orz0B*=kg~j(d`{R+|W3Mi+_Zy%DGq zk_uUGnK7%tu?HH%eB^}2FhRR@nt(oNC0(phg1<@;;xvg#i_jnIdr~=jcoN#-W^e;E z#hIM=#%3_miX(|M7VVRjxmTEVF-w!RS!$-3`Utnp*IKO#I?gCUKDvAHHlbNhd#n+T z2RJ~u*5LEl5dbNN$Mu-_HSaAskQD@78K|!xJ$eK;Fv!-oE0YI4HIP>ffd3%`ajNq! zJJBRqDh(P|LS``nG*GMc6YV}!58vT2;i+&rwPf0VUZZtFEx`oenS~XiH7&9#IBL<;d&EP zRN_MB=ek7xmZ#O)X^!2Aud3uvSakAvR4Ir8I3!rtsb_P*9` zl|LWtUsqMuT;gD=!otFYy=J%SsS>a`=lybeH!h`@V26DDPHqPIi5PjAd*WfMT1HPu z%Y8Guu*$`jSD7wx1uc^J&c{+VY?@Jq3`hlqc@u)tb-LUSePiG)8l%E2DxX?6aw;3g zAsFA}n^`NGVGn;aL__b0h9-vjUkj}3|9q_T`ziDNx-{-s1|TOfWPo507$=CeX(G@c zbZDTtOjukS73?vjvEcm=^ob`I#~{G)-|IlXRE4%n%Zxv#Z8TJNkU#0~Ma|COB(>Im zruVi}zj>5D@1wmpwm%PyQ$YiJ709~mX=mrmdF~Y1OAOyXy!Y!9DWrl{RI?%jJJ*NN+Hg5nHt6t-y zpY15VE+mT3qXBVx91UK9%p@z=Gzawx`4E0hQ_GPH*5m+Kywu2h!_0Q55(&(i%0+Ga zPUme8ANNuB@F?>JOj}%;xbC&i^D!dD*Zf2O{_PmGOwKnx4!n5A|cC_{B^uW#&K z#~2^rokIn5Y8Z;dXXw-lgSexhmZ2|(79W6}gQT?m=wgc?N0n3vx)g2xECI8?YXiPr zK2{vxi*E&vXPQ+kgliW^h#;WqlV$3LAP}PI;1`P=eK46ADmTZD+%WvmFx^~zG*)~@ z<^`}9CKe@wS#jE|A(o-E04h=ZTHznIB+f8ItD;Q{_L~;|M%UCl%=3y!Ea~H>eP$(_ zGXRJ?i|O5_HPG4xg*I+cb*`iz#wDy339zDU?ubE&{fan4tDfr?Bu^pRDokH9-sC!4 zKb+EE4W=<}(mRV$ZTS(4Z4RV;=)F?tIVdlnE)+684-O`mI*eo^dU|`gA#Nz(aWf8h z2=vBhR9ocR4dGZ5(?)@NJ@p~v zRJ!&F#8d;p?rt6jBVR*)`hj_$!8Zx?mJEh*<-LeHk%zN`YxIWnk9VMyCe&WFI9bl?~Ne zl+QCB#2zENjO*GDxMLa*E~OdYG*~?#Ei`wed&kv>2?a=B$}GTqV3736Wv{mUKzbPX z%Yb1~Gs~I3HLyhcBaH$G}P=n?^l?W^Ac1bKj=7}%%>_MX!A0~)$%Nx#eJ z=M$J=M&rDdw>b;#VcMtn=ZJ%WQ%}uvOH@VW@IM8(`im}IdzN8<>ApF5$}BLueME0D zq=Y%b$o?ofM!(HuJpG;gj8Ct2Ar|X${5A9?vSw9|u}@(rQ8cqJ!n zc-k!4SfEHV8&GAjaAcfrMUbru`rP0X0T=+O&`yp^IV}%{NN-zd+vhI z?q)fl+>4mhAAvq#2O!DP7eft=1^_E}<|sQVi;IKwkx(RaF|Y8!J(kN)V}Jk6LjwV9 zgQMr;6?XkkZqM1B%Z)nOtsGZx>V`Ji$LTcZM3_{6rcrWB?`?XjMPU%CG^BvwB$p%_ zRbQe?)sZnWk4n&9FV`r}hjU$?aQijEanFmV%{W9dRfq5q_l<gt?y+%slso{pcEiiA^1@mU0Y4I8a zJdNRzl3xo@{vUUe9C-kIP&y&;1q4oqID;)2;18S+ZvIpx61aW;0@-!%g^xUdmKe>K zjvRvsTOp8_+ff#gs}BNRzXlP%@E1@7{a0UF957W88?b)@NoXJx1oRnd(i6KB4R(z0D%FX_%!$up%_xIM-VjMpq83v~reJ?ZmW^`_kw7be`LBg6w%|4AIip;wEvJd2Bs z^zl>CM%HV`Fy3<}_L)OHg0vHUR{st^e=`Nnet|YQgEDK- zje>!490dfAX)x@DuBM}-;|kE7X@EHl5dd3wm7@)Wq-#AZyCc9pn=$T)_TBQLDcf)c z(PNq3EvGLp!}JRc&gflCj%Rp<%gyP$kZcsW%z=*$NX$9P^#lC;xXC+5z5e7Xs~7l2Nu2}!R&!YP>>8lr57~Q2G&G* zqOcW_ZJ*Ndfk&pEV1nM-gA;2c8<|i|r!5TLgy{E=9kLQ_cDaY6tc0BCz$o(S1q00`9gXe25VT2}?@F_6*`&4A6Oeso+R%YyTd=PY<((w#Zh%dAC}A4|^r^6=j8-mO?R zs}(KEtS=W11KR*x5u4P(LX-GumEOz`kYUm}@^9uHVWZ&{ZQ!5&MAT!+bkK!tC;|58 zKw3ex&t^Z*)vFyJpD_~bhi{F6EyhrP3eX;KgrxA9EPC?;&W$&1YcdS297rs~jf8gM zr81F0udNW*R5vdx?A@=|*PjiLZMQ(1U3RhFEv`GO0hLu%5>cKuQ+?fUL=XPeqwLIn zX<*reE0MPRqt>$b08_9!~pKcZD&E9r^!%@S5_f*=if*M#<9r~ggFO4t!TtT0s`!AjhmYEL% z!2b2CSMRO>nA8fas-=ZChe7kz{}QEX{@Z@{-vg!p&_3-Q_5ZTH%9pln&NT}}Z4hMv z?bh1TQXTLjpdCP987CN90CB3+?6W|oI-CXDa!%~&Gp{o9_z7F7#3kX(G z#rAZGfQf4s2HRwW46(q5A+1;-$|IRa9`iH?m80@I#xhF+E-u}05#DnF*h_o(+D+8pz{P&oD*NcppqK>JM0>>gAL%<-`v6Y9!gSn{;Q%c%N>2|<=7+D^Y@*92c z1ctWCGAj%Q#$B(k_bL?I6q|480k=G0LRT4C6tvjo?c-CsaqIW|*%yLl&mVgsYB!Jp z1}599xjsbp-5+P~zI%q>llWgWwxA&W0G zLk&7Fo-{sg4!{y94R^%44Q2MDYYZWhegnHtHaPw;F)t%1_iG z|DaEInChEuh0p!XxVrU{+;ywnVIiwv-($b!mw)0Jkm@Ev6C5x#?gpK*i0iB_8=zn6 zm)H@9s=Qzh-|hsS7uN(|Qx^`ARj#(1%cqmweMY?~J>QV!)XbrnAc#ul6II=looeQ{ z+HMG@+~>*VYzlcri7K!mL%<5SRv&?CCzB#SA$-1GHdg-n7+~>x_s=z7C}>cDmo|G9 zkfVcXGWH6%b(KQAm!m0r#0X%42s3|!uC6jBwdy%%d|bJd@kj+?#=DyEc!RDBwB-sW zNZ&915k;!xdm)Y!Y0A~i2qekksQV$rg5zATk}&l(f8PYai_o9iNh)A`XAQv&4NfV1dP@P8o7 zKL&>zGVBK%qgMb6)CdB!A#fYIw;^44+og&5k2t<{i)Nnf-Dd0PS$F zmKl&7@_zD4AKKC-F#pK}5Wx>j>`=Qq+tdsKA~5g($kCe!9ZEu48a*y22YVmjj8*?8 zb3#eU%%|frJ;1cug<7N^Yxu4}{toK<+hLZQqI-=uq0yHBQ8iT~2&Fy%ZTqB=C`DQ- zBvQ5$jsH|d3950DfIK5wS6>e%U&_E)yC;Y(+!NK{T7}F<95{YnJ))oUSqt0+sLwKi z9IH7M#PUasE&AU*aeO+!8WNCI=wE}e-2UQ)_1cG%`RnOI>0<++A1u74AKDU5O{m%Y zwDG65@tvNWgzVly27=ZdgM?!!?D^)=$hXs8t+1|ytFaQE_hPw%y*}QV7eFyf{R&KR zt=Ela4p$sdYsRs_Q0C=u!)__dsJJ;)4aCuJcVpu2Q2YH=9eDuOiK~Nia3#7eW(vOi zfs;+bU6!3q5*Nz9suZxZJ2&X18@3+1yGuUulIt`Z9G-MhF(Wns)|0Jxkuwu7^`*St zoSRVL++Xyqm{PZG7dq%HI_K!3C9pYXzWHo$g{zd3LclwuXR^2{7KX&EC#J2Xt{`(R!NWrE z+s7)3mTkqKJOheI4wRW{IW|j9a4i%TRd@|)?YMrlQsJ|8EB;7NX;`%J1wCWTGokJH z*t#=)iO0fEhc}wrB%3rH4Km{sGo!H3RknQ-GGmJ1?T$g{BXeK^nRXM!9j=k=KNWcA=k#o)4n{lWwFFG7Ps#muiMkz%^y&gR8$l^W3oJM z^)2R@%IpvPeACXUKtk{6(E6_P+Kfzka+|X_|EWa=AcQ~R*=q(!x-6P>+ zZcA_qC4GJ?p7lB}!PHR8Ulf~zt#E#Jc(kiKJfr|?RV5XcUm?!hW3+TYLacbR za>%X;epE$F-H>Qs2UaAUTf_;Qcad~5zFSXRsp6N*%%R@_2FxFNzJ}4gI$feT-X&wA>+OgSZ%TgD+RVL*8V*1;ep~gd8vob~ z+#B>a=JE zZB*gsZ#8%<53R82D-VWG^kubw3Fe&=m>L~2q~5MO591ohYKV6?2pL^rXpU%sEviwH9tDyYg3F-ljb5aq^hN(%b<%F4A#k%U zDiU#8yaluv&Zw2sg$YQU=?D`&UYtj6n}kB zlT2JDJS5DL9G7Hc_P*Kg#3R6;{3Y-B9rX+%{pzLc+|`K&=j&?evb@8`3c|Vmx>v{l z={$UF(dXxP^dpV#Y7TLmqL;8K!MaM8p;i^giIu!gDljPCD)<^QWg`j~&FVOTD;z^> zKE$MWKX-ia{@-iMVOhM`3a7VUa}TwyZ?N*e;u6&PU%HxoYIT)} zY#LZjBeRv!-CoJ!D>Ly$ho;IMQ$lc4+B2+^A zoa^^VybO=wzs>4%!?zaal2pAjDA?$buX((tBPCL~I^%WB)NA3AfJ>gD);?*Od7mRv z%N>nVtmu7*|12Xp=-OM0yPiPZWf7Sl{=fjI*R;65xUIgRt&-;)HLJ+hXEb&k%Q7>~ z8-|<>xiXe*h?i3ShHzVQ&g0tqQ1(yGcacKy5~c$6f0au&{OBN^v}FqkztcaY)wjL1 z+=pp05SZ$*liJ;~lgE{ds#>itbMSCa`NXTuc^kEFin&h)wI6i0%u>1(w`@r*W6J4} zZEKh9Gf=LdX@on(=I-8mz7u1h^`v?{iM44{a$5Yt@bjg!&+lv+o*eAJ-!v$4KoghQ zh(zxQwmw|Q=L6K~dN%7%wqEwiuUT~2l6tP0f=V>?OA!efQxTl*rln^8JO;uIik( z`~fbhY=3_`DWP_QoA;p?{IGMv&AKG6rU^B*1U{Se)Uf|uXZaUhrGa8w$BDcZ>!p;I z`wjUuPLDarge$FHcNmT;i9PQ9(KY&SS_T$WNh zP22}B1cxgO|E6{Vgy7?q892-EwTK+ehe`=uIjw%>TfHGCP1Q8MjOSGC6=z@on3KAkT+nz48=JE)ZRxp|@{!lKe8t)-@4~+az7QES7>b`dKE^|aix1F+mO2dJ?9&qH?L%)8^xJQ?TRsev@rKSs+mngM?B?Jy-yPo&4hfn* z<`WODOVP|_?=xKQot_MV-R*uVvbP6py{hY>hfyZ1{Zfm@R*2b@3sIiJ1)lO}(Qtu?ZXE zl#bioo4u-R;5Zu|J}SlgzLB}O?ds+Qqxg)f=iAqU&J|wj4{F+YaJ%yWel{rxBe11fLsfFNr;@pT6t)(j9$AhBdzkMmL`|7%_a{S2qjH=GIMGRhtu#oO5lUwzQGU;Tj zaPr3PI^p>|YNMfa0CQo{>)J=RISjrt$Ym7O_IP)7qdJHx#X7h1#{0;Ve%QlmM`^*T zu8GY{rKGQ}D)UZKXN%>ADT+GIN79YwH!17e9G~1Ce=V0P%v+F1^5(iz@m%HO=G_ee zkvFJH*Cb$H`hLCyc|7auyG)Jhh`{?3-{6xJYc0VN@7w*~Z4d31=(~GRC}ra_A~w7C zDSq4AIWL`UTtImFcsvIkOMkwkonT8n_ImB6__uZMuQ;+Sk?Gul!2Qs^*<1X6U=CM}B`}3rpJ1*}q z1k340TLx@=dAe}-NAWYJ5r<AX=T)!53%7opdf;Q0XOTFd#?H|=q>vhf--q=3rx2l$=+PPVt z`&{uj(tFs`5iTu$Z!)OQ{UTkh?8jdG1C91rJ9ZEPmw~duM@lNZ|1+S2c#| zOO%M`BNqnH-BON`PD7MOMreDPBV_oBQ7VXc#gjH?2Q{yGnZ{FMpy3|U$RSfsL}|!J({1){)yJ`?)VyJ zZLqQxle_woZ2$E78x#3~^p_)RyMreT?)Q6htUnlyPsYnMZL`>Jeq&mArsEq`#hgjO4!i5)XH+|RNODJH~=gf2p(!m@+4 zM>Vyz$F~9d{rpzT$S%$E`pUao!8@B5cH=AC*T02m;cdI_R`@&N=c)IsQAd5e3&vVN zh?!g|ahU#E<|NVmjh7vF7e67jlvl)iZ6XUDRJ~JT8*;MM>Zo_RUcbo0wgGA6aoSLS zHht&u0X_z|hn`8Cu{V>MZud7w#@%S#{dAkU6=UK^Ephqkl=y7TWP0>a;z{E))s|f~!)yJm?)ZS-jhNMg#J})rBn5y(q$Y)|I2+2M* z`S(=MJjOcOCtlF0gn}COe2=T0dgUr}uO2hUJG-T-2LGdR;O!)^W!x^e@O6GSdIGx+6uziDFJmNLL43v^n<4&xniOmfYYw-`qhs*_9+b z(KeOPT{GAAM8a+Ujb2jBvNRRB@U(GJ7 zg&y&ZYMK4!_LL{@f?RgVnky774f#r$WVYM!{~u@X9ZvNh{|_sn6j~xeWo46vvtZtE)fCIp=-e z@a1)&QXNYkfUFT7^L+oDac`U%iX`EW+z8{-vnx>H!0qzbhpv+5~0 zturK&L@~Hr+vQ91_d?yQRxa}KRw)$Ks?TfN8pQa>jjR~n!ONJ+BKKc<+Nqbr?LS@W zTynimA&a<&b}w^fn&0i(^LY%ejmC1v&r#vmJ|{>7_lVL5EVX?&!wuq<+qcyV$ZDg}(;&iodx>-2V=5b)F9Q~nckD+hcL$zS@I}qPigx4g zGZyf>hx&2ih1>`5(VeHyht{QhyI)>GMgXHgG8}(ZZkVb`R&8_RkNDp;@KC0q*xLQ3 zH%qQ@53MkHd@~{Isn70c5Pn-`Q^_o}>-Nyjs@wzem4RhlH@o{&iS50<3a4!*{5DV( zjs;uHC-(!rve7|70b^;Jwn^14g8FW=$tyI)R@+UlkK?-|FLgSO&e4Qz*NcaJT4~a< zu}`|(G?{URYEhqx=IHIw6`uMF)OWnjs1EFUfE(Fb=V!yUYd?iorsa{*ffXmkdv>q8 z6+I(FYME1QITSOT3|BlFOTIiDBZcXhzOg7Ua=;E)1|Ni zxfdJDZ^75E#8{2`j?;PNe!4g)lx{mDg7l48ql?hUvS<0QeV>gKZeKjGwP~# zofC1ln}Rs^0>qv=kLgL0p=KneB7T7e9z}abw-= zbJG{*rq`wwUMoSf2+!LW*Lo|3jle-+ z5ffO)ZXdc?n`YLFA#$Ljlbd77h2NjpN$a=N3ishxR?aaKzTZl<+pFOruLR3ozF#jh zfB>5B2Bw~Ok(^xq_k5gceOs@J;6Qg2905tu-H_YX%Wg^9s?NMm5uxHSk&+R1Z5NT% zhLK)PH1eqjp&Lc(_g`~NdR1yphof&7eoORLSA%B8x}nE0r5zu!JBX(rD#oJEjzr4t zPixT{Y*W-r%|`1p4}Hu-2YJB)&FEJcIXy2ix|zDgw|w2i3RRqJU$)K5 zY}pt6Yo&AoeEFUHNhgoI>GrzORH5|HG0WB_ecQ6k)utg%Vf(J!r0J}ybioGq)_%;^ zl4Q}SEMtxPq-|1R?Y%~IH=8tfrj){6X2BRv?9b0ETlriw4r&s9)w|NT6_O2)rNXW+ ztvI;9`W;;A(#y@-$2FR)%uU){iwvv#-60K=bQVUUOusT?iVa!RqrSI_%y-snwzv=~ z7#b?Wr)jq66?gF~u69uz1sk39lA?1rWlS3G?kCdd&X0TS?>b1U_aaQE1zT)O;VLVl zFl)Cy=tsX05yaZIbJk3z=*J|LDJB`);+MM<_?u6Rb zwkk%VP;Q{B=V{CC$8JvzmO*Qu9hSY;9A#)`G;ZRlM&!~`;@D13`%?nP z317!?+4s)7dtG7v2v)qo=0^%qghOrnzWu^k=0RiGw*zzA@6NNpe6HeawVB{jtEnR# zy9kysY0w({TwELglznG&JQ4E%I)!1wQ&Z%`AszgNRp}JWp7P)CKuDa*KN*v_VBggr zJIbmy6sqE&oLjcLQ?IhuB3d;_V~^S>+8+PZdSRc^=hZnyS%2iwstWMlQ+FUAI=lDY zaWfMrp4^nmDhk|bRY{fN zE!zgbzt^@t!@xbbmyG3NRv<^THaFFEF;Y*{ib?2;4;3+0&)u+_Dj3u&1uWdnSi7Uz zy}o4SnIt<5iqS#O@8dM*g;G>I1{mf1?;5}ISZF2W-h#{qk~e3M?Wu7WipLkIr<7LR ztB#!&^T*P*yJ_c*7|Py;rzFJy#D$JD9+=dBAEL=K3~rh_J7C+ZbAQ)(;T*wB@}a-? zWI^J_XQVkH$=GI|MxjyQ9OIYu~T5-sm0oYd17;_|b zgR|zgFNa=nD-A}Nuo>qLEt!{^gy!9u{l=zDq%8_eUX|5{oKhdFIfR|U?ncLyvgs+~ z)u(4CkP6LSlIsEi8SyMHx8qxE@ZY^~i|=;JMG2vj>x-E1!Q)c!A_OX?~(^1~Sj#?VyFlcD}y7w!gPf64I~t_rEevW54_ghq)% zqeK*G#?x{VGC6-vd`$p~wf-oeXtkwp{!)!35&+pTZhX2=jYRGpnc|(uiORC8R@Lo* zCKhG4`0o#pne+8~*T&R5em<6+!F41EKta{TP_hV=OFUvQm5fT?aFG&Kn-!bgP=no4 zEIN6g)dX!3P+jX*KFC=egSKP_$b4K)Oe&pKd{K4yteE`qzgK0x&RuE}orG79MMCBy zWugX(#*yMbW7y_{jJT15y{dDYv-op1^Uk(&_zN(G;1hYs9!cF29Sh|?5t$l!?%@l^ zZNxfI=aE6QTdT71MU2m;m*AxW4@^T=%v#Am(OC~|^=CA(7GMm5_Jx|J2t(@}sKho9J5LOPswP<;Sj zctW|aMNK@iuGc9e$j`@i9_0fiTmsKbk_So-ZKK%5UA+;N&EfH*S~t*^Njp3|90GI} zbpWF|ST?3|eqge42sT!5V<&Deb z!KS>*u$j`SG;JdP6KjW-)Di{mcIHSWBID*MJ!^BY(jjJz|b(6=y?OP zH;7{a$H(7r%>F(m68Z+>TY!{O@L$%OKYPWE`NMKT$M4n_7q5ME$OF~k4;}Deo z4?W^QYlQrPh3A$xZx2rkRgwsj91-y*R2N3vDM)!T%Y+}8AI@OLC zk{_-S22a?$OP;*C$)(kTN%)^5i{Q&}h>5IqPqqTc(x_P5zF{$ceZYg|N&Kxiy-M3YaWvd#lfHa^ArjTF zPd2GdVubWM29XlCUh6tmBmm{B3>Yp=zp1X9XmGTW`tZE4#-JZ*v@R0{hv!0OVnu9j z1xe3!wU7ATGMh7R-%8<}Nsv&rY{%are&8s|oZ(}3u7?U)gn7GAjKz<_zFFP?u{7#h%@x9nY?9x<3S+et8mwN&&n4@cH9ySCU{$;ghX zD!>xDW>?#v;3&<>TLlqvzASdlh*f`jjlT=ugH*A~vBqEGdPNuX9zA;eMk$WI;PIP8 ze7Bs{Vw3>){O-bekNKbxLI+p(^N7_WV71W*TIBlHOsWZ(cs2K>6Bd;!{AyaJ4bXg* zE%Sy8<~#~f2aL#m!u#PqMYH!M3iBO%dL>?MBgJbFuE}k$`g(=Yf~#b5tVm8)ONij; z;L`g7i%e&4k|ys~=EVIe&tF>)){t zIhPl5*xEsVCCy@D9CKcQj| zvn~!W>JLj^d~b(-ZBd>09rFIuGQker{@%p8Zg#@LD)k0fz@F9-$y_jt?tzyC{|xzc zDl@3 zpVKYu`_YQmxe$5>Gyg`^|DC?Qu!s%D4OYJ+hmqWjIJ=(qU{4_}wplvYCPZ!C2J}xY zvm{xMZ3E_Qs#WK#RMV7Z9H0Frx=SD55o8oajXjp%8%BO?mJ_`-cztrtmrJ1Wo|sXT z=)1psQ$Fv7wrPz|A;|WvrAEltyoX-h6>aYD`>C!=Aqj8OKbOmCe@gQg{iRvpy*1AbZh z=LYvuIDEf#m@a+2?xJJe9pyfGGHtKpd-O?P0k}%rV6;1-BYnN2>`q&Tf2aizW~4_t zb`8k-9gS>9f^U&S88Ax8;Ykq62-f-iCQ6j!56wsc{LLVoroeWFy@H%+&MD@agr}c zI-UWN2BL?++1Xj;?p;A$V;vB7*VotgwOJQYsT=aDw;D<)iXi;*2;Uu3VK02h?x-Hg zD=xWu@|5d8nAt5m0S*M*v8~e@>gth1R+ZuRyHCYgge8SBNevqMnSK6zmpPI{dQU>l z3#UhXBQI${YV$bVxrqX{1LEX`|8G$-Nc|s0#d*4gi=V%1Y_Vlc?z^0gisq0d$y3*6 zkFI9`rU9iCTH~fnl%g#!i-{kUrK)T<4-cZ)Hl80!{N|#jpN_enBVqkO+nI0kM{o~Vb%wn@vBiIYf>+XEYsgQ&@?Rv}~Ee6R8=9>_cUxE}6v`?F*#fV#)623U?{YRn0G@RtN8#BMhb*Z2M_#E=4T#uD7x_WZ4Ce|@IMFC5R&=e?jDg~FVq_&q=w zs4MeW_NFMjXA>+%e6|+f``-i|_*a4G&i6lb7#7-RL}0J11nrR$@h%(U`R^wTWlvER zOur!cy8=AC{1ykK-}AY0`}stb0}#J>NvE1TfdMNgZmY7f2pDi$2S&5U-jVZp6vovK zG~QU$G%L+#a@6Z(X>O%M?%)Ehxh~I#=?Zp3aU3#i8$9n20NgYC}ORd=L(cpp(EJG z^VetSDPq|~3wnyx*o*LyuiTfoRM4R<*V%3T!2jL0OXAaYPN?8DEb7$DSfdu$2te@0 z+nZ%JD%4S4D$ss=mR|gWh=Y?K-d-S9j93NmYwhQP7NFDDw@0nKUE{3GrfYyeXyZk3; zSxZYRy05<<0`3#w^B|^HuQU=GKxde##O!q&)YV zZRF74MbSLW$@^VY$21psv^}K_o}YP|e8q2&jy*5&8;w7CE6rkViJu?qK(HZyo_!iRaq7G`FRmINzzZ_(1th0DdsW|0rCc zq~Sj$PsJy=LKj<_rqOJ>b2VVlN~I=_)BWeFvoh8J#WsQ+fUGD1J%Z+Vmpy)KRJ#vj zOo2$^q5R{mGWjJ1q{STSsf8dq6evBkfgOq1LC$ui6ae`#SkLooYYo79@R3gcjwO1w z59Kn)Ua%~$_M>tTt$WSYtd?@m_5HHmSw{yU`F`z(KN!ts8vKt|D^xTvE>dopS*Mz& zj`mq;(N2L-$%D;_;4L6)%?Ei!bKPmWAb@DXYmSMSqpbxbkqz7REAzma#2{)D1ENWv z3Y1Z5>G2ylJFROdYr@QGV;(PTeDS^;w1{u*fpYG4RBh&;sM~~t{R*jTFu3*l8z+*> z8!nrTxAkY-H^u20+twa6SQ(>xn~DglB+oB#>p7A zVbYBV+_5=B(K=slK?+-&M|GH}J=dp-R!OG5&RUO3;@}$k75Yh~Ul&B{p0t0yF=sn~ zRo1srAB@Q9xs(II1$^rwYvXx%_g*o1MuR3?M$d)I?mmj?V_XiM-9@8@*hG4yWGA&l z_meHw_E0!ZN|v5sRswS3&|IgWa7H^e3(VvONx5?UYtyZzR8maCt+a;uN!NPW5#q}VLnFJ>cMkC4WZ}tN@*YDMkm|G#Y_l@R6 z-cj9el4KB0>-}!ag>ueny@Nu85HCQ%0i14w-`fEpvysvP+%KL$lT_O@>uC+s(lPBq zDvm!jI~RrY%@DsMhyf0i^aXYTj!=%&QtOluO$+Z2`oOx!leo4~ox2w1K`Y2q7 z|ISP zFe5qN0v?f;dNXr0nf>_Lyx&%H@R_y?BNPzb2o2n@D%@8KxBpSqg)x_8HC}bxMwT(t zT3A4fU)g}4oU|w{MNS{SG_XSTXB$T1#0T9-$0Tmvu>O8y|M!=#b1rEUB>O;X-9Jbg z<%ef8&sIh}o$I9f-5LNDdSZ3UO~_;>ftHbRGk@h*T-qf;wIi1aW_#zr;a{!0p}(_2 zV-$7ap0@j_*Zykfs^q~7pSgk2GQ{{PTV#C>+Bg6Tj66c(M+EPp#qw;xigMYGl0<>L zfV%AY(X8Hf(uu{Gg+gtuehc`)%b)aaEsg%pt09^i@!O3^;5;yl0y0}O2TGGC2>S=H z4lX}TTmLuL)CMp8NdLK*LDjzDlekxMPqDq%o6Pq7+PfEm)y1sTrk-opr5`-wCi2AC zIHL7{Xs{vXd=Y&IK>g?4sit(^(mBC}r!yNJ@^=fE1^VLl#~lLEMMK#(a~$T9NuA+N z(bW||)&XrStHApNX4t=u$3AecYl>j7Xs^_hB z%lBKGn|ZahQdPGH3+ZU?cF>vIX4h*NghdX3HBz|Iu(}!FI=Vp>Ikv5(yEMjT;9Tv} z)J_!%I4avOud$H}F~a(P`c5%TkRIHUWiuw9lfm&I6o3|;^3rQgfHcl-KQ z2sl8ZTc=M90u{XtXjwk8zXL*Cw4F@i)fwJQ5Yep|!u zTk&YnTCfA*o>m$zreUYkv3#MMugioBCdw|?a}x){foyhSakMk0yTNz zN^X~ID;+)PunwThKnbNKeQTOyv`a5e4a*NMmYFm@aJPN#eynw&8^D~GjYPvcHD`oS zylpyx44aSYe@I?r7=@K6sK%S`vW#wHg~($_KcWR8GYwB{G}1v_n}Jf-r^l!i>7|o4 zoaVOm1bS`p3fuWxwQ{T6OGETuoXQIXIh_ohil7<~upN40#I6ZbSCwROk&!M_z;gI& z?E#=8_(-ovryh@6(64kzsjwS<2jnpOQxtL!pirVUWpm86J0BRdh=HVTzgA5lx)jX5 zG^>_i(2~V*L-L>$v^qG9rEACsB}W*Dt+8o~V)tGwZrvo> zm2Hi86NxFwpgAGNm=d!C+*+ADeu7A`Zz)8Kp3sY(`u<)O-#XI?PoXGd6*|9p$qG8< zyd7m9n4S)5|5`@aK%tLzI{8d&O$_tb#ZBcDq2_IcdIQlH>;Bn8G0X6tM2^X=$GnMr zOK#S|S9}I#Stsbe#0Eu9?Owl~`AYXDo*U?@K|AJ5`Rpgoxb=Kztg5X$$FCB<(pjtg zj_fFt2gBKhggxX%iGm+ps&R>9XSz?mn4mob-~{);PI-SdSWer=rFi-8XdSi6A@NR`ed{WiHF-P|C->abnN7`8yD??oF(F zT=V*=R>ymAq_GRphWg3I90qtXd*k=-x|8$HnDa$Tsp|_rp;N*(Qct>(WLTf|9W7)_ zBG8YNUmvQxR}sF%{Uy@hSE!8F2#L1kKX$M(hZA!Kek+C3fml$F_X5%V^NKUIhZyE` zC-efbjReq%V?8#G7;6aT3rVnO?!Eyd87{4|81s0BIUs+d@(P6 z2nqtDYb3LFqs8UQivHd;9k7 z`)*x!c7xqa^LJai6k|UQH=zGl^*0F#`Qi2wWOW>JQzRtx$3oEy*u;|uZH<%Jp%z~H z8px1Cc~>Yg(fRyXyIb;bU}5bG50WiZ+6W1{f9WViJs(l87+pP&+_%9;9K8Ow?~Ap7 zg>u|e(f0jxK3whXJLhxfXyvR!S~%~ahEBX<5dWAXbT0Rr>_?7;yfto~0JQXH(I1)) zcn;dZqz=chgDSeack1?E&*QTYCfZ!C=^9bl9M#a3u--jAp+q?zo$ag^%tV=n_o`VY zR?jCnC$jWsME-P)Z?1&!Am9S84}Xg5ICg7+Cnmv|KeyWVZO0eA?0t#;ug&s*LnjsU zqL=S`u`T%{)nXB1UiF#_)kfvX8$ZA97y$|r5?s6aiLtZhS}4D}7ce}8=mwgVk7Wm% z$5JL-`495Us;y)h$lDdd`CM2Vx@_IrNS7YQuP#gC_+o>@- zzCjDW)m;$*!tZtytPKBT;U~>_%s`&#jyVwyV9F>e8lt^4vs$5+BlIp07(lY@hC!z= z>!@kr^qbza7-A^3P)n%%xU?|$hVUL6$5q)kMpAk8?~Z?9r;FK(eVF4jmxOcK#9q3B z62>g{hU|SMhDV!8;sIGLU5yaS?a@R*?=tT?=-_bxJ;PKDv3g}k^I6i$S(GiOGUlp5c5v~!gYZqPqqWK>w@y`~cWREa^G$K} ziKoSzq;F5Q63`>;ps=w45_6WO!a+;fHkg`4d^@($zK&97Xg@7C->FDoh?}_2BuM9Q ziDd^%J}>YnXrUkDbIC`PLoElK2FRq)RpOL@(gbrwJ zk-ty`eg3tU^6zPm*S*lJU!QoEg=e?0IMI9|j&n;!P3>-s-`4GH_Hq6KSP-6V=}OV) z8mvA5w>^6WS1$7Q^Vd)L3bKZ|&bqa9%`mAE-{@56&-0zQaevScV+DZzZ^{P}HDhEwgAroO8QO1FW2?8*)cYbFLbI{^Uk z;lBMy0!<7wiOHSo#<+X}z&)v4PAThc(^u?3?8VXBrs(#pRX^S@H20uS3=*0bu++2V z4hMy{irU83Ha0HKg1{8`zb*yd^N8u>$vag9if55em}_Z~hyb*EcAW;=kO zI_bajY~*X_ktvQt8s3mK1=n;EiCLjeMzOm-g6Ar+FnekRtbHo>KU&bq?H1D(=lJjv zEnG~zHkr7d#CKTpvz$Ie4XOWKYQu9fN)wQ_;^hY)f8O3lsnp5}Nou>{AC{-eJc_7l zXZbpovvgAD3$#YEs!-i!eqP1Z;P$lW-dOJp#2{DKlHb#9`4KXM{DX-epmo>q2W=hw z+1ljMO?9RHBmKRvKpuu%x>C+Vlq>FK>aQ6e@9ijLySGw~QKvH1g2B>QRx>Bvm-<0w z9cBc8tL(Y0v3~lAl>kY=IcDCj-8yg1V=1+kB`v8L$8JI?{f!lNjrp1znyt57@r^M`r z(gB(PBI;lLnV;=sUxmFH%9SoTT%&-@^a_XjTJA>9s~<2@gE#;??UE*wF7V70b`EMb7kntzcL}u_mJ_~VRX6kQtRl3}zM@ysy+%pt zellmeH{XEnwGEXV_y@H^16LJ!VMwt`-@~F`!U6KwcpvNvg4KI39{&N8mVHe*s!*pn zu{QWbngNRLgI!z!mk}Ay(S$aZm?`uCvwMhP#=m@`$ZaUfg zcWDX=q<}WYqaNn&gX-%o8d_#9hb}@ks*OZbmNZ76J9H*W8c{@)C)M88UK%?uR#_zU zGVHi!NjZTM#F)4qTG9}K$|ao5I3u0%-AHcyjM4rs*W@02PMxWDy3tpuds#MvL)k3K zPDKb?(Je^MnhUH$X*&s4HZT2Q+uouUn^w@pWz=(3BO+B;c%j^5>kH5Pq8H)Psr@`6 zVoF-NpHLn6b>h1D#D`OcQ;LU9D#WYZWpxy9fg2vQck2||q+Mxnv}$WDyKP;AFTvgv z2H3Mwc~vOw{;}oXJ2ud6pVBxWn%+1%^y8sw#>O8!l)R>Tmo4f&(-l1Op;NnK{UEd$1O?l3movkQ(;a9l5+x<9F0*gWu(KNT{Ii|@ zP%b2+pe~o3w>G<;u7Ug96uOC>pLN+e>Ve-;Q3&Lpl+FvJQvpV+-&7U!c}%6s^Vt~P z9p+~LzKJ~17wPCHyX}bFxV!9kGE5!<83(B1fXGa$5O{cIE2^L-CD0!6pfhT@wIA;m5j8~$otxpt@)v@d{(p(unx8I$wzoHX<_OUoi(&*Wkkr6~k@Qz_S zpG}WTuv`a<*gsHxRE#g{OFL5QRlOo@f8rSJf^8YpU50k;*BwSQqBDC~WpW2jxxj8& zymIN75nGP`Y+dIUy_xRMfd6hnB!;(;I0oBT_)0#^O>x}CaCcB`ED$9_Ax`(e=eTgm zbj!J?mT}lMpcJi5)A=Rn-y_`{Y~F5r5E))!r3Jymw$qSRr&E9P35a-8OQUCD4tmMt zsI{D#)~8O#zA2n$GgTbWR5JJ$d(mjpc=ftvQ6tnTwIgtwifj}xf>F+OeJIl-Qbc;; zs?9j|f*glVzLw&`s*)uw9b;zJGoeIl#@x_&Y46I}XV<+a2X_frVb{tGEpTom_ioN# z9}Y2u3zM5IxdydrN4Z>h5fsv8&(C#3u1L9YdZc_9oTt$CGR0xPuA7?@j(&PYAJ;y4 zU%Z~6Wv4=Oeljk7KqvkJ1?lpz=`QO|rD*TzOI?YX*+o>|LcboBJqEV-@wjwtLgdA^ z6Hx_MH|37Hyv>B&IE1+oR|;}+1bVs$5j{M%@tEf;ys;aOx>l_t!DW(uzH&z(r^rI@ zBucp^JETdfpp=rdozsncwoq*|IC&2dDVQ#X4Qd7N9c|=`LigJ)FVkGE=%77Cf@5;j zg+2-*7c0J5GRR0e{bI_EMQ=M#p31v;BAXBKSZ3Jqfj7yt7?ntEsoryHW1WK9f%tHS z;|^OqVB14fhDj+l_o5|~NIrTRbx#>D*Y1`O#kTU&XoHS2Q1E>?eg znOrU|D}}nYYC?dPa{RQV=>j;9Q!pMKzuc2Zds2jOF75R~%6Ovt?l?xKT3v}d0L&!s z*`#Pu+M3Z^Zc*feT6{?sd7LJ>ElO^H3n~fMw@iLIV122>E|D^x9rVk$L{*pP8QG|Q zdW`i9kSs*sirn5td323#J|Kmc$Pvwx z&S830ip}#9DepAee775!8BJKQQ!rk6|D0@n})Lh@SZ;)WNsOMTX@Twe@HljzE}@nW}7Q!Ub|1OLxh2GUM)S+x@+?E zWCi)Ceas+5ZpKS~OVe9c-lR>krk?LTcLSOke;*x*!;?d&Qg9f0;Z5@+RPjEcC^Thm zv-nN`x2;`rb;2tm&nALCv(#*)kPck0ey;a6=BTNNmAmtJfHSS&>HlT-Sk(5YxP4(B zmHf4Pep)X;?8y`Ot^Omwgs=HUHqfxgyNFj5m^AlLXa{Ee-Czs46$t|;<)uSEHi;AM z|DXNwfj7Uhme0=GRw}pDQp&Ba+K5GrSwm|<*;Slm|FDn=-Xc=X_V#l#?rB;|t?VgM z==@U4?uE^H;Sv2cp8X1pp1qf4eMp6`ZY>Qy9_*UEj+2}@G-=akbfv8fG>1ZKrXwD! zCDZ3}X|?c}58e|kt1mb=z6;yltKE#HXv}Y`^wQqiWm)iaed_hH14IGjWZpu}x~e_bx!H9Vc5dq1j-M`&CB(FtZl+8(L9yAVH1Utd88}8=_!s4 zIaNc&xF2EWiDF{VfQBy_Leq)`MVaH$&(r~AyJK$J5l-V)6T>)`O4QM!jajf7IoB+1fz{G}s_Iw;`|3`M{W*lp z-){RYkPg@_UQ^Zyj|kNwj`iy%B%f{V8r&jy-IN>Mdd9uoi16u)v)XcVJ>TTEo`4^*T^`dE`tmnf>9kJ%R97 zjEob-6GaT{UmPOJ6}5~rzJ@P-wCj{P$xwDyqcPFL=%PN61a|7dydtd@t>a9_!%YQcJuD=Uw z)6=gV8~dZx2ix*&B4(Zu^jXnfh+G)Dj!uX$psy8UEO~_n0@^vdIMBa2E7v{VULxR= zt@jrVN-0&LD%~ejLaW1dnWvcB4xg|vlRz#K-V2j+8pX~S>?Wpb*cJQPc<*jNBm6E8 zT>zwBhS?R}fa|ywn>9@o${v^9N-egk(1f<$(tE93Vd8EJ5ZipN(_H8+2 zI1%gDuCRcxOGzv%7pug_`26|F+2rJFdKHQQM$c6&INkz{6-(qzua*U!v%D7!vZh0c@^MkQ zqS-G(sgPe{80x`s3|%?l;9zpreDbL4XNQ4;h9|b43m@LDGB{Xj1P3by5T>rFhyXv{}z1IS9 zBfPrN)qR&S4b?FV-Lu5T>}0A^TSz_X-{Nuh?03|--%S)s$?e;%+?Gg|f}tV$=Fbml z&zbeU6>_dwX}H(iqA%jVH)JAK@J662rzi6}K*#LubZ`1UE9^W72a#QGAjW{`+1^dq zY~*c=Tj{trz7kL-4KU-8>P}tVk<&Jl0-$~7DN|tL=2|CHBNn&LmJjZ@!D0#3c0KwL z4g>$^HgUcq;&*PURHT*#Ge4cYszC0bQn5A1ZX0Y`x3o^-Pw^SiN!{c>6K~zw2HJzC zXJ@LOx78?-xmY{PSuF$O7>u=PVUvsc3FqlE(>6}&)xB9J4mbioV-Nl+ zev3y&B%52+`*t?y&Yv?Qima5Ba=}dV>{sWeMa@Gx0fbd?@%-LCA``!v=#=I6ZT3a&%k!YTs3Bp=hxg&c=AOc4q zCQtr7pa8zsDS&_hq=`42>+C9?OcvEH=u!})q^(?XX73G&>uTodjD78 z0z~fqZ+Rnvw+<8+QdLA&7jPOt<4cr~81lzgpL}xUU+Dw@JMlMjbCrlPF`$tKR07e> zvbpI76l}jkU0bJxJji-z4iAI%;bNXfNy}$2N<$}8F}x210fIOs@J9s9^akewTIKX{ zj7nba@ArKGzHi)wQL|_LI*ty|^u4j%jLYMghSVn=&8(M1ua1xZLCpLbE=H zd*~0xQK+H3b6|Mg=Q-KjN!g7su|13~4%HIw{p~s3CUHnNhyw)Q&c@L(e(&Z(l6cFt z#HPZ7b65545Rqbz0V3@S1rTQC%&)QjAUCVW(SL(+=Esj~?Cj0j3jppu(g`cn%&1lY z*o>W)a=}V}Uma>Bc?Jn2!65Vp7MMenK(*_b=zMev@)NK z%U~Hm6Y+o+CG9^}6=U#2(|f|k7;5B_r)6F}C^B50JD^)>8F>CB@~HSf^FR!2)2eyg zIao0}#|8stUWgH{gDC&(tgLH5l}@DIg}*2fK*&&t?nla6LMfDI2}F6Icu}>GXe+c< zU?C)vxaY%wG#?iUcJm*Pd0NNFt4{puj0&e)ZNI1C^B=joicSYoh=AyxSRu#M+3qxC zq~D?$gN(P^Dw#Ce{KXfr2mrma$NVMxHi78yc$04&|FQK*dTF-m7HKw_MOyDR?k9;! zZzmg#RHy;@T(5xt+DT$u^N_n!0vzN>VIH(qd}!JQb{hZ=zq8f^0$8w3>Sf_Aa@edt zrW!@G4H+v9k)g!?M+TMxm ziFjW^jOEG~%ub^Nqb)lsiZ!BQeydux>R-3-9p&+<7_%kSI!@9QzjFt2$b7%Z$T$vX zmUO$TWXMmHQ%--MhSiiq!2JXsV(>Rmc-A<53j%Qfh;F}S*F?F12CtU|ItRGfY3XLQ z&VLy+PtITvwadVdbD`}lV*#|*V}U5RXz<(J1UXOElMQ~}Ao2Ex%MSCm$nbDckH$v; zXc%7i>mU+z>hiR#rw&`{rk)3n;YaBiz+VTq`UlA2GyR{aq5m`4^zUH?fd;@B@Z%aT zf7TDPk+{K0^K%lvPX`2+5FnGQmiX^t!Q@3?Dk7jcwRL@NNBYj1&TlHKt-8%?Z4+K$ zY$Nj)=hT0Gd1C{5^Z372{Mks|&Lpg&9@03D3$YPs{uw>#d24uwddV6yh%#F z-Q613Z|F^gN0d>K@10lU6sU)_s#5s5JM|LWdVJ?t294?V(K zhBBQ)Zu7Tr=gg!xY|EFfv()5e?aaY;9~HDD`x~HyqTRkPvMWcS7eqa_&9#ZDbs|HB zZ#Io*pgPjzudOS3S9MVzBY8n)m3v26ywZ9%h&BTogBZlI#iusdxpO=1|0~R54RxxS ztv=ZO*!o1DMraXTkz_TY?0aqmItUq{S>Zg+JyD|-HNZH(M);G8mch$zkmQE0yk0})Z3cD$_Xg|lx3VR~=> zlwM`m_v15Q^2Kx%#@%7V@pq>Q*UB6`R_fTqLht}*sI*Et=FFwI4BH8ZrcLtLnKvEI zo&OG7*6!C`=T4S9yh&1fz<$IQ-Zfk^nG>>6FrMhS&I)7lyfatmj32z9cNHTTy_9!Q(M zF@SoBc~ZJp^ATJ(8Hs>K%`GxY^Ph>^Fzffhs@066kkenTx_^y}2ySUA|F@cjTs&9s zR(OqJ6c&Q#1c#8GF=^9 zl~4Bh9r+9-4H!wm5BJ*fr4O?I-`Y#E3N9m8#|^?;kWKW8N%GQhF^20>bSsOibXx+f z5;t8S$4Zr4U3_n9pxda8s{`sl^r6L*{;Rj67;fk!a`iX!z{lNE?Zo_7hcoPZBBGY3}A=voDb1$I0A@k9`~UTc7J_bTUw zZYEH>zWCY&(xE!}u6F3pp5&fW^#U8&e)3}_8l<6KxFB1e7_VBF$q!G*=@|}JSj9Yn zvFMD{F0t6jfJ*lixtxeld+%w5O8=61`=y2YJUumovTM$b_$6cJeu0jL%oXH*|LPI$ zj;7huv01rE^DSm&V*h) zCc-nRqikEELoR$nVWX+$J~givN^dbu|1xpAP+G3N6wW5LZ}(-V3om*l;z>pUf&-03fNe9?T9;_+5lbPxa$n+_p)BaoV$QgESnHaEy}yOt}gYu^b?4aVwNxw4_7we|d3KcJ@|%2K!G zB(qstBW>HOizIadG*#uWLJ%Dk*Fx&~`Yl{=%@8Ux!E8Eq+vDSy>M3!cn&-7aM`z>Z zg%C;qTN?#fAkft%#>Hd>U+}+0|rZ_B&rxX-Gr*2|(XoLotgZ~Hp#bHSVZT7!@q9&0JQV;3R z!qn)vuDy(Ylb9!`tPUdc6e8&UxZuK^-W-J!9f0G|_026z(>BeQwN8ONyYWe~4(a;( z8ZZa&>Jq1u%wbnmMfR{rr#T`dT9iXkGR8iv~Aw~*|3n1JFrnp&$GvlsEXh(l%_EX{W zW+dX2u78!ey27~_S1H3N!kYllf0O!n%oFxY73d_p+8-4kW7!bL^D0}r&MmNn7{6v4 zuf7bWxfe<`INv8xkC$VaCk>Eydoh~5B$|O?oJmq$n!RfBMJV@=mWJB87>t8Q*Y?PwQT1eW?vG#foO4O2{>?fq~buo#YhFzr|%_dlD63uaETe@fTw((%MG{poWY3;mIM&+(fA4Ytfze=?8K&uhlZ3hHi7r}|iO&uCsAEr;ZGQiXA- zaFXg3Tekj;AdB|BIV{Eb(>DdVSItx;Dd!MX&FM7$uk@*)_fsnH>&CF3OK5g3`g^f# zq0QX3VGxkXI;_gltBaP-p9#RHtRCUuAQ!$pmk;T#A3kq1i39n7R>1gDq~|SsR6svFJk;8-U}*p>hN}?&uG{f=-!N! zBcw3n9wFq?F+N@hV)rfc^7 z!>YxJVtfzia@PXkM^yXPDJve^_nS+rb#19`T*0=T;H#Oz;dZzYASabM& zmE@cm<6Ry;?_Vd^J&6klYS}?!Un4$$d!~Ne>3*VEfBYTqxoatnkMn#=A#+b*X5r)H zdCHsuYCWWZbdc;8IR0rQxhRsyiSJ?Ee*NGd@w52tL-z~m`WUsbQ(0xMl2CE6H*5j9 z`3VPOpcU62hsJLvD6PlT&aS}nH-hl|E5ecii1D;$o8A|?)QQ2248oT41s5~j+?4E{ zmIpFcJG0$g&j|hif?winng7tyPu_GeOrPA(>P$g?wiZT>_Jc?F-P*ac1Ec+!%gg-D z>dA`%Ua`7qRohoKPU~{Y@cnvM;AnBLXQw8ED(|Q`<^SmACn&#YH)G{<{ybO5F2SyYi^$1uomw4lT&WzRRbQ2Tqs8yY3b<1ar5vHSE@D7uL`I14{Ww2GeG8msJr>6 z%Rj37FNPODB zlUOGZmCeCu#i_o2sqCYzz#tjV)oSPm1tFuv{X+%_V?7s5Kyvl1+z;%XGmi~x zeWm^I<v2$A4MTa8TuZy3xjyB zq%mSj>=!fEcp4-M=`Es{ulnBNY+1Qy~>en;OIS^@R{o49Ir~`%!f^hbZZ%%g) zYiO7u9lv1@$o+p@I?Sd_9E;A!ha%o0;JzWEKvBejo5Xy1%kEUs@Y8CUWlmtzQ!vw| zIvBA|39n(B5cPF_NQ0D*GtHI3ZMM43S%$8V`V$k2{s8M+`gX+so* z;}(d4+FzSPj{n1;PMkOqm6el|V=|m8i{%1o>;Uy5>qtyWVpj$Ob&tnk1@w4GU1KHy z5vyh2v*wN(rpxP*W0L<9AyNOKT{c&}M;O*}zA4v}&-h2jRTR-aY_EHb4WLIR4d|=! z0;O&wK!H-`rgwSQ4WxvhMl$W*%FF?RjltpQpk{gczl-4juuPU)^pq|QGMch44!S;1*)M;f$~2t6TQnuK`J4>k@Etw zf(AZ=)OSc+iNvk#|Fh$6{~!N<`nJs{boaa{rTd&6SSr=Og*AG8FL zxQ1 z@Jc-FVZ02K%6fKY=D~(lJLL{Mg;n}U+fq?RUVbIk>59TfabFytsBD@zAGTSF!NLC7 zxxW!N(qT!0l(AhDYjCY5h8ui=fBkjoZ$JODPqEgUd30IV2atz6UHx3vIVCg!06F{4 AKmY&$ literal 0 HcmV?d00001 diff --git a/custom_tables.jpg b/Images/custom_tables.jpg similarity index 100% rename from custom_tables.jpg rename to Images/custom_tables.jpg diff --git a/custom_tables_v2.png b/Images/custom_tables_v2.png similarity index 100% rename from custom_tables_v2.png rename to Images/custom_tables_v2.png diff --git a/init.rb b/init.rb index 9648edf..b938491 100644 --- a/init.rb +++ b/init.rb @@ -1,12 +1,12 @@ # init.rb Redmine::Plugin.register :custom_tables do name 'Custom Tables plugin' - author 'Ivan Marangoz' + author 'Arean Narrayan' description 'This is a plugin for Redmine' - version '1.1.1' + version '1.1.2' requires_redmine :version_or_higher => '3.4.0' - url 'https://github.com/frywer/custom_tables' - author_url 'https://github.com/frywer' + url 'https://github.com/Arean82/custom_tables' + author_url 'https://github.com/Arean82/' # Add settings configuration settings default: { 'allowed_groups' => [], 'enable_custom_permissions' => false }, From 2fa3e624cfb78e59a9c8f397f907085b5a7fea1a Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 11:08:26 +0530 Subject: [PATCH 28/41] updated Readme --- Images/custom_table_configure.png | Bin 0 -> 7112 bytes README.md | 107 ++++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 Images/custom_table_configure.png diff --git a/Images/custom_table_configure.png b/Images/custom_table_configure.png new file mode 100644 index 0000000000000000000000000000000000000000..89bb0e5ed68512f30e2f105d54a00e58b65ba7b3 GIT binary patch literal 7112 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index ccfd020..1a74b9b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,23 @@ +Here's the updated README with your modifications highlighted: + +```markdown Redmine Custom Tables ================== This plugin provides a possibility to create custom tables. The table is built with Redmine custom fields. It allows you to create any databases you need for your business and integrate it into your workflow processes. - + + +## 🆕 Enhanced Features & Modifications + +**Custom Modifications Added:** +- **Advanced Permission System** - Configurable group-based access control +- **Role-Based Restrictions** - Fine-grained control over edit/delete permissions +- **Admin Settings Interface** - Easy configuration via plugin settings page +- **Enhanced Security** - Controller-level and view-level permission checks -[Online demo](https://redmineplus.com/sign-up-to-redmine-plus/) + + Features ------------- @@ -18,6 +30,9 @@ Features * Commenting entities * Export CSV/PDF * API +* **🆕 Configurable permission system** - Control which user groups can edit and delete records +* **🆕 Role-based access control** - Restrict operations to specific roles (Administrator, Manager) +* **🆕 Settings configuration page** - Easy management of permissions via admin interface Compatibility ------------- @@ -26,10 +41,10 @@ Compatibility Installation and Setup ---------------------- -* Clone or [download](https://github.com/frywer/custom_tables/archive/master.zip) this repo into your **redmine_root/plugins/** folder +* Clone or [download](https://github.com/Arean82/custom_tables/archive/refs/heads/master.zip) this repo into your **redmine_root/plugins/** folder ``` -$ git clone https://github.com/frywer/custom_tables.git +$ git clone https://github.com/Arean82/custom_tables.git ``` * If you downloaded a tarball / zip from master branch, make sure you rename the extracted folder to `custom_tables` * You have to run the plugin rake task to provide the assets (from the Redmine root directory): @@ -40,7 +55,89 @@ $ bundle exec rake redmine:plugins:migrate RAILS_ENV=production Usage ---------------------- +### Basic Setup 1) Visit **Administration->Custom tables** to open table constructor. 2) Press button **New table**. Fill the name field, select projects you want to enable table on and submit the form. 3) Add custom fields to your new table. -4) Give access to the users **Administration -> Roles and permissions -> Project -> Manage custom tables** \ No newline at end of file +4) Give access to the users **Administration -> Roles and permissions -> Project -> Manage custom tables** + +### 🆕 Enhanced Permission Configuration +The plugin includes a **flexible and configurable permission system** that allows precise control over who can edit and delete custom table records: + +#### Default Behavior (When Custom Permissions Disabled) +- **Administrators**: Full access (create, edit, delete) +- **Managers**: Full access (create, edit, delete) +- **Other Users**: View and add records only (edit/delete buttons hidden) + +#### 🆕 Custom Group Permissions (Enhanced Feature) +You can configure specific user groups to have full access via the admin interface: + +1. Go to **Administration → Plugins → Custom Tables plugin → Configure** +2. Check **"Enable Custom Group Permissions"** to activate custom group-based access +3. Select the user groups that should have full edit/delete permissions +4. Click **Save** to apply changes + +#### 🆕 Multi-Layer Security +- **View-Level Security**: Delete/edit buttons are hidden from unauthorized users +- **Controller-Level Security**: All destructive actions are blocked at the controller level +- **Role-Based Security**: Built-in support for Administrator and Manager roles +- **Group-Based Security**: Custom group permissions via settings configuration + +#### Permission Levels +- **Full Access Users** (Admins, Managers, or selected groups): Can create, edit, and delete tables and records +- **Standard Users**: Can view and add records, but cannot edit or delete existing ones + +### 🆕 Technical Implementation Details +The enhanced permission system includes: +- **CustomTablesPermissionHelper** - Centralized permission logic +- **Controller-level before_actions** - Security at the action level +- **View-level conditionals** - UI element visibility control +- **Settings management** - Persistent configuration storage +- **Role and group validation** - Multi-factor permission checking + +### API Access +The plugin provides API endpoints for managing custom tables and entities. API access follows the same permission rules as the web interface. + +Support +------- +If you find any bugs, feel free to create an issue on GitHub or make a pull request. +https://github.com/frywer/custom_tables + +Contributors +------------ +* Ivan Ivon (@frywer) +* **🆕 Plugin customization and enhanced permission system** - Added configurable group-based permissions, role restrictions, and admin settings interface + +License +------- +The plugin is available under the MIT license. + +## 🆕 Changelog + +### Enhanced Version Features: +- ✅ Configurable group-based permission system +- ✅ Admin settings page for easy permission management +- ✅ Role-based restrictions (Administrator, Manager) +- ✅ Multi-layer security (view + controller level) +- ✅ Enhanced UI with conditional button visibility +- ✅ Persistent settings configuration +- ✅ Backward compatibility with existing installations +``` + +## Key Highlights of Your Modifications: + +1. **🆕 Enhanced Features Section** - Clear overview of what you added +2. **🆕 Icon indicators** - Visual markers for new features +3. **🆕 Technical Details** - Explanation of the multi-layer security +4. **🆕 Implementation Details** - Technical architecture of your permission system +5. **🆕 Updated Contributors** - Credit for your enhancements +6. **🆕 Changelog** - Summary of all new features + +This README now clearly documents: +- Your custom permission system implementation +- The admin configuration interface you built +- The multi-layer security approach +- All technical enhancements you made +- How to use the new features + +It gives proper credit for your work while maintaining the original plugin structure and functionality! \ No newline at end of file From e4194b376168f98aed95ad5cc932d794553c671d Mon Sep 17 00:00:00 2001 From: arean82 <38061300+Arean82@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:09:32 +0530 Subject: [PATCH 29/41] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1a74b9b..a1c0391 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Redmine Custom Tables This plugin provides a possibility to create custom tables. The table is built with Redmine custom fields. It allows you to create any databases you need for your business and integrate it into your workflow processes. - + ## 🆕 Enhanced Features & Modifications @@ -140,4 +140,4 @@ This README now clearly documents: - All technical enhancements you made - How to use the new features -It gives proper credit for your work while maintaining the original plugin structure and functionality! \ No newline at end of file +It gives proper credit for your work while maintaining the original plugin structure and functionality! From d2d90a41137a44e4aae54585d8b82b7571cf7b21 Mon Sep 17 00:00:00 2001 From: arean82 <38061300+Arean82@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:13:21 +0530 Subject: [PATCH 30/41] Update README.md changed images to Images --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a1c0391..7abb269 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Redmine Custom Tables This plugin provides a possibility to create custom tables. The table is built with Redmine custom fields. It allows you to create any databases you need for your business and integrate it into your workflow processes. - + ## 🆕 Enhanced Features & Modifications From db514748e62bd8e959ab50ab84a7548771122da7 Mon Sep 17 00:00:00 2001 From: arean82 <38061300+Arean82@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:13:46 +0530 Subject: [PATCH 31/41] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7abb269..7c3a30b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Redmine Custom Tables This plugin provides a possibility to create custom tables. The table is built with Redmine custom fields. It allows you to create any databases you need for your business and integrate it into your workflow processes. - + ## 🆕 Enhanced Features & Modifications From e23a9e33d946d97c87efcc6513a905494fdc748a Mon Sep 17 00:00:00 2001 From: arean82 <38061300+Arean82@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:14:23 +0530 Subject: [PATCH 32/41] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 7c3a30b..86d63ff 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -Here's the updated README with your modifications highlighted: -```markdown Redmine Custom Tables ================== From ded421751ad1e4fdf8dac38aed4f9fbf9bbb2e9e Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 11:15:34 +0530 Subject: [PATCH 33/41] readme updated --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 86d63ff..8d85f54 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ This plugin provides a possibility to create custom tables. The table is built w - **Admin Settings Interface** - Easy configuration via plugin settings page - **Enhanced Security** - Controller-level and view-level permission checks - - + + Features ------------- @@ -120,7 +120,7 @@ The plugin is available under the MIT license. - ✅ Enhanced UI with conditional button visibility - ✅ Persistent settings configuration - ✅ Backward compatibility with existing installations -``` + ## Key Highlights of Your Modifications: From b8e5024c6437a2b449644ea9d50aabfc6f61f21c Mon Sep 17 00:00:00 2001 From: arean82 <38061300+Arean82@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:17:18 +0530 Subject: [PATCH 34/41] Update README.md --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 8d85f54..3d850ef 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,3 @@ The plugin is available under the MIT license. 5. **🆕 Updated Contributors** - Credit for your enhancements 6. **🆕 Changelog** - Summary of all new features -This README now clearly documents: -- Your custom permission system implementation -- The admin configuration interface you built -- The multi-layer security approach -- All technical enhancements you made -- How to use the new features - -It gives proper credit for your work while maintaining the original plugin structure and functionality! From aaada7c29c1be7af267b176b2ce03059e836749e Mon Sep 17 00:00:00 2001 From: arean82 <38061300+Arean82@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:20:33 +0530 Subject: [PATCH 35/41] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3d850ef..7e649db 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,11 @@ $ bundle exec rake redmine:plugins:migrate RAILS_ENV=production Usage ---------------------- ### Basic Setup -1) Visit **Administration->Custom tables** to open table constructor. +1) Visit **Administration → Custom tables** to open table constructor. 2) Press button **New table**. Fill the name field, select projects you want to enable table on and submit the form. 3) Add custom fields to your new table. -4) Give access to the users **Administration -> Roles and permissions -> Project -> Manage custom tables** +4) Give access to the users **Administration → Roles and permissions → Project → Manage custom tables** +5) **🆕 Configure permissions** by going to **Administration → Plugins → Custom Tables plugin → Configure** to set up which user groups can edit and delete records ### 🆕 Enhanced Permission Configuration The plugin includes a **flexible and configurable permission system** that allows precise control over who can edit and delete custom table records: From cfd8039ceabda6559925205a9fc1937004203b61 Mon Sep 17 00:00:00 2001 From: arean82 <38061300+Arean82@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:22:15 +0530 Subject: [PATCH 36/41] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e649db..db1fa60 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Usage 2) Press button **New table**. Fill the name field, select projects you want to enable table on and submit the form. 3) Add custom fields to your new table. 4) Give access to the users **Administration → Roles and permissions → Project → Manage custom tables** -5) **🆕 Configure permissions** by going to **Administration → Plugins → Custom Tables plugin → Configure** to set up which user groups can edit and delete records +5) **🆕 Configure permissions** by going to **Administration → Plugins → Custom Tables plugin → Configure** to set up which user groups can edit and delete records *(All users can view and add records, but only authorized users can edit or delete)* ### 🆕 Enhanced Permission Configuration The plugin includes a **flexible and configurable permission system** that allows precise control over who can edit and delete custom table records: From 7fb555e6e5d2b4e63fe07f5a6c33003d040b7d59 Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 14:47:19 +0530 Subject: [PATCH 37/41] updated locals --- config/locales/cs.yml | 43 +++++++++++++++++++++++++++++++++++++++++++ config/locales/en.yml | 9 ++++++++- config/locales/es.yml | 13 +++++++++++++ config/locales/sv.yml | 16 ++++++++++++++-- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 11acc57..b376097 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -1 +1,44 @@ cs: + label_id: ID + label_custom_tables: Vlastní tabulky + label_glad_custom_tables: Vlastní tabulky + label_custom_table_new: Nová tabulka + label_custom_table: Vlastní tabulka + label_table: Tabulka + label_custom_table_edit: Upravit tabulku + glad_custom_tables: Vlastní tabulky + custom_tables: Vlastní tabulky + label_custom_entity_edit: Upravit %{name} + label_custom_entity_new: Nový %{name} + label_custom_table_tab: '%{name}' + button_edit_custom_table: Upravit tabulku + button_delete_custom_table: Smazat tabulku + label_custom_field_edit: Upravit vlastní pole + field_main_custom_field: Hlavní sloupec + label_belongs_to: Patří k + field_parent_table: Tabulka + label_bulk_edit_selected: Hromadně upravit vybrané %{table_name} + label_new_column: Nový sloupec + label_all_tables: Všechny tabulky + help_please_configure_table_first: V této tabulce nejsou žádná vlastní pole. %{settings} + label_add: Přidat + button_new_comment: Nový komentář + label_new_note: Nový komentář + field_external_name: Externí název + field_export: Exportovat + text_missing_permission_manage_custom_tables: Nemáte oprávnění spravovat vlastní tabulky! + field_name: Název + field_created_on: Vytvořeno + + # NEW STRINGS: + label_custom_tables_settings: "Nastavení vlastních tabulek" + label_enable_custom_permissions: "Povolit vlastní oprávnění skupin" + label_allowed_groups: "Skupiny s plným přístupem" + text_enable_custom_permissions_help: "Když je povoleno, pouze vybrané skupiny budou mít plná oprávnění (úpravy, mazání). Když je zakázáno, plný přístup mají pouze administrátoři a manažeři." + text_allowed_groups_help: "Vyberte uživatelské skupiny, které by měly mít plný přístup (úpravy, mazání tabulek a záznamů). Ostatní uživatelé budou moci pouze zobrazovat a přidávat záznamy." + + # NEW STRINGS FOR SERIAL NUMBERS FEATURE: + label_enable_serial_numbers: "Povolit sériová čísla v tabulkách" + text_enable_serial_numbers_help: "Když je povoleno, tabulky zobrazí sloupec sériového čísla (S. č.) s pořadovým číslem každého záznamu." + + diff --git a/config/locales/en.yml b/config/locales/en.yml index c4a23f4..783d26a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -35,4 +35,11 @@ en: label_enable_custom_permissions: "Enable Custom Group Permissions" label_allowed_groups: "Groups with Full Access" text_enable_custom_permissions_help: "When enabled, only selected groups will have full permissions (edit, delete). When disabled, only Administrators and Managers have full access." - text_allowed_groups_help: "Select user groups that should have full permissions (edit, delete tables and entities). All other users will only be able to view and add records." \ No newline at end of file + text_allowed_groups_help: "Select user groups that should have full permissions (edit, delete tables and entities). All other users will only be able to view and add records." + text_allowed_groups_help: "Select user groups that should have full permissions (edit, delete tables and entities). All other users will only be able to view and add records." + + # NEW STRINGS FOR SERIAL NUMBERS FEATURE: + label_enable_serial_numbers: "Enable Serial Numbers in Tables" + text_enable_serial_numbers_help: "When enabled, tables will display a serial number (S.No) column showing the row number for each record" + + diff --git a/config/locales/es.yml b/config/locales/es.yml index 77c8615..746318a 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -31,3 +31,16 @@ es: text_missing_permission_manage_custom_tables: ¡Sin permiso para administrar tablas personalizadas! field_name: Nombre field_created_on: Creado el + + # NEW STRINGS: + label_custom_tables_settings: "Configuración de tablas personalizadas" + label_enable_custom_permissions: "Habilitar permisos personalizados de grupo" + label_allowed_groups: "Grupos con acceso completo" + text_enable_custom_permissions_help: "Cuando está habilitado, solo los grupos seleccionados tendrán permisos completos (editar, eliminar). Cuando está deshabilitado, solo los administradores y gerentes tendrán acceso completo." + text_allowed_groups_help: "Seleccione los grupos de usuarios que deben tener acceso completo (editar, eliminar tablas y registros). Todos los demás usuarios solo podrán ver y añadir registros." + + # NEW STRINGS FOR SERIAL NUMBERS FEATURE: + label_enable_serial_numbers: "Habilitar números de serie en las tablas" + text_enable_serial_numbers_help: "Cuando está habilitado, las tablas mostrarán una columna de número de serie (Nº) que muestra el número de fila de cada registro." + + diff --git a/config/locales/sv.yml b/config/locales/sv.yml index fd62ffa..5c5fc77 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -1,5 +1,4 @@ sv: - # my_label: "Min rubrik" label_id: ID label_custom_tables: Custom tables label_glad_custom_tables: Anpassade tabeller @@ -29,4 +28,17 @@ sv: field_export: Exportera text_missing_permission_manage_custom_tables: Du har ej rättigheter att ändra anpassade tabeller! field_name: Namn - field_created_on: Skapad \ No newline at end of file + field_created_on: Skapad + field_created_on: Skapad den + + # NEW STRINGS: + label_custom_tables_settings: "Inställningar för anpassade tabeller" + label_enable_custom_permissions: "Aktivera anpassade gruppbehörigheter" + label_allowed_groups: "Grupper med full åtkomst" + text_enable_custom_permissions_help: "När aktiverat kommer endast valda grupper att ha full behörighet (redigera, ta bort). När det är inaktiverat har endast administratörer och chefer full åtkomst." + text_allowed_groups_help: "Välj användargrupper som ska ha full behörighet (redigera, ta bort tabeller och poster). Alla andra användare kan bara visa och lägga till poster." + + # NEW STRINGS FOR SERIAL NUMBERS FEATURE: + label_enable_serial_numbers: "Aktivera serienummer i tabeller" + text_enable_serial_numbers_help: "När aktiverat visar tabellerna en kolumn med serienummer (Nr) som visar radnumret för varje post." + From 21e3d314c42c9842d0cf04ffa4cebf9fc50c70c8 Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 14:48:30 +0530 Subject: [PATCH 38/41] updated locals --- config/locales/cs.yml | 4 ++++ config/locales/en.yml | 4 ++++ config/locales/es.yml | 4 ++++ config/locales/sv.yml | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/config/locales/cs.yml b/config/locales/cs.yml index b376097..712f291 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -40,5 +40,9 @@ cs: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Povolit sériová čísla v tabulkách" text_enable_serial_numbers_help: "Když je povoleno, tabulky zobrazí sloupec sériového čísla (S. č.) s pořadovým číslem každého záznamu." +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes diff --git a/config/locales/en.yml b/config/locales/en.yml index 783d26a..ba4e3a9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,5 +41,9 @@ en: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Enable Serial Numbers in Tables" text_enable_serial_numbers_help: "When enabled, tables will display a serial number (S.No) column showing the row number for each record" +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes diff --git a/config/locales/es.yml b/config/locales/es.yml index 746318a..b4091c2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -42,5 +42,9 @@ es: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Habilitar números de serie en las tablas" text_enable_serial_numbers_help: "Cuando está habilitado, las tablas mostrarán una columna de número de serie (Nº) que muestra el número de fila de cada registro." +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes diff --git a/config/locales/sv.yml b/config/locales/sv.yml index 5c5fc77..f75bc42 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -41,4 +41,8 @@ sv: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Aktivera serienummer i tabeller" text_enable_serial_numbers_help: "När aktiverat visar tabellerna en kolumn med serienummer (Nr) som visar radnumret för varje post." +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes From d6dd8219461ec7ee754810b9f9047c35870fd87d Mon Sep 17 00:00:00 2001 From: arean82 Date: Tue, 4 Nov 2025 14:50:10 +0530 Subject: [PATCH 39/41] update redmine version --- init.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/init.rb b/init.rb index b938491..99ba3c2 100644 --- a/init.rb +++ b/init.rb @@ -4,7 +4,7 @@ author 'Arean Narrayan' description 'This is a plugin for Redmine' version '1.1.2' - requires_redmine :version_or_higher => '3.4.0' + requires_redmine :version_or_higher => '5.0.0' url 'https://github.com/Arean82/custom_tables' author_url 'https://github.com/Arean82/' From ae4957895ba39a3cc56a1c0ab0dbe9cfeabc920b Mon Sep 17 00:00:00 2001 From: arean82 Date: Wed, 5 Nov 2025 10:06:09 +0530 Subject: [PATCH 40/41] updated locals --- config/locales/cs.yml | 5 ----- config/locales/en.yml | 9 ++++----- config/locales/es.yml | 5 ----- config/locales/sv.yml | 6 +----- 4 files changed, 5 insertions(+), 20 deletions(-) diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 712f291..0796044 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -40,9 +40,4 @@ cs: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Povolit sériová čísla v tabulkách" text_enable_serial_numbers_help: "Když je povoleno, tabulky zobrazí sloupec sériového čísla (S. č.) s pořadovým číslem každého záznamu." -<<<<<<< Updated upstream - -======= - ->>>>>>> Stashed changes diff --git a/config/locales/en.yml b/config/locales/en.yml index ba4e3a9..09c19a7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,9 +41,8 @@ en: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Enable Serial Numbers in Tables" text_enable_serial_numbers_help: "When enabled, tables will display a serial number (S.No) column showing the row number for each record" -<<<<<<< Updated upstream - -======= - ->>>>>>> Stashed changes + #Download all tables as CSV + label_export_all_tables: "Export All Custom Tables" + title_export_all_tables: "Export all custom tables data from this project to CSV" + diff --git a/config/locales/es.yml b/config/locales/es.yml index b4091c2..109a466 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -42,9 +42,4 @@ es: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Habilitar números de serie en las tablas" text_enable_serial_numbers_help: "Cuando está habilitado, las tablas mostrarán una columna de número de serie (Nº) que muestra el número de fila de cada registro." -<<<<<<< Updated upstream - -======= - ->>>>>>> Stashed changes diff --git a/config/locales/sv.yml b/config/locales/sv.yml index f75bc42..ffebe8a 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -41,8 +41,4 @@ sv: # NEW STRINGS FOR SERIAL NUMBERS FEATURE: label_enable_serial_numbers: "Aktivera serienummer i tabeller" text_enable_serial_numbers_help: "När aktiverat visar tabellerna en kolumn med serienummer (Nr) som visar radnumret för varje post." -<<<<<<< Updated upstream - -======= - ->>>>>>> Stashed changes + From c309404fa9a43bcd5fa46bf622ef38f1090c703c Mon Sep 17 00:00:00 2001 From: arean82 Date: Wed, 5 Nov 2025 10:19:01 +0530 Subject: [PATCH 41/41] added api --- app/controllers/custom_entities_controller.rb | 3 +- config/routes.rb | 38 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/controllers/custom_entities_controller.rb b/app/controllers/custom_entities_controller.rb index ba6d52e..054b34a 100644 --- a/app/controllers/custom_entities_controller.rb +++ b/app/controllers/custom_entities_controller.rb @@ -18,7 +18,8 @@ class CustomEntitiesController < ApplicationController # Add permission helper helper :custom_tables_permission - accept_api_auth :show, :create, :update, :destroy + #accept_api_auth :show, :create, :update, :destroy + accept_api_auth :index, :show, :create, :update, :destroy # Use the permission method before_action :check_destroy_permission, only: [:destroy, :context_menu, :bulk_update] diff --git a/config/routes.rb b/config/routes.rb index 32d5745..5cf5d98 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,15 +1,15 @@ # Plugin's routes # See: http://guides.rubyonrails.org/routing.html -resources :custom_tables - -resources :custom_tables do +resources :custom_tables, defaults: { format: :html } do collection do get :context_menu end end + resources :table_fields -resources :custom_entities do + +resources :custom_entities, defaults: { format: :html } do collection do get :bulk_edit post :bulk_update @@ -27,4 +27,32 @@ member do get 'edit_note' end -end \ No newline at end of file +end + +# -------------------------------------------------------------------- +# ✅ Add Redmine REST API endpoints (JSON / XML supported) +# -------------------------------------------------------------------- +match '/custom_tables(.:format)', to: 'custom_tables#index', via: :get, defaults: { format: 'json' } +match '/custom_tables/:id(.:format)', to: 'custom_tables#show', via: :get, defaults: { format: 'json' } +match '/custom_tables(.:format)', to: 'custom_tables#create', via: :post, defaults: { format: 'json' } +match '/custom_tables/:id(.:format)', to: 'custom_tables#update', via: :put, defaults: { format: 'json' } +match '/custom_tables/:id(.:format)', to: 'custom_tables#destroy',via: :delete, defaults: { format: 'json' } + +match '/custom_tables/:custom_table_id/custom_entities(.:format)', + to: 'custom_entities#index', + via: :get, defaults: { format: 'json' } +match '/custom_entities/:id(.:format)', + to: 'custom_entities#show', + via: :get, defaults: { format: 'json' } +match '/custom_entities(.:format)', + to: 'custom_entities#create', + via: :post, defaults: { format: 'json' } +match '/custom_entities/:id(.:format)', + to: 'custom_entities#update', + via: :put, defaults: { format: 'json' } +match '/custom_entities/:id(.:format)', + to: 'custom_entities#destroy', + via: :delete, defaults: { format: 'json' } + + +