Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions app/controllers/reports/annual_reports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ class Reports::AnnualReportsController < ApplicationController
def index
# 2813_update_annual_report -- changed to earliest_reporting_year
# so that we can do system tests and staging
foundation_year = current_organization.earliest_reporting_year
@foundation_year = current_organization.earliest_reporting_year

@actual_year = Time.current.year

@years = (foundation_year...@actual_year).to_a
@years = (@foundation_year...@actual_year).to_a

@month_remaining_to_report = 12 - Time.current.month
end
Expand All @@ -32,12 +32,35 @@ def recalculate
redirect_to reports_annual_report_path(year), notice: "Recalculated annual report!"
end

def range
year_start = range_params[:year_start].to_i
year_end = range_params[:year_end].to_i

if year_end < year_start
flash[:error] = "End year must be greater than or equal to start year."
redirect_to reports_annual_reports_path and return
end

reports = Reports::AnnualSurveyReportService.new(organization: current_organization, year_start: year_start, year_end: year_end).call

respond_to do |format|
format.csv do
send_data Exports::ExportReportCSVService.new(reports:).generate_csv(range: true),
filename: "NdbnAnnuals-#{year_start}-#{year_end}.csv"
end
end
end

private

def year_param
params.require(:year)
end

def range_params
params.permit(:year_start, :year_end)
end

def validate_show_params
not_found! unless year_param.to_i.positive?
end
Expand Down
35 changes: 33 additions & 2 deletions app/services/exports/export_report_csv_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ def initialize(reports:)
@reports = reports
end

def generate_csv
csv_data = generate_csv_data
def generate_csv(range: false)
csv_data = range ? generate_range_csv_data : generate_csv_data

::CSV.generate(headers: true) do |csv|
csv_data.each { |row| csv << row }
Expand All @@ -31,5 +31,36 @@ def generate_csv_data

csv_data
end

def generate_range_csv_data
return [] if @reports.empty?

# Ordered unique headers (in first-seen order)
header_index = {}

# Cache each year's flattened entries so we don't re-walk twice
yearly_entries = @reports.map do |report|
entries = {}

report.all_reports.each do |section|
section.fetch("entries", {}).each do |key, value|
header_index[key] ||= true
entries[key] = value
end
end

{year: report["year"], entries: entries}
end

headers = header_index.keys
csv_data = []
csv_data << ["Year"] + headers

yearly_entries.each do |row|
csv_data << [row[:year]] + headers.map { |h| row[:entries][h] }
end

csv_data
end
end
end
18 changes: 18 additions & 0 deletions app/services/reports/annual_survey_report_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module Reports
class AnnualSurveyReportService
def initialize(organization:, year_start:, year_end:)
@organization = organization
@year_start = year_start
@year_end = year_end
end

def call
(@year_start..@year_end).map do |year|
Reports.retrieve_report(organization: @organization, year: year, recalculate: true)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("Failed to retrieve annual report for year #{year}: #{e.message}")
nil
end.compact
end
end
end
9 changes: 9 additions & 0 deletions app/views/reports/annual_reports/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
<h5 class="mb-1">Reports are available at the end of every year.</h5>
<span class="text-muted"> <%= "#{@actual_year} (available in #{pluralize(@month_remaining_to_report, 'month')})" %> </span>
</div>
<div class="col-md-12 mb-2">
<%=
download_button_to(
range_reports_annual_reports_path(year_start: @foundation_year, year_end: (@actual_year - 1), format: :csv),
text: "Export Yearly Reports"
)
%>
This will recalculate all the reports, and may take some time.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might make sense to pair with #5495. Not sure if using this with actual data might time out right now.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's reasonable. I saw that the PR was stale, so I just created a new PR based off it here: #5542. The new PR contains some changes based on the feedback. I think we should release that PR first to see that everything is working fine for distributions, and then I can update this PR once those changes are successfully integrated. How does that sound?

</div>
<div class="col-md-12">
<div class="card card-primary">
<div class="card-header">
Expand Down
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ def set_up_flipper
namespace :reports do
resources :annual_reports, only: [:index, :show], param: :year do
post :recalculate, on: :member
get 'range/:year_start/:year_end',
to: 'annual_reports#range',
on: :collection,
as: 'range',
constraints: { year_start: /\d{4}/, year_end: /\d{4}/ }
end
get :donations_summary
get :manufacturer_donations_summary
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion docs/user_guide/bank/reports_annual_survey.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
The annual survey contains information useful for completing the NDBN or Alliance for Period Supplies annual survey, but also for grant writing.
Each year's annual survey becomes available January 1 of the following year.

## How to get the report
## How to export all annual survey reports

1. Click on “Reports”
2. Click on “Annual Survey”
3. Click on "Export Yearly Reports

![Annual Report Yearly](images/reports/reports-anual-survey-yearly-export.png)

## How to export individual annual survey reports

