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 @@ + + + + + + + + + + <% activity_log_items.each do |activity_log_item| %> + + + + + + <% end %> + +
DatePersonDescription
<%= activity_log_item.display_created_at %><%= activity_log_item.display_actor %><%= render activity_log_item %>
diff --git a/app/views/frameworks/activity_log_items/activity/_version.html.erb b/app/views/frameworks/activity_log_items/activity/_version.html.erb new file mode 100644 index 000000000..3d59da708 --- /dev/null +++ b/app/views/frameworks/activity_log_items/activity/_version.html.erb @@ -0,0 +1,17 @@ +<%= activity.event.humanize %>d <%= activity.item.activity_log_display_type %> <%= activity.item.activity_log_display_name %> + +<%= link_to "[+]", "#", title: "See changes", class: "govuk-link", "data-component" => "toggle-panel-visibility", "data-panel" => activity.id %> + +
+ + + <% activity.presentable_chages.each do |changes| %> + + + + + <% end %> + +
<%= changes.field_name %><%= changes.display %>
+
+ diff --git a/app/views/frameworks/dashboards/index.html.erb b/app/views/frameworks/dashboards/index.html.erb index 816bbc57d..e8c58113f 100644 --- a/app/views/frameworks/dashboards/index.html.erb +++ b/app/views/frameworks/dashboards/index.html.erb @@ -1 +1,15 @@ -

Frameworks Portal

+<%= content_for :title, "GHBS | Frameworks" %> + +
+ + + <%= turbo_frame_tag "frameworks-register-frame", src: frameworks_frameworks_path(**request.params.except(:controller, :action)) do %> +
+

Loading...

+
+ <% end %> +
diff --git a/app/views/frameworks/frameworks/_framework.html.erb b/app/views/frameworks/frameworks/_framework.html.erb new file mode 100644 index 000000000..4f7d1fdc3 --- /dev/null +++ b/app/views/frameworks/frameworks/_framework.html.erb @@ -0,0 +1,54 @@ +
+
+
+
+
Framework Ref
+
<%= link_to framework.reference, frameworks_framework_path(framework, back_to: current_url_b64(:frameworks_register)), class: "govuk-link", "data-turbo" => false %>
+
+ +
+
Framework Name
+
<%= framework.name %>
+
+ +
+
Framework Provider
+
<%= framework.provider_name %>
+
+ +
+
Category
+
<%= framework.category_name %>
+
+
+
+ +
+
+
+
Is approved?
+
<%= framework.display_status %>
+
+ +
+
DfE Start date
+
<%= framework.display_dfe_start_date %>
+
+ +
+
DfE End date
+
<%= framework.display_dfe_end_date %>
+
+ +
+
Proc Ops Lead
+
<%= framework.proc_ops_lead_name %>
+
+ +
+
E&O Lead
+
<%= framework.e_and_o_lead_name %>
+
+
+
+
diff --git a/app/views/frameworks/frameworks/_framework_status.html.erb b/app/views/frameworks/frameworks/_framework_status.html.erb new file mode 100644 index 000000000..f71d64c24 --- /dev/null +++ b/app/views/frameworks/frameworks/_framework_status.html.erb @@ -0,0 +1,10 @@ +<%# Required local: either literal status or a framework record %> +<% the_status = (defined?(status) ? status : framework.status).inquiry %> + + + <%= "govuk-tag--grey" if the_status.not_approved? %> + <%= "govuk-tag--purple" if the_status.evaluating? %> +"> + <%= the_status.humanize %> + diff --git a/app/views/frameworks/frameworks/_index_filters.html.erb b/app/views/frameworks/frameworks/_index_filters.html.erb new file mode 100644 index 000000000..c880dc074 --- /dev/null +++ b/app/views/frameworks/frameworks/_index_filters.html.erb @@ -0,0 +1,58 @@ +
+

<%= I18n.t("support.case.label.filters") %>

