diff --git a/.env.example b/.env.example index 707a4d808..920f86696 100644 --- a/.env.example +++ b/.env.example @@ -64,4 +64,6 @@ AWS_SECRET_ACCESS_KEY= AWS_REGION= BUCKET_NAME= +# FaF FAF_WEBHOOK_SECRET= +FAF_FRAMEWORK_ENDPOINT= diff --git a/.env.test b/.env.test index a6f61b94a..536432414 100644 --- a/.env.test +++ b/.env.test @@ -46,3 +46,6 @@ MS_GRAPH_CLIENT_SECRET="z" MS_GRAPH_SHARED_MAILBOX_USER_ID="c" MS_GRAPH_SHARED_MAILBOX_NAME="mailbox" MS_GRAPH_SHARED_MAILBOX_ADDRESS="test@mailbox.com" + +# FaF +FAF_FRAMEWORK_ENDPOINT=http://faf.test diff --git a/.github/workflows/ci-full-pipeline.yml b/.github/workflows/ci-full-pipeline.yml index b4faf542e..51bfba32e 100644 --- a/.github/workflows/ci-full-pipeline.yml +++ b/.github/workflows/ci-full-pipeline.yml @@ -139,6 +139,7 @@ jobs: -e PROC_OPS_TEAM="DSI Caseworkers" \ -e QUALTRICS_SURVEY_URL=https://dferesearch.fra1.qualtrics.com \ -e SUPPORT_EMAIL=email@example.gov.uk \ + -e FAF_FRAMEWORK_ENDPOINT=http://faf.test \ -e CI_NODE_TOTAL=${{ matrix.ci_node_total }} \ -e CI_NODE_INDEX=${{ matrix.ci_node_index }} \ -e CI=1 \ diff --git a/app/controllers/api/find_a_framework/frameworks_controller.rb b/app/controllers/api/find_a_framework/frameworks_controller.rb deleted file mode 100644 index 9bc5b8f1e..000000000 --- a/app/controllers/api/find_a_framework/frameworks_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class Api::FindAFramework::FrameworksController < Api::BaseController - def changed - added_frameworks = [] - framework_params.each do |framework| - f = Support::Framework.upsert( - { - name: framework[:title], - supplier: framework[:provider][:initials], - category: framework[:cat][:title], - expires_at: framework[:expiry], - }, - unique_by: %i[name supplier], - returning: %w[name supplier], - ) - added_frameworks << f.first - end - - render json: { status: "OK" }, status: :ok - Rollbar.info("Processed webhook event for FaF framework", added_frameworks) - end - -private - - def framework_params - params.permit(_json: [:title, { provider: [:initials] }, { cat: [:title] }, :expiry])[:_json] - end -end diff --git a/app/controllers/support/management/sync_frameworks_controller.rb b/app/controllers/support/management/sync_frameworks_controller.rb new file mode 100644 index 000000000..d0156d463 --- /dev/null +++ b/app/controllers/support/management/sync_frameworks_controller.rb @@ -0,0 +1,15 @@ +module Support + module Management + class SyncFrameworksController < BaseController + after_action -> { flash.discard }, only: :create + + def index; end + + def create + Support::SyncFrameworksJob.perform_later + flash[:notice] = "Task triggered" + render :index + end + end + end +end diff --git a/app/jobs/support/sync_frameworks_job.rb b/app/jobs/support/sync_frameworks_job.rb new file mode 100644 index 000000000..26c19087f --- /dev/null +++ b/app/jobs/support/sync_frameworks_job.rb @@ -0,0 +1,9 @@ +module Support + class SyncFrameworksJob < ApplicationJob + queue_as :support + + def perform + Support::SyncFrameworks.new.call + end + end +end diff --git a/app/services/support/sync_frameworks.rb b/app/services/support/sync_frameworks.rb new file mode 100644 index 000000000..76ad3c89c --- /dev/null +++ b/app/services/support/sync_frameworks.rb @@ -0,0 +1,49 @@ +module Support + class SyncFrameworks + def initialize(endpoint: ENV["FAF_FRAMEWORK_ENDPOINT"]) + @endpoint = endpoint + end + + def call + fetch_frameworks + upsert_frameworks if @frameworks.present? + end + + private + + def fetch_frameworks + uri = URI.parse(@endpoint) + response = + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| + request = Net::HTTP::Get.new(uri) + http.request(request) + end + + if response.code == "200" + @frameworks = JSON.parse(response.body) + else + Rollbar.error("Could not fetch frameworks", uri: @endpoint, status: response.code) + end + end + + def upsert_frameworks + prepared_frameworks = prepare_frameworks(@frameworks) + Support::Framework.upsert_all( + prepared_frameworks, + unique_by: %i[ref], + ) + end + + def prepare_frameworks(frameworks) + frameworks.select { |framework| framework["expiry"].present? }.map do |framework| + { + name: framework["title"], + ref: framework["ref"], + supplier: framework["provider"].try(:[], "initials"), + category: framework["cat"].try(:[], "title"), + expires_at: Date.parse(framework["expiry"]), + } + end + end + end +end diff --git a/app/views/support/management/base/index.html.erb b/app/views/support/management/base/index.html.erb index 9a6edc6e2..b19d72ea7 100644 --- a/app/views/support/management/base/index.html.erb +++ b/app/views/support/management/base/index.html.erb @@ -26,6 +26,7 @@