Click on "Reports", then "Annual Survey" in the left-hand menu. Then click on the year of the report you wish to view.
![Navigation to annual report](images/reports/reports_annual_survey_1.png)
Expand Down
63 changes: 62 additions & 1 deletion spec/requests/reports/annual_reports_requests_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
RSpec.describe "Annual Reports", type: :request do
let(:organization) { create(:organization) }
let(:organization) { create(:organization, created_at: Time.zone.local(2006, 1, 1)) }
let(:user) { create(:user, organization: organization) }
let(:organization_admin) { create(:organization_admin, organization: organization) }

Expand Down Expand Up @@ -56,6 +56,67 @@
end
end

describe "GET /range" do
context "with valid year range" do
it "returns http success and generates a CSV with the correct year ranges" do
get range_reports_annual_reports_path(year_start: 2016, year_end: 2018, format: :csv)

expect(response).to have_http_status(:success)
expect(response.body).to include("2016")
expect(response.body).to include("2017")
expect(response.body).to include("2018")
end

it "returns correct data given columns are not at parity between years" do
# Some years may have columns that do not exist in other years, simulate the
# situation with "New Field" that only exists in 2017, but not 2016
shared_entries = { "Total Distributed" => 100, "Total Donors" => 5 }
extra_entries = { "Total Distributed" => 200, "Total Donors" => 8, "New Field" => 42 }

report_2016 = instance_double(AnnualReport,
"[]": nil,
all_reports: [{ "entries" => shared_entries }])
report_2017 = instance_double(AnnualReport,
"[]": nil,
all_reports: [{ "entries" => extra_entries }])

allow(report_2016).to receive(:[]).with("year").and_return(2016)
allow(report_2017).to receive(:[]).with("year").and_return(2017)

allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2016)).and_return(report_2016)
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2017)).and_return(report_2017)

get range_reports_annual_reports_path(year_start: 2016, year_end: 2017, format: :csv)

csv = CSV.parse(response.body, headers: true)

expect(csv&.headers).to include("Year", "Total Distributed", "Total Donors", "New Field")

row_2016 = csv&.find { |r| r["Year"] == "2016" }
row_2017 = csv&.find { |r| r["Year"] == "2017" }

expect(row_2016["New Field"]).to be_nil
expect(row_2017["New Field"]).to eq("42")
expect(row_2017["Total Distributed"]).to eq("200")
end
end

context "invalid year ranges given" do
it "should raise a URL error" do
expect { get range_reports_annual_reports_path(year_start: 'test', year_end: 'test', format: :csv) }
.to raise_error(ActionController::UrlGenerationError)
end

it "should redirect and show an error message if end year is less than start year" do
get range_reports_annual_reports_path(year_start: 2018, year_end: 2016, format: :csv)
expect(response).to have_http_status(:found)
expect(flash[:error]).to eq("End year must be greater than or equal to start year.")
end
end
end

describe 'POST /recalculate' do
it "recalculates new reports" do
expect(AnnualReport.count).to eq(0)
Expand Down
84 changes: 84 additions & 0 deletions spec/services/reports/annual_survey_report_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
RSpec.describe Reports::AnnualSurveyReportService, type: :service do
let(:organization) { create(:organization) }

subject(:service) do
described_class.new(organization: organization, year_start: year_start, year_end: year_end)
end

describe "#call" do
context "with a valid year range" do
let(:year_start) { 2020 }
let(:year_end) { 2022 }

let(:report_2020) { instance_double(AnnualReport) }
let(:report_2021) { instance_double(AnnualReport) }
let(:report_2022) { instance_double(AnnualReport) }

before do
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2020)).and_return(report_2020)
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2021)).and_return(report_2021)
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2022)).and_return(report_2022)
end

it "returns one report per year in the range" do
expect(service.call).to eq([report_2020, report_2021, report_2022])
end

it "calls retrieve_report with recalculate: true for each year" do
service.call
expect(Reports).to have_received(:retrieve_report)
.with(hash_including(organization: organization, year: 2020, recalculate: true))
expect(Reports).to have_received(:retrieve_report)
.with(hash_including(organization: organization, year: 2021, recalculate: true))
expect(Reports).to have_received(:retrieve_report)
.with(hash_including(organization: organization, year: 2022, recalculate: true))
end
end

context "with a single year range" do
let(:year_start) { 2021 }
let(:year_end) { 2021 }

let(:report_2021) { instance_double(AnnualReport) }

before do
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2021)).and_return(report_2021)
end

it "returns a single report" do
expect(service.call).to eq([report_2021])
end
end

context "when a year's report raises ActiveRecord::RecordInvalid" do
let(:year_start) { 2020 }
let(:year_end) { 2022 }

let(:report_2020) { instance_double(AnnualReport) }
let(:report_2022) { instance_double(AnnualReport) }

before do
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2020)).and_return(report_2020)
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2021)).and_raise(ActiveRecord::RecordInvalid)
allow(Reports).to receive(:retrieve_report)
.with(hash_including(year: 2022)).and_return(report_2022)
end

it "skips the failed year and returns the remaining reports" do
expect(service.call).to eq([report_2020, report_2022])
end

it "logs the error" do
allow(Rails.logger).to receive(:error)
service.call
expect(Rails.logger).to have_received(:error).with(/Failed to retrieve annual report for year 2021/)
end
end
end
end