+ + <%= form_with model: @filtering, scope: :frameworks_filter, method: :get, url: frameworks_frameworks_path, html: { "data-controller" => "case-filters" } do |form| %> + <%= link_to I18n.t("support.case.filter.buttons.clear"), frameworks_frameworks_path, class: "govuk-button govuk-button--secondary", role: "button" %> + + <%= form.govuk_select :sort_by, + options_for_select(@filtering.available_sort_options, @filtering.sort_by), + label: { text: I18n.t("support.case.label.sort_by"), size: "s" } %> + + <%= form.govuk_collection_radio_buttons :sort_order, + available_sort_orders, + :id, + :title, + legend: nil, + small: true %> + + <%= expander title: "Status", subtitle: "#{@filtering.number_of_selected(:statuses)} selected", expanded: true do %> + <%= form.govuk_check_boxes_fieldset :status, legend: nil, small: true, form_group: { class: "govuk-!-margin-bottom-0" } do %> + <% @filtering.available_status_filter_options.each do |status| %> + <%= form.govuk_check_box :status, status.last, exclusive: false, label: { text: status.first } %> + <% end %> + <% end %> + <% end %> + + <%= expander title: "Category", subtitle: "#{@filtering.number_of_selected(:categories)} selected", expanded: false do %> + <%= form.govuk_check_boxes_fieldset :category, legend: nil, small: true, form_group: { class: "govuk-!-margin-bottom-0" } do %> + <% @filtering.available_category_options.each do |option| %> + <%= form.govuk_check_box :category, option.last, exclusive: false, label: { text: option.first } %> + <% end %> + <% end %> + <% end %> + + <%= expander title: "Provider", subtitle: "#{@filtering.number_of_selected(:providers)} selected", expanded: false do %> + <%= form.govuk_check_boxes_fieldset :provider, legend: nil, small: true, form_group: { class: "govuk-!-margin-bottom-0" } do %> + <% @filtering.available_provider_filter_options.each do |option| %> + <%= form.govuk_check_box :provider, option.last, exclusive: false, label: { text: option.first } %> + <% end %> + <% end %> + <% end %> + + <%= expander title: "Proc-Ops Lead", subtitle: "#{@filtering.number_of_selected(:proc_ops_leads)} selected", expanded: false do %> + <%= form.govuk_check_boxes_fieldset :proc_ops_lead, legend: nil, small: true, form_group: { class: "govuk-!-margin-bottom-0" } do %> + <% @filtering.available_proc_ops_lead_options.each do |option| %> + <%= form.govuk_check_box :proc_ops_lead, option.last, exclusive: false, label: { text: option.first } %> + <% end %> + <% end %> + <% end %> + + <%= expander title: "E&O Lead", subtitle: "#{@filtering.number_of_selected(:e_and_o_leads)} selected", expanded: false do %> + <%= form.govuk_check_boxes_fieldset :e_and_o_lead, legend: nil, small: true, form_group: { class: "govuk-!-margin-bottom-0" } do %> + <% @filtering.available_e_and_o_lead_options.each do |option| %> + <%= form.govuk_check_box :e_and_o_lead, option.last, exclusive: false, label: { text: option.first } %> + <% end %> + <% end %> + <% end %> + <% end %> +
diff --git a/app/views/frameworks/frameworks/index.html.erb b/app/views/frameworks/frameworks/index.html.erb new file mode 100644 index 000000000..94384c529 --- /dev/null +++ b/app/views/frameworks/frameworks/index.html.erb @@ -0,0 +1,20 @@ +<%= turbo_frame_tag "frameworks-register-frame" do %> +
+
+
+ <%= render "index_filters" %> +
+ +
+ <%= render @frameworks %> + + <% unless @frameworks.any? %> +

No frameworks found

+

Try clearing or changing your filters.

+ <% end %> + + <%= render "components/pagination", records: @frameworks, page_param_name: :frameworks_page, pagination_params: { anchor: "frameworks-register" } %> +
+
+
+<% end %> diff --git a/app/views/frameworks/frameworks/new.html.erb b/app/views/frameworks/frameworks/new.html.erb new file mode 100644 index 000000000..e69de29bb diff --git a/app/views/frameworks/frameworks/show.html.erb b/app/views/frameworks/frameworks/show.html.erb new file mode 100644 index 000000000..5ad3707fe --- /dev/null +++ b/app/views/frameworks/frameworks/show.html.erb @@ -0,0 +1,38 @@ +<%= content_for :title, "GHBS | Frameworks | #{@framework.name}" %> + +<%= @framework.reference_and_short_name %> +

<%= @framework.name %>

+ +

<%= @framework.display_status %>

