diff --git a/Gemfile b/Gemfile index 0bc6fa410..43ecaddc0 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,7 @@ gem "omniauth" gem "omniauth_openid_connect" gem "omniauth-rails_csrf_protection" gem "pandoc-ruby" +gem "paper_trail" gem "pg" gem "pg_search" gem "puma", "~> 6" @@ -51,6 +52,7 @@ gem "rollbar" gem "scenic" gem "sidekiq", "~> 6.4" gem "sidekiq-cron", "~> 1.10" +gem "simple_xlsx_reader" gem "slack-notifier" gem "sprockets-rails" gem "stimulus-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 1370b13a1..efc2a8983 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -390,6 +390,9 @@ GEM validate_url webfinger (~> 2.0) pandoc-ruby (2.1.7) + paper_trail (15.0.0) + activerecord (>= 6.1) + request_store (~> 1.4) parallel (1.23.0) parser (3.2.2.3) ast (~> 2.4.1) @@ -491,6 +494,8 @@ GEM redis-store (1.9.2) redis (>= 4, < 6) regexp_parser (2.8.1) + request_store (1.5.1) + rack (>= 1.4) rexml (3.2.6) rollbar (3.4.0) rouge (4.1.3) @@ -572,6 +577,9 @@ GEM fugit (~> 1.8) globalid (>= 1.0.1) sidekiq (>= 6) + simple_xlsx_reader (5.0.0) + nokogiri + rubyzip simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -705,6 +713,7 @@ DEPENDENCIES omniauth-rails_csrf_protection omniauth_openid_connect pandoc-ruby + paper_trail pg pg_search pry-byebug @@ -727,6 +736,7 @@ DEPENDENCIES shoulda-matchers sidekiq (~> 6.4) sidekiq-cron (~> 1.10) + simple_xlsx_reader simplecov slack-notifier spring diff --git a/app/assets/stylesheets/application.sass.scss b/app/assets/stylesheets/application.sass.scss index c51f295dc..2ddef00ed 100644 --- a/app/assets/stylesheets/application.sass.scss +++ b/app/assets/stylesheets/application.sass.scss @@ -2,6 +2,7 @@ @import "./base/frontend"; @import "./base/misc"; +@import "./components/activity-log"; @import "./components/supported/procurement-stage-colours"; @import "./components/govuk-radios-small"; @import "./components/autocomplete"; @@ -47,7 +48,7 @@ &.wide-container { max-width: 2000px; } - + @media (min-width: 1020px) { &.wide-container { margin-left: 30px; @@ -55,4 +56,3 @@ } } } - \ No newline at end of file diff --git a/app/assets/stylesheets/base/_misc.scss b/app/assets/stylesheets/base/_misc.scss index d4c34186e..709dd8c77 100644 --- a/app/assets/stylesheets/base/_misc.scss +++ b/app/assets/stylesheets/base/_misc.scss @@ -3,6 +3,10 @@ align-items: center; } +.pull-up-10 { + margin-top: -10px; +} + .spread-out { display: flex; align-items: center; diff --git a/app/assets/stylesheets/components/_activity-log.scss b/app/assets/stylesheets/components/_activity-log.scss new file mode 100644 index 000000000..c1b022a8b --- /dev/null +++ b/app/assets/stylesheets/components/_activity-log.scss @@ -0,0 +1,20 @@ +.activity-log-table { + @extend .govuk-\!-font-size-16; + + table { + @extend .govuk-\!-font-size-16; + } + + .activity-log-date { + width: 180px; + } + .activity-log-person { + width: 180px; + } + + .activity-log-change-arrow { + color: #a2a2a2; + margin-left: 5px; + margin-right: 5px; + } +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9dc8a6e83..1a1daa97d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base default_form_builder GOVUKDesignSystemFormBuilder::FormBuilder before_action :authenticate_user!, except: %i[health_check maintenance] + before_action :set_current_request_id protect_from_forgery @@ -24,7 +25,7 @@ def maintenance protected - helper_method :current_user, :cookie_policy, :record_ga?, :engagement_portal?, :support_portal?, :frameworks_portal? + helper_method :current_user, :cookie_policy, :record_ga?, :engagement_portal?, :support_portal?, :frameworks_portal?, :current_url_b64 # @return [User, Guest] # @@ -86,9 +87,17 @@ def cookie_policy CookiePolicy.new(cookies) end - def current_url_b64 - Base64.encode64(request.fullpath) + def current_url_b64(tab = nil) + Base64.encode64("#{request.fullpath}#{"##{tab.to_s.dasherize}" if tab.present?}") + end + + def back_link_param(back_to = params[:back_to]) + return if back_to.blank? + + Base64.decode64(back_to) end def tracking_base_properties = { user_id: current_user.id } + + def set_current_request_id = Current.request_id = request.request_id end diff --git a/app/controllers/engagement/application_controller.rb b/app/controllers/engagement/application_controller.rb index 9dbd2d1e3..73bd0ca26 100644 --- a/app/controllers/engagement/application_controller.rb +++ b/app/controllers/engagement/application_controller.rb @@ -2,20 +2,6 @@ module Engagement class ApplicationController < ::ApplicationController include SupportAgents - helper_method :current_url_b64 - - protected - - def back_link_param(back_to = params[:back_to]) - return if back_to.blank? - - Base64.decode64(back_to) - end - - def current_url_b64(tab = "") - Base64.encode64("#{request.fullpath}##{tab.to_s.dasherize}") - end - private def portal_namespace = :engagement diff --git a/app/controllers/frameworks/application_controller.rb b/app/controllers/frameworks/application_controller.rb index 160a39224..78cc45997 100644 --- a/app/controllers/frameworks/application_controller.rb +++ b/app/controllers/frameworks/application_controller.rb @@ -1,5 +1,6 @@ class Frameworks::ApplicationController < ApplicationController include SupportAgents + before_action { Current.actor = current_agent } private diff --git a/app/controllers/frameworks/frameworks_controller.rb b/app/controllers/frameworks/frameworks_controller.rb new file mode 100644 index 000000000..7e3ac2cf0 --- /dev/null +++ b/app/controllers/frameworks/frameworks_controller.rb @@ -0,0 +1,30 @@ +class Frameworks::FrameworksController < Frameworks::ApplicationController + before_action :redirect_to_register_tab, unless: :turbo_frame_request?, only: :index + before_action :set_back_url, only: %i[new show] + + def new; end + + def index + @filtering = Frameworks::Framework.filtering(filter_form_params) + @frameworks = @filtering.results.paginate(page: params[:frameworks_page]) + end + + def show + @framework = Frameworks::Framework.find(params[:id]) + @activity_log_items = @framework.activity_log_items.paginate(page: params[:activities_page]) + end + +private + + def filter_form_params + params.fetch(:frameworks_filter, {}).permit(:sort_by, :sort_order, status: [], provider: [], e_and_o_lead: [], proc_ops_lead: [], category: []) + end + + def set_back_url + @back_url = back_link_param + end + + def redirect_to_register_tab + redirect_to frameworks_root_path(anchor: "frameworks-register", **request.params.except(:controller, :action)) + end +end diff --git a/app/controllers/frameworks/management/activity_logs_controller.rb b/app/controllers/frameworks/management/activity_logs_controller.rb new file mode 100644 index 000000000..ab57b44be --- /dev/null +++ b/app/controllers/frameworks/management/activity_logs_controller.rb @@ -0,0 +1,11 @@ +class Frameworks::Management::ActivityLogsController < Frameworks::Management::ApplicationController + before_action { @back_url = frameworks_management_path } + + def show + @activity_log_items = Frameworks::ActivityLogItem.paginate(page: params[:page], per_page: 50) + end + +private + + def authorize_agent_scope = [super, :manage_frameworks_activity_log?] +end diff --git a/app/controllers/frameworks/management/application_controller.rb b/app/controllers/frameworks/management/application_controller.rb new file mode 100644 index 000000000..bbfd07560 --- /dev/null +++ b/app/controllers/frameworks/management/application_controller.rb @@ -0,0 +1,5 @@ +class Frameworks::Management::ApplicationController < Frameworks::ApplicationController +private + + def authorize_agent_scope = [super, :access_admin_settings?] +end diff --git a/app/controllers/frameworks/management/management_controller.rb b/app/controllers/frameworks/management/management_controller.rb new file mode 100644 index 000000000..d841436f4 --- /dev/null +++ b/app/controllers/frameworks/management/management_controller.rb @@ -0,0 +1,3 @@ +class Frameworks::Management::ManagementController < Frameworks::Management::ApplicationController + def index; end +end diff --git a/app/controllers/frameworks/management/register_uploads_controller.rb b/app/controllers/frameworks/management/register_uploads_controller.rb new file mode 100644 index 000000000..4a91d5851 --- /dev/null +++ b/app/controllers/frameworks/management/register_uploads_controller.rb @@ -0,0 +1,19 @@ +class Frameworks::Management::RegisterUploadsController < Frameworks::Management::ApplicationController + before_action { @back_url = frameworks_management_path } + + def new; end + + def create + Frameworks::Framework.import_from_spreadsheet(register_upload_params[:spreadsheet]) + + redirect_to frameworks_management_path + end + +private + + def register_upload_params + params.require(:register_upload).permit(:spreadsheet) + end + + def authorize_agent_scope = [super, :manage_frameworks_register_upload?] +end diff --git a/app/models/current.rb b/app/models/current.rb index e48bce946..601277fa1 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,3 +1,3 @@ class Current < ActiveSupport::CurrentAttributes - attribute :user, :agent + attribute :user, :agent, :actor, :request_id end diff --git a/app/models/frameworks/activity.rb b/app/models/frameworks/activity.rb new file mode 100644 index 000000000..9b7f86be1 --- /dev/null +++ b/app/models/frameworks/activity.rb @@ -0,0 +1,7 @@ +module Frameworks::Activity + extend ActiveSupport::Concern + + included do + has_one :activity_log_item, as: :activity, touch: true + end +end diff --git a/app/models/frameworks/activity_log_item.rb b/app/models/frameworks/activity_log_item.rb new file mode 100644 index 000000000..1548b3cdf --- /dev/null +++ b/app/models/frameworks/activity_log_item.rb @@ -0,0 +1,12 @@ +class Frameworks::ActivityLogItem < ApplicationRecord + include Presentable + + belongs_to :actor, polymorphic: true, optional: true + belongs_to :subject, polymorphic: true, optional: true + delegated_type :activity, types: %w[PaperTrail::Version] + + before_create do + self.guid ||= Current.request_id + self.actor ||= Current.actor + end +end diff --git a/app/models/frameworks/activity_log_item/presentable.rb b/app/models/frameworks/activity_log_item/presentable.rb new file mode 100644 index 000000000..7941fb2c8 --- /dev/null +++ b/app/models/frameworks/activity_log_item/presentable.rb @@ -0,0 +1,11 @@ +module Frameworks::ActivityLogItem::Presentable + extend ActiveSupport::Concern + + def display_created_at + created_at.strftime("%d/%m/%Y %H:%M:%S") + end + + def display_actor + actor.try(:full_name) + end +end diff --git a/app/models/frameworks/activity_loggable.rb b/app/models/frameworks/activity_loggable.rb new file mode 100644 index 000000000..107bf672e --- /dev/null +++ b/app/models/frameworks/activity_loggable.rb @@ -0,0 +1,19 @@ +module Frameworks::ActivityLoggable + extend ActiveSupport::Concern + include ActivityLogPresentable + + included do + has_paper_trail versions: { class_name: "Frameworks::ActivityLoggableVersion" } + + has_many :activity_log_items, as: :subject + + after_create :log_latest_version_in_activity_log + after_update :log_latest_version_in_activity_log + end + +private + + def log_latest_version_in_activity_log + Frameworks::ActivityLogItem.create!(subject: self, activity: versions.last) + end +end diff --git a/app/models/frameworks/activity_loggable/activity_log_presentable.rb b/app/models/frameworks/activity_loggable/activity_log_presentable.rb new file mode 100644 index 000000000..3e22fd4b3 --- /dev/null +++ b/app/models/frameworks/activity_loggable/activity_log_presentable.rb @@ -0,0 +1,20 @@ +module Frameworks::ActivityLoggable::ActivityLogPresentable + extend ActiveSupport::Concern + + def activity_log_association_display_name(association:, id:) + self.class.reflections[association].klass.find_by(id:).try(:activity_log_display_name) + end + + def activity_log_display_type + self.class.to_s.demodulize.underscore.humanize + end + + def activity_log_display_name + try(:full_name) || try(:short_name) || try(:name) + end + + def activity_log_display(field) + return send("activity_log_#{field}") if respond_to?("activity_log_#{field}") + return send("display_#{field}") if respond_to?("display_#{field}") + end +end diff --git a/app/models/frameworks/activity_loggable_version.rb b/app/models/frameworks/activity_loggable_version.rb new file mode 100644 index 000000000..ac1dff111 --- /dev/null +++ b/app/models/frameworks/activity_loggable_version.rb @@ -0,0 +1,5 @@ +class Frameworks::ActivityLoggableVersion < PaperTrail::Version + def presentable_chages + PresentableChanges.new(self) + end +end diff --git a/app/models/frameworks/activity_loggable_version/presentable_changes.rb b/app/models/frameworks/activity_loggable_version/presentable_changes.rb new file mode 100644 index 000000000..006c79089 --- /dev/null +++ b/app/models/frameworks/activity_loggable_version/presentable_changes.rb @@ -0,0 +1,27 @@ +class Frameworks::ActivityLoggableVersion::PresentableChanges + include Enumerable + + def initialize(version) + @version = version + end + + def each(&block) + if block_given? + non_common_object_changes.each(&block) + else + to_enum(:each) + end + end + +private + + def non_common_object_changes + @version.object_changes + .except("id", "created_at", "updated_at") + .map { |field, changes| change_type(field).new(@version, field, changes) } + end + + def change_type(field) + field.ends_with?("_id") ? BelongsToAssociationChange : FieldChange + end +end diff --git a/app/models/frameworks/activity_loggable_version/presentable_changes/belongs_to_association_change.rb b/app/models/frameworks/activity_loggable_version/presentable_changes/belongs_to_association_change.rb new file mode 100644 index 000000000..35f08d654 --- /dev/null +++ b/app/models/frameworks/activity_loggable_version/presentable_changes/belongs_to_association_change.rb @@ -0,0 +1,15 @@ +class Frameworks::ActivityLoggableVersion::PresentableChanges::BelongsToAssociationChange < Frameworks::ActivityLoggableVersion::PresentableChanges::FieldChange +private + + def to + item.activity_log_association_display_name(association:, id: changes.last) + end + + def from + item.activity_log_association_display_name(association:, id: changes.first) + end + + def association + field.split("_id").first + end +end diff --git a/app/models/frameworks/activity_loggable_version/presentable_changes/field_change.rb b/app/models/frameworks/activity_loggable_version/presentable_changes/field_change.rb new file mode 100644 index 000000000..350c50c13 --- /dev/null +++ b/app/models/frameworks/activity_loggable_version/presentable_changes/field_change.rb @@ -0,0 +1,33 @@ +class Frameworks::ActivityLoggableVersion::PresentableChanges::FieldChange + attr_reader :version, :item, :previous_item, :field, :changes + + def initialize(version, field, changes) + @version = version + @field = field + @changes = changes + @item = version.item + @previous_item = version.previous.try(:item) + end + + def field_name + field.humanize + end + + def display + "#{value_or_empty(from)} ➔ #{value_or_empty(to)}".html_safe + end + +private + + def to + item.activity_log_display(field) || changes.last + end + + def from + previous_item.try(:activity_log_display, field) || changes.first + end + + def value_or_empty(value) + value.nil? ? "empty" : value + end +end diff --git a/app/models/frameworks/framework.rb b/app/models/frameworks/framework.rb index d3d5a8178..731f266d8 100644 --- a/app/models/frameworks/framework.rb +++ b/app/models/frameworks/framework.rb @@ -1,6 +1,15 @@ class Frameworks::Framework < ApplicationRecord - include FafImportable + include Frameworks::ActivityLoggable include StatusChangeable + include SpreadsheetImportable + include Sourceable + include Presentable + include Filterable + include Sortable belongs_to :provider + belongs_to :provider_contact, optional: true + belongs_to :proc_ops_lead, class_name: "Support::Agent", optional: true + belongs_to :e_and_o_lead, class_name: "Support::Agent", optional: true + belongs_to :support_category, class_name: "Support::Category", optional: true end diff --git a/app/models/frameworks/framework/faf_importable.rb b/app/models/frameworks/framework/faf_importable.rb deleted file mode 100644 index 026b2d5ae..000000000 --- a/app/models/frameworks/framework/faf_importable.rb +++ /dev/null @@ -1,21 +0,0 @@ -module Frameworks::Framework::FafImportable - extend ActiveSupport::Concern - - class_methods do - def import_from_faf(framework_details) - framework = find_or_initialize_by(provider_reference: framework_details.provider_reference) - - provider = Frameworks::Provider.find_or_create_by!(name: framework_details.provider_name) - - framework.update!( - status: :dfe_approved, - name: framework_details.name, - provider_url: framework_details.provider_url, - ends_at: framework_details.ends_at, - description: framework_details.description, - published_on_faf: true, - provider:, - ) - end - end -end diff --git a/app/models/frameworks/framework/filterable.rb b/app/models/frameworks/framework/filterable.rb new file mode 100644 index 000000000..8dc118b6e --- /dev/null +++ b/app/models/frameworks/framework/filterable.rb @@ -0,0 +1,17 @@ +module Frameworks::Framework::Filterable + extend ActiveSupport::Concern + + included do + scope :by_status, ->(statuses) { where(status: Array(statuses)) } + scope :by_provider, ->(provider_ids) { where(provider_id: Array(provider_ids)) } + scope :by_category, ->(category_ids) { where(support_category_id: Array(category_ids)) } + scope :by_e_and_o_lead, ->(e_and_o_lead_ids) { where(e_and_o_lead_id: Array(e_and_o_lead_ids)) } + scope :by_proc_ops_lead, ->(proc_ops_lead_ids) { where(proc_ops_lead_id: Array(proc_ops_lead_ids)) } + end + + class_methods do + def filtering(params = {}) + Frameworks::Framework::Filtering.new(params) + end + end +end diff --git a/app/models/frameworks/framework/filtering.rb b/app/models/frameworks/framework/filtering.rb new file mode 100644 index 000000000..6962383a6 --- /dev/null +++ b/app/models/frameworks/framework/filtering.rb @@ -0,0 +1,65 @@ +class Frameworks::Framework::Filtering + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + attribute :scoped_frameworks, default: -> { Frameworks::Framework } + attribute :status, default: -> { [] } + attribute :provider, default: -> { [] } + attribute :e_and_o_lead, default: -> { [] } + attribute :proc_ops_lead, default: -> { [] } + attribute :category, default: -> { [] } + attribute :sort_by + attribute :sort_order + + def results + frameworks = scoped_frameworks + + filters.each { |filter| frameworks = filter.filter(frameworks) } + + frameworks.sorted_by(sort_by:, sort_order:) + end + + def available_category_options + Support::Category.order("title ASC").where(id: Frameworks::Framework.pluck(:support_category_id)) + .map { |category| [category.title, category.id] } + end + + def available_provider_filter_options + Frameworks::Provider.order("short_name ASC").pluck(:short_name, :id) + end + + def available_e_and_o_lead_options + Support::Agent.by_first_name.where(id: Frameworks::Framework.pluck(:e_and_o_lead_id)) + .map { |agent| [agent.full_name, agent.id] } + end + + def available_proc_ops_lead_options + Support::Agent.by_first_name.where(id: Frameworks::Framework.pluck(:proc_ops_lead_id)) + .map { |agent| [agent.full_name, agent.id] } + end + + def available_status_filter_options + Frameworks::Framework.statuses.map { |label, _id| [label.humanize, label] } + end + + def available_sort_options + Frameworks::Framework.available_sort_options + end + + def number_of_selected(field) + send(field.to_s.singularize).count(&:present?) + end + +private + + def filters + [ + Support::Concerns::ScopeFilter.new(status, scope: :by_status), + Support::Concerns::ScopeFilter.new(provider, scope: :by_provider), + Support::Concerns::ScopeFilter.new(e_and_o_lead, scope: :by_e_and_o_lead), + Support::Concerns::ScopeFilter.new(proc_ops_lead, scope: :by_proc_ops_lead), + Support::Concerns::ScopeFilter.new(category, scope: :by_category), + ] + end +end diff --git a/app/models/frameworks/framework/presentable.rb b/app/models/frameworks/framework/presentable.rb new file mode 100644 index 000000000..e5e8822f3 --- /dev/null +++ b/app/models/frameworks/framework/presentable.rb @@ -0,0 +1,47 @@ +module Frameworks::Framework::Presentable + extend ActiveSupport::Concern + + def provider_name + provider.name || provider.short_name + end + + def reference_and_short_name + [reference, short_name].compact.join(" - ") + end + + def provider_framework_owner_phone + provider_contact&.phone + end + + def provider_framework_owner_name + provider_contact&.name + end + + def provider_framework_owner_email + provider_contact&.email + end + + def category_name + support_category&.title + end + + def proc_ops_lead_name + proc_ops_lead&.full_name + end + + def e_and_o_lead_name + e_and_o_lead&.full_name + end + + def display_status + ApplicationController.render(partial: "frameworks/frameworks/framework_status", locals: { status: }) + end + + def display_dfe_start_date + dfe_start_date.strftime("%d/%m/%Y") if dfe_start_date.present? + end + + def display_dfe_end_date + dfe_end_date.strftime("%d/%m/%Y") if dfe_end_date.present? + end +end diff --git a/app/models/frameworks/framework/sortable.rb b/app/models/frameworks/framework/sortable.rb new file mode 100644 index 000000000..bfe56710c --- /dev/null +++ b/app/models/frameworks/framework/sortable.rb @@ -0,0 +1,35 @@ +module Frameworks::Framework::Sortable + extend ActiveSupport::Concern + + included do + scope :sort_by_updated, ->(direction = "descending") { order("updated_at #{safe_direction(direction)}") } + scope :sort_by_dfe_start_date, ->(direction = "descending") { order("dfe_start_date #{safe_direction(direction)}") } + scope :sort_by_dfe_end_date, ->(direction = "descending") { order("dfe_end_date #{safe_direction(direction)}") } + scope :sort_by_provider_start_date, ->(direction = "descending") { order("provider_start_date #{safe_direction(direction)}") } + scope :sort_by_provider_end_date, ->(direction = "descending") { order("provider_end_date #{safe_direction(direction)}") } + scope :sort_by_reference, ->(direction = "descending") { order("reference #{safe_direction(direction)}") } + end + + class_methods do + def sorted_by(sort_by:, sort_order:) + return sort_by_updated("descending") unless sort_by.present? && sort_order.present? + + public_send("sort_by_#{sort_by}", sort_order) + end + + def available_sort_options + [ + ["Updated", "updated"], + ["Reference", "reference"], + ["DfE start date", "dfe_start_date"], + ["DfE end date", "dfe_end_date"], + ["Provider start date", "provider_start_date"], + ["Provider end date", "provider_end_date"], + ] + end + + def safe_direction(direction) + direction == "descending" ? "DESC" : "ASC" + end + end +end diff --git a/app/models/frameworks/framework/sourceable.rb b/app/models/frameworks/framework/sourceable.rb new file mode 100644 index 000000000..3dafc6a7d --- /dev/null +++ b/app/models/frameworks/framework/sourceable.rb @@ -0,0 +1,9 @@ +module Frameworks::Framework::Sourceable + extend ActiveSupport::Concern + + included do + enum source: { + spreadsheet_import: 0, + } + end +end diff --git a/app/models/frameworks/framework/spreadsheet_importable.rb b/app/models/frameworks/framework/spreadsheet_importable.rb new file mode 100644 index 000000000..7b674b5dd --- /dev/null +++ b/app/models/frameworks/framework/spreadsheet_importable.rb @@ -0,0 +1,25 @@ +module Frameworks::Framework::SpreadsheetImportable + extend ActiveSupport::Concern + + class_methods do + def import_from_spreadsheet(file) + frameworks_list = SimpleXlsxReader.open(file) + .sheets + .find { |potential_sheet| potential_sheet.name == "Recommended Framework List" } + + populated_rows = frameworks_list.rows + .each(headers: true) + .reject { |row| row[nil].nil? } + + populated_rows + .each { |row| import_from_spreadsheet_row(RowMapping.new(row)) } + end + + def import_from_spreadsheet_row(row_mapping) + framework = find_or_initialize_by(provider: row_mapping.provider, reference: row_mapping.reference) + framework.source = :spreadsheet_import + framework.attributes = row_mapping.attributes + framework.save!(context: :spreadsheet_import) + end + end +end diff --git a/app/models/frameworks/framework/spreadsheet_importable/row_mapping.rb b/app/models/frameworks/framework/spreadsheet_importable/row_mapping.rb new file mode 100644 index 000000000..b85027e62 --- /dev/null +++ b/app/models/frameworks/framework/spreadsheet_importable/row_mapping.rb @@ -0,0 +1,68 @@ +class Frameworks::Framework::SpreadsheetImportable::RowMapping + attr_reader :row + + def initialize(row) + @row = row + end + + def provider = Frameworks::Provider.find_or_create_by!(short_name: row["FWK Provider"]) + + def reference = row["Framework Reference "] + + def attributes + { + provider:, + provider_contact:, + status:, + name:, + url:, + reference:, + dfe_start_date:, + dfe_end_date:, + sct_framework_provider_lead:, + proc_ops_lead:, + e_and_o_lead:, + } + end + +private + + def name = row["Framework Name"] + + def provider_contact + return nil if [ + String(row["PBO Framework Owner"]), + String(row["PBO Framework Email"]), + ].any? { |value| value.strip == "-" || value.empty? } + + Frameworks::ProviderContact.find_or_create_by!( + provider:, + name: row["PBO Framework Owner"], + email: row["PBO Framework Email"], + ) + end + + def status + recommended = String(row["Presently Recommended Y/N"]).downcase.inquiry + + if recommended.y? + :dfe_approved + elsif recommended.n? + :not_approved + elsif recommended.x? + :evaluating + end + end + + def url = (row["Framework Name"].url if row["Framework Name"].respond_to?(:url)) + + def dfe_start_date = row["Date when added on FaF"] + + def dfe_end_date = row["Contracted End Date on FaF as at 20.1.2023"] + + def sct_framework_provider_lead = row["Current PSBO Lead (Interim - To Be Confirmed)"] + + def proc_ops_lead = Support::Agent.find_or_create_by_full_name(row["Proc Ops Lead"]) + + def e_and_o_lead = Support::Agent.find_or_create_by_full_name(row["Engagement and Outreach Lead"]) +end diff --git a/app/models/frameworks/framework/status_changeable.rb b/app/models/frameworks/framework/status_changeable.rb index ea5ceab79..b268e5537 100644 --- a/app/models/frameworks/framework/status_changeable.rb +++ b/app/models/frameworks/framework/status_changeable.rb @@ -4,9 +4,10 @@ module Frameworks::Framework::StatusChangeable included do enum status: { pending_evaluation: 0, - not_approved: 1, - dfe_approved: 2, - cab_approved: 3, + evaluating: 1, + not_approved: 2, + dfe_approved: 3, + cab_approved: 4, } end end diff --git a/app/models/frameworks/provider.rb b/app/models/frameworks/provider.rb index f492d2bd0..345785986 100644 --- a/app/models/frameworks/provider.rb +++ b/app/models/frameworks/provider.rb @@ -1,2 +1,3 @@ class Frameworks::Provider < ApplicationRecord + include Frameworks::ActivityLoggable end diff --git a/app/models/frameworks/provider_contact.rb b/app/models/frameworks/provider_contact.rb new file mode 100644 index 000000000..1202e97ab --- /dev/null +++ b/app/models/frameworks/provider_contact.rb @@ -0,0 +1,7 @@ +class Frameworks::ProviderContact < ApplicationRecord + include Frameworks::ActivityLoggable + include ActivityLogPresentable + + belongs_to :provider + has_many :frameworks +end diff --git a/app/models/frameworks/provider_contact/activity_log_presentable.rb b/app/models/frameworks/provider_contact/activity_log_presentable.rb new file mode 100644 index 000000000..5f611136b --- /dev/null +++ b/app/models/frameworks/provider_contact/activity_log_presentable.rb @@ -0,0 +1,7 @@ +module Frameworks::ProviderContact::ActivityLogPresentable + extend ActiveSupport::Concern + + def activity_log_display_name + "#{name} (#{email})" + end +end diff --git a/app/models/support/agent.rb b/app/models/support/agent.rb index 8794b6ff0..379501bae 100644 --- a/app/models/support/agent.rb +++ b/app/models/support/agent.rb @@ -15,6 +15,7 @@ class Agent < ApplicationRecord internal: "Digital Team Staff Member", analyst: "Data Analyst", framework_evaluator: "Framework Evaluator", + framework_evaluator_admin: "Framework Evaluator Admin", }.freeze has_many :cases, class_name: "Support::Case" @@ -33,6 +34,14 @@ class Agent < ApplicationRecord caseworkers.where(sql, q: "#{query}%").limit(30) } + def self.find_or_create_by_full_name(full_name) + first_name, last_name = String(full_name).split(" ") + + return nil if first_name.blank? || last_name.blank? + + find_or_create_by!(first_name:, last_name:) + end + def self.find_or_create_by_user(user) agent = find_by(dsi_uid: user.dfe_sign_in_uid) diff --git a/app/models/support/concerns/scope_filter.rb b/app/models/support/concerns/scope_filter.rb index 4b4cf6f79..0b882fb35 100644 --- a/app/models/support/concerns/scope_filter.rb +++ b/app/models/support/concerns/scope_filter.rb @@ -6,11 +6,11 @@ def initialize(value, scope:, mapping: {}, multiple: true) @multiple = multiple end - def filter(cases) - return cases if selected_all? || !entered? - return cases.send("#{scope}_unspecified") if selected_unspecified? + def filter(records) + return records if selected_all? || !entered? + return records.send("#{scope}_unspecified") if selected_unspecified? - cases.send(scope, multiple ? values : values.first) + records.send(scope, multiple ? values : values.first) end def selected?(*checked_for_values) diff --git a/app/policies/cms_portal_policy.rb b/app/policies/cms_portal_policy.rb index 21b86e032..76c0890b5 100644 --- a/app/policies/cms_portal_policy.rb +++ b/app/policies/cms_portal_policy.rb @@ -9,14 +9,19 @@ def access_legacy_admin? = allow_any_of(%w[global_admin internal analyst]) def access_statistics? = allow_any_of(%w[global_admin internal procops_admin procops analyst]) def access_proc_ops_portal? = allow_any_of(%w[global_admin internal procops_admin procops]) def access_e_and_o_portal? = allow_any_of(%w[global_admin internal e_and_o_admin e_and_o]) - def access_admin_settings? = allow_any_of(%w[global_admin procops_admin e_and_o_admin]) + def access_admin_settings? = allow_any_of(%w[global_admin procops_admin e_and_o_admin framework_evaluator_admin]) def access_establishment_search? = access_proc_ops_portal? || access_e_and_o_portal? - def access_frameworks_portal? = allow_any_of(%w[global_admin framework_evaluator]) + def access_frameworks_portal? = allow_any_of(%w[global_admin framework_evaluator_admin framework_evaluator]) # Agent management def manage_agents? = allow_any_of(%w[global_admin procops_admin e_and_o_admin]) + # Frameworks management + + def manage_frameworks_register_upload? = allow_any_of(%w[global_admin framework_evaluator_admin]) + def manage_frameworks_activity_log? = allow_any_of(%w[global_admin framework_evaluator_admin]) + # Role management def grantable_roles = @grantable_roles ||= Support::Agent::ROLES.select { |role, _label| send("grant_#{role}_role?") } @@ -28,7 +33,8 @@ def grant_e_and_o_admin_role? = allow_any_of(%w[global_admin e_and_o_admin]) def grant_e_and_o_role? = allow_any_of(%w[global_admin e_and_o_admin]) def grant_internal_role? = allow_any_of(%w[global_admin]) def grant_analyst_role? = allow_any_of(%w[global_admin]) - def grant_framework_evaluator_role? = allow_any_of(%w[global_admin framework_evaluator]) + def grant_framework_evaluator_admin_role? = allow_any_of(%w[global_admin]) + def grant_framework_evaluator_role? = allow_any_of(%w[global_admin framework_evaluator_admin]) private diff --git a/app/services/support/sync_frameworks.rb b/app/services/support/sync_frameworks.rb index 28473aaff..e95ea7c3e 100644 --- a/app/services/support/sync_frameworks.rb +++ b/app/services/support/sync_frameworks.rb @@ -8,11 +8,7 @@ def initialize(endpoint: ENV["FAF_FRAMEWORK_ENDPOINT"]) def call fetch_frameworks - - if @frameworks.present? - upsert_frameworks - import_to_framework_register - end + upsert_frameworks if @frameworks.present? end private @@ -40,20 +36,6 @@ def upsert_frameworks ) end - def import_to_framework_register - @frameworks.each do |raw_framework| - framework = OpenStruct.new( - name: raw_framework["title"], - provider_reference: raw_framework["ref"], - provider_name: raw_framework["provider"].try(:[], "title"), - provider_url: raw_framework["url"], - ends_at: raw_framework["expiry"], - description: raw_framework["descr"], - ) - Frameworks::Framework.import_from_faf(framework) - end - end - def prepare_frameworks(frameworks) frameworks.select { |framework| framework["expiry"].present? }.map do |framework| { diff --git a/app/views/frameworks/activity_log_items/_activity_log_item.html.erb b/app/views/frameworks/activity_log_items/_activity_log_item.html.erb new file mode 100644 index 000000000..ecee3c40f --- /dev/null +++ b/app/views/frameworks/activity_log_items/_activity_log_item.html.erb @@ -0,0 +1,4 @@ +<%= render "frameworks/activity_log_items/activity/#{activity_log_item.activity_type.demodulize.underscore}", + activity_log_item:, + activity: activity_log_item.activity, + subject: activity_log_item.subject %> diff --git a/app/views/frameworks/activity_log_items/_list.html.erb b/app/views/frameworks/activity_log_items/_list.html.erb new file mode 100644 index 000000000..ed43c4f2a --- /dev/null +++ b/app/views/frameworks/activity_log_items/_list.html.erb @@ -0,0 +1,18 @@ +
Date | +Person | +Description | +
---|---|---|
<%= activity_log_item.display_created_at %> | +<%= activity_log_item.display_actor %> | +<%= render activity_log_item %> | +
<%= changes.field_name %> | +<%= changes.display %> | +
---|
Loading...
+Try clearing or changing your filters.
+ <% end %> + + <%= render "components/pagination", records: @frameworks, page_param_name: :frameworks_page, pagination_params: { anchor: "frameworks-register" } %> +<%= @framework.display_status %>
+ +