diff --git a/app/services/export/activities_level_b.rb b/app/services/export/activities_level_b.rb index 9b213ecfb..c28fa7a3b 100644 --- a/app/services/export/activities_level_b.rb +++ b/app/services/export/activities_level_b.rb @@ -1,48 +1,91 @@ class Export::ActivitiesLevelB - HEADERS = [ - "Partner Organisation", - "Activity level", - "Parent activity", - "ODA or Non-ODA", - "Partner organisation identifier", - "RODA identifier", - "IATI identifier", - "Linked activity", - "Activity title", - "Activity description", - "Aims or objectives", - "Sector", - "Original commitment figure", - "Activity status", - "Planned start date", - "Planned end date", - "Actual start date", - "Actual end date", - "ISPF ODA partner countries", - "Benefitting countries", - "Benefitting region", - "Global Development Impact", - "Sustainable Development Goals", - "ISPF themes", - "Aid type", - "ODA eligibility", - "Publish to IATI?", - "Tags", - "Budget 2023-2024", - "Budget 2024-2025", - "Budget 2025-2026", - "Budget 2026-2027", - "Budget 2027-2028", - "Budget 2028-2029", - "Comments" + Field = Data.define(:name, :fund, :value) + + # A place to: + # - Name all the fields on the left + # - filter applicable fields on a per-fund basis in the middle + # - evaluate a row's values in the context of a fund's activity via a `value` Proc on the right + # - show all this on a line-by-line basis to avoid one tall export per fund, given + # there are many common fields + # standard:disable Layout/ExtraSpacing + FIELDS = [ + Field.new("Partner Organisation", "ALL", -> { activity.organisation.name }), + Field.new("Activity level", "ALL", -> { activity.level }), + Field.new("Parent activity", "ALL", -> { activity.source_fund.name }), + Field.new("ODA or Non-ODA", "ISPF", -> { activity.is_oda }), + Field.new("Partner organisation identifier", "ALL", -> { activity.partner_organisation_identifier }), + Field.new("RODA identifier", "ALL", -> { activity.roda_identifier }), + Field.new("IATI identifier", "ALL", -> { activity.transparency_identifier }), + Field.new("Linked activity", "ALL", -> { activity.linked_activity_identifier }), + Field.new("Activity title", "ALL", -> { activity.title }), + Field.new("Activity description", "ALL", -> { activity.description }), + Field.new("Aims or objectives", "ALL", -> { activity.objectives }), + Field.new("Sector", "ALL", -> { activity.sector }), + Field.new("Original commitment figure", "ALL", -> { activity.commitment&.value }), + Field.new("Activity status", "ALL", -> { activity.programme_status }), + Field.new("Planned start date", "ALL", -> { activity.planned_start_date }), + Field.new("Planned end date", "ALL", -> { activity.planned_end_date }), + Field.new("Actual start date", "ALL", -> { activity.actual_start_date }), + Field.new("Actual end date", "ALL", -> { activity.actual_end_date }), + Field.new("ISPF ODA partner countries", "ISPF", -> { activity.ispf_oda_partner_countries }), + Field.new("ISPF non-ODA partner countries", "ISPF", -> { activity.ispf_non_oda_partner_countries }), + Field.new("GCRF Strategic Area", "GCRF", -> { activity.gcrf_strategic_area }), + Field.new("GCRF Challenge Area", "GCRF", -> { activity.gcrf_challenge_area }), + Field.new("Newton Fund Country Partner Organisations", "NF", -> { activity.country_partner_organisations }), + Field.new("Newton Fund Pillar", "NF", -> { activity.fund_pillar }), + Field.new("Benefitting countries", "ALL", -> { activity.benefitting_countries }), + Field.new("Benefitting region", "ALL", -> { activity.benefitting_region }), + Field.new("Global Development Impact", "ALL", -> { activity.gdi }), + Field.new("Sustainable Development Goals", "ALL", -> { activity.sustainable_development_goals }), + Field.new("ISPF themes", "ISPF", -> { activity.ispf_themes }), + Field.new("Aid type", "ALL", -> { activity.aid_type }), + Field.new("ODA eligibility", "ALL", -> { activity.oda_eligibility }), + Field.new("Publish to IATI?", "ALL", -> { activity.publish_to_iati }), + Field.new("Tags", "ISPF", -> { activity.tags }), + Field.new("Budget 2023-2024", "ALL", -> { budgets_by_year[2023]&.value }), + Field.new("Budget 2024-2025", "ALL", -> { budgets_by_year[2024]&.value }), + Field.new("Budget 2025-2026", "ALL", -> { budgets_by_year[2025]&.value }), + Field.new("Budget 2026-2027", "ALL", -> { budgets_by_year[2026]&.value }), + Field.new("Budget 2027-2028", "ALL", -> { budgets_by_year[2027]&.value }), + Field.new("Budget 2028-2029", "ALL", -> { budgets_by_year[2028]&.value }), + Field.new("Comments", "ALL", -> { activity.comments.map(&:body).join("|") }) ].freeze + # standard:enable Layout/ExtraSpacing + + # Given a fund and an activity (or nil for a header row), return all the cell values in a row + # via #to_a + Row = Struct.new(:fund, :activity) do + def budgets_by_year + @budgets_by_year ||= activity.budgets.each_with_object({}) do |budget, years| + years[budget.financial_year.start_year] = BudgetPresenter.new(budget) + end + end + + def to_a + return applicable_fields.map(&:name) if header? + + applicable_fields.map do |field| + instance_exec(&field.value) # get the field's value from its Proc in the context of this Row + end + end + + private + + def header? + activity.nil? + end + + def applicable_fields + FIELDS.select { |field| field.fund.in? ["ALL", fund.short_name] } + end + end def initialize(fund:) @fund = fund end def headers - HEADERS + Row.new(fund: @fund, activity: nil).to_a end def filename @@ -51,58 +94,14 @@ def filename def rows activities.map do |activity| - row_for(activity) + Row.new(fund: @fund, activity: ActivityCsvPresenter.new(activity)).to_a end end private - def row_for(activity) - activity = ActivityCsvPresenter.new(activity) - budgets_by_year = activity.budgets.each_with_object({}) do |budget, hash| - hash[budget.financial_year.start_year] = BudgetPresenter.new(budget) - end - [ - activity.organisation.name, # "Partner Organisation", - activity.level, # "Activity level", - @fund.name, # "Parent activity", - activity.is_oda, # "ODA or Non-ODA", - activity.partner_organisation_identifier, # "Partner organisation identifier", - activity.roda_identifier, # "RODA identifier", e.g. GCRF-LCXHF - activity.transparency_identifier, # "IATI identifier", - activity.linked_activity_identifier, # "Linked activity", - activity.title, # "Activity title", - activity.description, # "Activity description", - activity.objectives, # "Aims or objectives", - activity.sector, # "Sector", - activity.commitment&.value, # "Original commitment figure", - activity.programme_status, # "Activity status", - activity.planned_start_date, # "Planned start date", - activity.planned_end_date, # "Planned end date", - activity.actual_start_date, # "Actual start date", - activity.actual_end_date, # "Actual end date", - activity.ispf_oda_partner_countries, # "ISPF ODA partner countries", - activity.benefitting_countries, # "Benefitting countries", - activity.benefitting_region, # "Benefitting region", - activity.gdi, # "Global Development Impact", - activity.sustainable_development_goals, # "Sustainable Development Goals", - activity.ispf_themes, # "ISPF themes", - activity.aid_type, # "Aid type", - activity.oda_eligibility, # "ODA eligibility", - activity.publish_to_iati, # "Publish to IATI?", - activity.tags, # "Tags", - budgets_by_year[2023]&.value, # "Budget 2023-2024", - budgets_by_year[2024]&.value, # "Budget 2024-2025", - budgets_by_year[2025]&.value, # "Budget 2025-2026", - budgets_by_year[2026]&.value, # "Budget 2026-2027", - budgets_by_year[2027]&.value, # "Budget 2027-2028", - budgets_by_year[2028]&.value, # "Budget 2028-2029", - activity.comments.map(&:body).join("|") # "Comments" - ] - end - def activities @activities ||= @fund.activity.child_activities - .includes(:organisation, :linked_activity, :commitment, :budgets, :comments) + .includes(:organisation, :commitment, :budgets, :linked_activity, :comments) end end diff --git a/config/environments/test.rb b/config/environments/test.rb index 031b73688..36167aef3 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -74,6 +74,7 @@ Bullet.add_safelist type: :unused_eager_loading, class_name: "User", association: :organisation Bullet.add_safelist type: :unused_eager_loading, class_name: "Activity", association: :organisation Bullet.add_safelist type: :unused_eager_loading, class_name: "Activity", association: :child_activities + Bullet.add_safelist type: :unused_eager_loading, class_name: "Activity", association: :linked_activity Bullet.add_safelist type: :unused_eager_loading, class_name: "Transaction", association: :provider Bullet.add_safelist type: :unused_eager_loading, class_name: "Transaction", association: :receiver Bullet.add_safelist type: :unused_eager_loading, class_name: "Activity", association: :parent diff --git a/spec/controllers/exports_controller_spec.rb b/spec/controllers/exports_controller_spec.rb index 4f7ad500c..15d1d5db2 100644 --- a/spec/controllers/exports_controller_spec.rb +++ b/spec/controllers/exports_controller_spec.rb @@ -120,7 +120,8 @@ end it "returns a CSV of all of the exports" do - expect(CSV.parse(response.body.delete_prefix("\uFEFF")).first).to match_array(Export::ActivitiesLevelB::HEADERS) + header = Export::ActivitiesLevelB::Row.new(fund, nil).to_a + expect(CSV.parse(response.body.delete_prefix("\uFEFF")).first).to match_array(header) end end end diff --git a/spec/features/beis_users_can_download_exports_spec.rb b/spec/features/beis_users_can_download_exports_spec.rb index 1578760f3..12720fc5d 100644 --- a/spec/features/beis_users_can_download_exports_spec.rb +++ b/spec/features/beis_users_can_download_exports_spec.rb @@ -360,7 +360,7 @@ "Budget 2027-2028" => nil, "Budget 2028-2029" => nil, "Comments" => "#{programme_1.comments.first.body}|#{programme_1.comments.last.body}" - })).and(have_attributes(length: 35)) + })).and(have_attributes(length: 36)) # And that file should contain no level B activities for any other fund expect(document.none? { |row| row["RODA Identifier"] == other_fund_programme.roda_identifier }).to be true diff --git a/spec/services/export/activities_level_b_spec.rb b/spec/services/export/activities_level_b_spec.rb new file mode 100644 index 000000000..080c5ef21 --- /dev/null +++ b/spec/services/export/activities_level_b_spec.rb @@ -0,0 +1,254 @@ +require "rails_helper" + +RSpec.describe Export::ActivitiesLevelB do + let(:export) { Export::ActivitiesLevelB.new(fund:) } + + before do + Fund.all.each { |fund| create(:fund_activity, source_fund_code: fund.id, roda_identifier: fund.short_name) } + end + + describe "#headers" do + subject(:headers) { export.headers } + + context "fund is ISPF" do + let(:fund) { Fund.by_short_name("ISPF") } + + it "has ISPF-only columns" do + expect(headers).to include("ODA or Non-ODA") + expect(headers).to include("ISPF ODA partner countries") + expect(headers).to include("ISPF themes") + expect(headers).to include("Tags") + end + end + context "fund is GCRF" do + let(:fund) { Fund.by_short_name("GCRF") } + + it "has no ISPF-only columns" do + expect(headers).not_to include("ODA or Non-ODA") + expect(headers).not_to include("ISPF ODA partner countries") + expect(headers).not_to include("ISPF themes") + expect(headers).not_to include("Tags") + end + + it "has GCRF-only columns" do + expect(headers).to include("GCRF Strategic Area") + expect(headers).to include("GCRF Challenge Area") + end + end + context "fund is Newton" do + let(:fund) { Fund.by_short_name("NF") } + + it "has no ISPF-only columns" do + expect(headers).not_to include("ODA or Non-ODA") + expect(headers).not_to include("ISPF ODA partner countries") + expect(headers).not_to include("ISPF themes") + expect(headers).not_to include("Tags") + end + + it "has NF-only columns" do + expect(headers).to include("Newton Fund Country Partner Organisations") + expect(headers).to include("Newton Fund Pillar") + end + end + end + + describe "#rows" do + subject(:rows) { export.rows } + let(:key_value_first_row) { export.headers.zip(export.rows.first).to_h } + + before { travel_to Date.new(2025, 1, 31) } # Factories default to dates around today for actual/planned dates + + context "fund is ISPF" do + let(:fund) { Fund.by_short_name("ISPF") } + let!(:programme_activity) do + create( + :programme_activity, :ispf_funded, commitment: create(:commitment, value: BigDecimal("250_000.00")), + benefitting_countries: %w[AR EC BR], tags: [4, 5], transparency_identifier: "GB-GOV-26-1234-5678-91011" + ) + end + + it "has one row with some values" do + expect(key_value_first_row).to match a_hash_including({ + "Partner Organisation" => "Department for Business, Energy and Industrial Strategy", + "Activity level" => "Programme (level B)", + "Parent activity" => "International Science Partnerships Fund", + "ODA or Non-ODA" => "ODA", + "Partner organisation identifier" => a_string_starting_with("GCRF-"), + "RODA identifier" => a_string_starting_with("ISPF-"), + "IATI identifier" => a_string_starting_with("GB-GOV-26-"), + "Linked activity" => nil, + "Activity title" => programme_activity.title, + "Activity description" => programme_activity.description, + "Aims or objectives" => programme_activity.objectives, + "Sector" => "11110: Education policy and administrative management", + "Original commitment figure" => "£250,000.00", + "Activity status" => "Spend in progress", + "Planned start date" => "31 Jan 2025", + "Planned end date" => "1 Feb 2025", + "Actual start date" => "30 Jan 2025", + "Actual end date" => "31 Jan 2025", + "ISPF ODA partner countries" => "India (ODA)", + "ISPF non-ODA partner countries" => "India (non-ODA)", + "Benefitting countries" => "Argentina; Ecuador; Brazil", + "Benefitting region" => "South America, regional", + "Global Development Impact" => "GDI not applicable", + "Sustainable Development Goals" => "Not applicable", + "ISPF themes" => "Resilient Planet", + "Aid type" => "D01: Donor country personnel", + "ODA eligibility" => "Eligible", + "Publish to IATI?" => "Yes", + "Tags" => "Tactical Fund|Previously reported under OODA", + "Budget 2023-2024" => nil, + "Budget 2024-2025" => nil, + "Budget 2025-2026" => nil, + "Budget 2026-2027" => nil, + "Budget 2027-2028" => nil, + "Budget 2028-2029" => nil, + "Comments" => "" + }).and(have_attributes(length: 36)) + end + end + + context "fund is GCRF" do + let(:fund) { Fund.by_short_name("GCRF") } + let!(:programme_activity) do + create( + :programme_activity, :gcrf_funded, commitment: create(:commitment, value: BigDecimal("250_000.00")), + benefitting_countries: %w[AR EC BR], transparency_identifier: "GB-GOV-26-1234-5678-91011" + ) + end + + it "has one row with some values" do + expect(key_value_first_row).to match a_hash_including({ + "Partner Organisation" => "Department for Business, Energy and Industrial Strategy", + "Activity level" => "Programme (level B)", + "Parent activity" => "Global Challenges Research Fund", + "Partner organisation identifier" => a_string_starting_with("GCRF-"), + "RODA identifier" => a_string_starting_with("GCRF-"), + "IATI identifier" => a_string_starting_with("GB-GOV-26-"), + "Linked activity" => nil, + "Activity title" => programme_activity.title, + "Activity description" => programme_activity.description, + "Aims or objectives" => programme_activity.objectives, + "Sector" => "11110: Education policy and administrative management", + "Original commitment figure" => "£250,000.00", + "Activity status" => "Spend in progress", + "Planned start date" => "31 Jan 2025", + "Planned end date" => "1 Feb 2025", + "Actual start date" => "30 Jan 2025", + "Actual end date" => "31 Jan 2025", + "GCRF Strategic Area" => "UKRI Collective Fund (2017 allocation) and Academies Collective Fund: Resilient Futures", + "GCRF Challenge Area" => "Not applicable", + "Benefitting countries" => "Argentina; Ecuador; Brazil", + "Benefitting region" => "South America, regional", + "Global Development Impact" => "GDI not applicable", + "Sustainable Development Goals" => "Not applicable", + "Aid type" => "D01: Donor country personnel", + "ODA eligibility" => "Eligible", + "Publish to IATI?" => "Yes", + "Budget 2023-2024" => nil, + "Budget 2024-2025" => nil, + "Budget 2025-2026" => nil, + "Budget 2026-2027" => nil, + "Budget 2027-2028" => nil, + "Budget 2028-2029" => nil, + "Comments" => "" + }).and(have_attributes(length: 33)) + end + end + + context "fund is OODA" do + let(:fund) { Fund.by_short_name("OODA") } + let!(:programme_activity) do + create( + :programme_activity, :ooda_funded, commitment: create(:commitment, value: BigDecimal("250_000.00")), + benefitting_countries: %w[AR EC BR], transparency_identifier: "GB-GOV-26-1234-5678-91011" + ) + end + + it "has one row with some values" do + expect(key_value_first_row).to match a_hash_including({ + "Partner Organisation" => "Department for Business, Energy and Industrial Strategy", + "Activity level" => "Programme (level B)", + "Parent activity" => "Other ODA", + "Partner organisation identifier" => a_string_starting_with("GCRF-"), + "RODA identifier" => a_string_starting_with("OODA-"), + "IATI identifier" => a_string_starting_with("GB-GOV-26-"), + "Linked activity" => nil, + "Activity title" => programme_activity.title, + "Activity description" => programme_activity.description, + "Aims or objectives" => programme_activity.objectives, + "Sector" => "11110: Education policy and administrative management", + "Original commitment figure" => "£250,000.00", + "Activity status" => "Spend in progress", + "Planned start date" => "31 Jan 2025", + "Planned end date" => "1 Feb 2025", + "Actual start date" => "30 Jan 2025", + "Actual end date" => "31 Jan 2025", + "Benefitting countries" => "Argentina; Ecuador; Brazil", + "Benefitting region" => "South America, regional", + "Global Development Impact" => "GDI not applicable", + "Sustainable Development Goals" => "Not applicable", + "Aid type" => "D01: Donor country personnel", + "ODA eligibility" => "Eligible", + "Publish to IATI?" => "Yes", + "Budget 2023-2024" => nil, + "Budget 2024-2025" => nil, + "Budget 2025-2026" => nil, + "Budget 2026-2027" => nil, + "Budget 2027-2028" => nil, + "Budget 2028-2029" => nil, + "Comments" => "" + }).and(have_attributes(length: 31)) + end + end + + context "fund is Newton" do + let(:fund) { Fund.by_short_name("NF") } + let!(:programme_activity) do + create( + :programme_activity, :newton_funded, commitment: create(:commitment, value: BigDecimal("250_000.00")), + benefitting_countries: %w[AR EC BR], transparency_identifier: "GB-GOV-26-1234-5678-91011", + country_partner_organisations: ["National Council for the State Funding Agencies (CONFAP)", "Other"] + ) + end + + it "has one row with some values" do + expect(key_value_first_row).to match a_hash_including({ + "Partner Organisation" => "Department for Business, Energy and Industrial Strategy", + "Activity level" => "Programme (level B)", + "Parent activity" => "Newton Fund", + "Partner organisation identifier" => a_string_starting_with("GCRF-"), + "RODA identifier" => a_string_starting_with("NF-"), + "IATI identifier" => a_string_starting_with("GB-GOV-26-"), + "Linked activity" => nil, + "Activity title" => programme_activity.title, + "Activity description" => programme_activity.description, + "Aims or objectives" => programme_activity.objectives, + "Sector" => "11110: Education policy and administrative management", + "Original commitment figure" => "£250,000.00", + "Activity status" => "Spend in progress", + "Planned start date" => "31 Jan 2025", + "Planned end date" => "1 Feb 2025", + "Actual start date" => "30 Jan 2025", + "Actual end date" => "31 Jan 2025", + "Newton Fund Country Partner Organisations" => "National Council for the State Funding Agencies (CONFAP)|Other", + "Benefitting countries" => "Argentina; Ecuador; Brazil", + "Benefitting region" => "South America, regional", + "Global Development Impact" => "GDI not applicable", + "Sustainable Development Goals" => "Not applicable", + "Aid type" => "D01: Donor country personnel", + "ODA eligibility" => "Eligible", + "Publish to IATI?" => "Yes", + "Budget 2023-2024" => nil, + "Budget 2024-2025" => nil, + "Budget 2025-2026" => nil, + "Budget 2026-2027" => nil, + "Budget 2027-2028" => nil, + "Budget 2028-2029" => nil, + "Comments" => "" + }).and(have_attributes(length: 33)) + end + end + end +end