+ +
+ + + <%= turbo_frame_tag "framework-details-frame" do %> +
+ <%= render "frameworks/frameworks/show/framework_details" %> +
+ <% end %> + + <%= turbo_frame_tag "framework-provider-frame" do %> +
+ <%= render "frameworks/frameworks/show/framework_provider" %> +
+ <% end %> + + <%= turbo_frame_tag "framework-activity-frame" do %> +
+ <%= render "frameworks/frameworks/show/framework_activity" %> +
+ <% end %> +
diff --git a/app/views/frameworks/frameworks/show/_framework_activity.html.erb b/app/views/frameworks/frameworks/show/_framework_activity.html.erb new file mode 100644 index 000000000..820c7082d --- /dev/null +++ b/app/views/frameworks/frameworks/show/_framework_activity.html.erb @@ -0,0 +1,2 @@ +<%= render "frameworks/activity_log_items/list", activity_log_items: @activity_log_items %> +<%= render "components/pagination", records: @activity_log_items, page_param_name: 'activities_page', pagination_params: { anchor: "framework-activity" } %> diff --git a/app/views/frameworks/frameworks/show/_framework_details.html.erb b/app/views/frameworks/frameworks/show/_framework_details.html.erb new file mode 100644 index 000000000..1ca41c151 --- /dev/null +++ b/app/views/frameworks/frameworks/show/_framework_details.html.erb @@ -0,0 +1,47 @@ + +
+
+
Url
+
<%= link_to @framework.url, @framework.url, target: "_blank", class: "govuk-link" if @framework.url.present? %>
+
+ +
+
Category
+
<%= @framework.category_name %>
+
+ +
+
SCT Framework Owner
+
<%= @framework.sct_framework_owner %>
+
+ +
+
Procurement Operations Lead
+
<%= @framework.proc_ops_lead_name %>
+
+ +
+
Engagement and Outreach Lead
+
<%= @framework.e_and_o_lead_name %>
+
+ +
+
Provider start date
+
<%= @framework.provider_start_date %>
+
+ +
+
Provider End Date
+
<%= @framework.provider_end_date %>
+
+ +
+
DfE Start Date
+
<%= @framework.display_dfe_start_date %>
+
+ +
+
DfE End Date
+
<%= @framework.display_dfe_end_date %>
+
+
diff --git a/app/views/frameworks/frameworks/show/_framework_provider.html.erb b/app/views/frameworks/frameworks/show/_framework_provider.html.erb new file mode 100644 index 000000000..1a1d72b18 --- /dev/null +++ b/app/views/frameworks/frameworks/show/_framework_provider.html.erb @@ -0,0 +1,26 @@ +
+
+
Provider
+
<%= @framework.provider_name %>
+
+ +
+
SCT Provider Lead
+
<%= @framework.sct_framework_provider_lead %>
+
+ +
+
Framework Owner
+
<%= @framework.provider_framework_owner_name %>
+
+ +
+
Email
+
<%= @framework.provider_framework_owner_email %>
+
+ +
+
Phone
+
<%= @framework.provider_framework_owner_phone %>
+
+
diff --git a/app/views/frameworks/management/activity_logs/show.html.erb b/app/views/frameworks/management/activity_logs/show.html.erb new file mode 100644 index 000000000..d15c0ec4f --- /dev/null +++ b/app/views/frameworks/management/activity_logs/show.html.erb @@ -0,0 +1,4 @@ +

Activity Log

+ +<%= render "frameworks/activity_log_items/list", activity_log_items: @activity_log_items %> +<%= render "components/pagination", records: @activity_log_items %> diff --git a/app/views/frameworks/management/management/index.html.erb b/app/views/frameworks/management/management/index.html.erb new file mode 100644 index 000000000..7b7523fda --- /dev/null +++ b/app/views/frameworks/management/management/index.html.erb @@ -0,0 +1,11 @@ +

Frameworks Portal Management

+ + diff --git a/app/views/frameworks/management/register_uploads/new.html.erb b/app/views/frameworks/management/register_uploads/new.html.erb new file mode 100644 index 000000000..fe207c39d --- /dev/null +++ b/app/views/frameworks/management/register_uploads/new.html.erb @@ -0,0 +1,8 @@ +

Upload Frameworks Register xlsx

+ +<%= form_with url: frameworks_management_register_upload_path, method: :post, scope: :register_upload do |f| %> + <%= f.govuk_file_field :spreadsheet, + label: { text: 'Register Spreadsheet (.xlsx)' } %> + + <%= f.govuk_submit "Upload" %> +<% end %> diff --git a/app/views/layouts/_navigation_links.html.erb b/app/views/layouts/_navigation_links.html.erb index 0bc5efa87..1a00f5afe 100644 --- a/app/views/layouts/_navigation_links.html.erb +++ b/app/views/layouts/_navigation_links.html.erb @@ -49,6 +49,16 @@ <% end %> <% if frameworks_portal? %> + + + <% if policy(:cms_portal).access_admin_settings? %> + + <% end %> + <% if policy(:cms_portal).access_proc_ops_portal? %>