<%= I18n.t("support.management.base.tasks") %>

diff --git a/app/views/support/management/sync_frameworks/index.html.erb b/app/views/support/management/sync_frameworks/index.html.erb new file mode 100644 index 000000000..b023e1af8 --- /dev/null +++ b/app/views/support/management/sync_frameworks/index.html.erb @@ -0,0 +1,20 @@ +<%= content_for :title, "#{I18n.t("support.management.base.header")} | #{I18n.t("support.management.sync_frameworks.header")}" %> + +
+
    +
  1. + <%= link_to I18n.t("support.management.base.header"), support_management_path, class: "govuk-breadcrumbs__link" %> +
  2. +
  3. + <%= link_to I18n.t("support.management.sync_frameworks.header"), support_management_email_templates_path, class: "govuk-breadcrumbs__link" %> +
  4. +
+
+ +

<%= I18n.t("support.management.sync_frameworks.header") %>

+ +<% I18n.t("support.management.sync_frameworks.body").each do |p| %> +

<%= p.html_safe %>

+<% end %> + +<%= button_to I18n.t("support.management.sync_frameworks.sync"), support_management_sync_frameworks_path, class: "govuk-button" %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 9db5a21d1..e2c67a59b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1465,6 +1465,12 @@ en: notice: Updates to your template have been saved destroy: notice: Your template has been deleted + sync_frameworks: + header: Synchronise frameworks + body: + - Frameworks are fetched from Find a Framework and saved automatically every midnight and midday. + - Click Synchronise to trigger the task manually. + sync: Synchronise procurement_details: edit: ended_at: diff --git a/config/routes.rb b/config/routes.rb index 962bc339d..92c8ff0f5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -265,6 +265,7 @@ end resource :category_detection, only: %i[new create] resources :all_cases_surveys, only: %i[index create] + resources :sync_frameworks, only: %i[index create] end end diff --git a/config/schedule.yml b/config/schedule.yml index 9c9e00cec..72b9cd8c5 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -46,3 +46,8 @@ refresh_establishment_groups: cron: 0 4 1 * * # At 04:00 on day-of-month 1. class: Support::RefreshEstablishmentGroupsJob description: Update internal EstablishmentGroup records using public data from GIAS. + +sync_frameworks: + cron: "0 0,12 * * *" # every midnight and midday + class: Support::SyncFrameworksJob + description: Fetch and persist frameworks from FaF diff --git a/db/migrate/20230707133414_add_ref_to_support_frameworks.rb b/db/migrate/20230707133414_add_ref_to_support_frameworks.rb new file mode 100644 index 000000000..d6193d4b6 --- /dev/null +++ b/db/migrate/20230707133414_add_ref_to_support_frameworks.rb @@ -0,0 +1,6 @@ +class AddRefToSupportFrameworks < ActiveRecord::Migration[7.0] + def change + add_column :support_frameworks, :ref, :string + add_index :support_frameworks, :ref, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index b741bc3c2..f34967c50 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_06_29_150515) do +ActiveRecord::Schema[7.0].define(version: 2023_07_07_133414) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_trgm" @@ -559,7 +559,9 @@ t.date "expires_at" t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.datetime "updated_at", default: -> { "CURRENT_TIMESTAMP" }, null: false + t.string "ref" t.index ["name", "supplier"], name: "index_support_frameworks_on_name_and_supplier", unique: true + t.index ["ref"], name: "index_support_frameworks_on_ref", unique: true end create_table "support_group_types", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/lib/tasks/data_migrations.rake b/lib/tasks/data_migrations.rake index 4cae5da62..2ed249e9d 100644 --- a/lib/tasks/data_migrations.rake +++ b/lib/tasks/data_migrations.rake @@ -92,4 +92,30 @@ namespace :data_migrations do agent.save! end end + + desc "Populate Support::Framework refs" + task populate_framework_refs: :environment do + uri = URI.parse(ENV["FAF_FRAMEWORK_ENDPOINT"]) + response = + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| + request = Net::HTTP::Get.new(uri) + http.request(request) + end + + if response.code == "200" + frameworks = JSON.parse(response.body) + prepared_frameworks = frameworks.map do |framework| + { + name: framework["title"], + supplier: framework["provider"]["initials"], + ref: framework["ref"], + } + end + + Support::Framework.all.each do |framework| + match = prepared_frameworks.select { |p| p[:name] == framework.name && p[:supplier] == framework.supplier } + framework.update!(ref: match.first[:ref]) if match.present? + end + end + end end diff --git a/spec/factories/support/framework.rb b/spec/factories/support/framework.rb index e8656b0c1..93d9f17d9 100644 --- a/spec/factories/support/framework.rb +++ b/spec/factories/support/framework.rb @@ -4,5 +4,6 @@ supplier { "Test supplier" } category { "Test category" } expires_at { "2022-12-02" } + sequence(:ref) { |n| "test-#{n}" } end end diff --git a/spec/requests/self-serve/api/find_a_framework/webhook_upsert_framework_spec.rb b/spec/requests/self-serve/api/find_a_framework/webhook_upsert_framework_spec.rb deleted file mode 100644 index 6c3449919..000000000 --- a/spec/requests/self-serve/api/find_a_framework/webhook_upsert_framework_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -RSpec.describe "Webhook upserts frameworks", type: :request do - let(:webhook_payload) do - [ - { - "title" => "Framework title", - "provider" => { - "initials" => "PRVDR", - }, - "cat" => { - "title" => "Supplies", - }, - "expiry" => "2022-12-31", - }, - ] - end - - let(:rollbar_info) do - [ - { - "name" => "Framework title", - "supplier" => "PRVDR", - }, - ] - end - - around do |example| - ClimateControl.modify(FAF_WEBHOOK_SECRET: "foo") do - example.run - end - end - - it "creates a new framework" do - expect(Support::Framework.count).to be_zero - expect(Rollbar).to receive(:info) - .with("Processed webhook event for FaF framework", rollbar_info) - .and_call_original - - post "/api/find_a_framework/framework", - params: webhook_payload, - headers: { "Authorization": "Token foo" }, - as: :json - - expect(response).to have_http_status(:ok) - expect(Support::Framework.first.name).to eql "Framework title" - expect(Support::Framework.first.supplier).to eql "PRVDR" - end - - it "updates an existing framework" do - create(:support_framework, name: "Framework title", supplier: "PRVDR", category: "Old category", expires_at: "2022-01-01") - expect(Support::Framework.count).to eq 1 - - expect(Rollbar).to receive(:info) - .with("Processed webhook event for FaF framework", rollbar_info) - .and_call_original - - post "/api/find_a_framework/framework", - params: webhook_payload, - headers: { "Authorization": "Token foo" }, - as: :json - - expect(response).to have_http_status(:ok) - expect(Support::Framework.count).to eq 1 - expect(Support::Framework.first.name).to eql "Framework title" - expect(Support::Framework.first.supplier).to eql "PRVDR" - expect(Support::Framework.first.category).to eql "Supplies" - expect(Support::Framework.first.expires_at).to eql Date.parse("2022-12-31") - end -end diff --git a/spec/services/support/sync_frameworks_spec.rb b/spec/services/support/sync_frameworks_spec.rb new file mode 100644 index 000000000..333ff224f --- /dev/null +++ b/spec/services/support/sync_frameworks_spec.rb @@ -0,0 +1,84 @@ +require "rails_helper" + +describe Support::SyncFrameworks do + subject(:service) { described_class.new } + + let(:http_response) { nil } + + before do + http = double("http") + allow(Net::HTTP).to receive(:start).and_yield(http) + allow(http).to receive(:request).with(an_instance_of(Net::HTTP::Get)).and_return(http_response) + end + + describe "#call" do + context "when the request is not authorized" do + let(:http_response) { Net::HTTPUnauthorized.new(1.0, "401", "Unauthorized") } + + it "logs error to Rollbar" do + expect(Rollbar).to receive(:error).with("Could not fetch frameworks", uri: ENV["FAF_FRAMEWORK_ENDPOINT"], status: "401") + service.call + end + end + + context "when the request is authorized" do + let(:http_response) { Net::HTTPSuccess.new(1.0, "200", "OK") } + let(:body) do + [ + { + provider: { initials: "P1", title: "Provider 1" }, + cat: { ref: "energy", title: "Energy" }, + ref: "f1", + title: "Framework 1", + expiry: "2020-08-31T00:00:00.000Z", + }, + { + provider: { initials: "P2", title: "Provider 2" }, + cat: { ref: "catering", title: "Catering" }, + ref: "f2", + title: "Framework 2", + expiry: "2020-06-30T00:00:00.000Z", + }, + ].to_json + end + + before do + allow(http_response).to receive(:body).and_return(body) + end + + context "when there are frameworks to update" do + let!(:existing_framework) { create(:support_framework, name: "Framework 1", supplier: "P1", category: "Energy", ref: "f1", expires_at: Date.parse("2020-01-15")) } + + it "creates new frameworks and updates existing ones" do + expect { service.call }.to change(Support::Framework, :count).from(1).to(2) + .and(change { existing_framework.reload.expires_at }.from(Date.parse("2020-01-15")).to(Date.parse("2020-08-31"))) + .and(not_change { existing_framework.reload.name }) + .and(not_change { existing_framework.reload.supplier }) + .and(not_change { existing_framework.reload.category }) + + new_framework = Support::Framework.find_by(name: "Framework 2") + expect(new_framework.supplier).to eq("P2") + expect(new_framework.category).to eq("Catering") + expect(new_framework.expires_at).to eq(Date.parse("2020-06-30")) + end + end + + context "when there are no frameworks to update" do + let!(:existing_framework1) { create(:support_framework, name: "Framework 1", supplier: "P1", category: "Energy", ref: "f1", expires_at: Date.parse("2020-08-31")) } + let!(:existing_framework2) { create(:support_framework, name: "Framework 2", supplier: "P2", category: "Catering", ref: "f2", expires_at: Date.parse("2020-06-30")) } + + it "makes no changes to existing frameworks" do + expect { service.call }.to not_change(Support::Framework, :count) + .and(not_change { existing_framework1.reload.name }) + .and(not_change { existing_framework1.reload.supplier }) + .and(not_change { existing_framework1.reload.category }) + .and(not_change { existing_framework1.reload.expires_at }) + .and(not_change { existing_framework2.reload.name }) + .and(not_change { existing_framework2.reload.supplier }) + .and(not_change { existing_framework2.reload.category }) + .and(not_change { existing_framework2.reload.expires_at }) + end + end + end + end +end