34 changed files with 713 additions and 190 deletions
@ -0,0 +1,81 @@
|
||||
module CollectionDeadlineHelper |
||||
include CollectionTimeHelper |
||||
|
||||
QUARTERLY_DEADLINES = { |
||||
2024 => { |
||||
first_quarter_deadline: Time.zone.local(2024, 7, 12), |
||||
second_quarter_deadline: Time.zone.local(2024, 10, 11), |
||||
third_quarter_deadline: Time.zone.local(2025, 1, 10), |
||||
fourth_quarter_deadline: Time.zone.local(2025, 6, 6), # Same as submission deadline |
||||
}, |
||||
2025 => { |
||||
first_quarter_deadline: Time.zone.local(2025, 7, 11), |
||||
second_quarter_deadline: Time.zone.local(2025, 10, 10), |
||||
third_quarter_deadline: Time.zone.local(2026, 1, 16), |
||||
fourth_quarter_deadline: Time.zone.local(2026, 6, 5), # Same as submission deadline |
||||
}, |
||||
}.freeze |
||||
|
||||
def quarterly_cutoff_date(quarter, year) |
||||
send("#{quarter}_quarter", year)[:cutoff_date].strftime("%A %-d %B %Y") |
||||
end |
||||
|
||||
def quarter_deadlines_for_year(year) |
||||
QUARTERLY_DEADLINES[year] |
||||
end |
||||
|
||||
def first_quarter(year) |
||||
{ |
||||
cutoff_date: quarter_deadlines_for_year(year)[:first_quarter_deadline], |
||||
start_date: Time.zone.local(year, 4, 1), |
||||
end_date: Time.zone.local(year, 6, 30), |
||||
} |
||||
end |
||||
|
||||
def second_quarter(year) |
||||
{ |
||||
cutoff_date: quarter_deadlines_for_year(year)[:second_quarter_deadline], |
||||
start_date: Time.zone.local(year, 7, 1), |
||||
end_date: Time.zone.local(year, 9, 30), |
||||
} |
||||
end |
||||
|
||||
def third_quarter(year) |
||||
{ |
||||
cutoff_date: quarter_deadlines_for_year(year)[:third_quarter_deadline], |
||||
start_date: Time.zone.local(year, 10, 1), |
||||
end_date: Time.zone.local(year, 12, 31), |
||||
} |
||||
end |
||||
|
||||
def fourth_quarter(year) |
||||
{ |
||||
cutoff_date: quarter_deadlines_for_year(year)[:fourth_quarter_deadline], |
||||
start_date: Time.zone.local(year + 1, 1, 1), |
||||
end_date: Time.zone.local(year + 1, 3, 31), |
||||
} |
||||
end |
||||
|
||||
def quarter_dates(year) |
||||
[ |
||||
first_quarter(year).merge(quarter: "Q1"), |
||||
second_quarter(year).merge(quarter: "Q2"), |
||||
third_quarter(year).merge(quarter: "Q3"), |
||||
] |
||||
end |
||||
|
||||
def quarter_for_date(date: Time.zone.now) |
||||
quarters = quarter_dates(current_collection_start_year) |
||||
|
||||
quarter = quarters.find { |q| date.between?(q[:start_date], q[:cutoff_date] + 1.day) } |
||||
|
||||
return unless quarter |
||||
|
||||
OpenStruct.new( |
||||
quarter: quarter[:quarter], |
||||
cutoff_date: quarter[:cutoff_date], |
||||
quarter_start_date: quarter[:start_date], |
||||
quarter_end_date: quarter[:end_date], |
||||
) |
||||
end |
||||
end |
@ -0,0 +1,118 @@
|
||||
--- |
||||
nav_order: 10 |
||||
--- |
||||
|
||||
# CSV Downloads |
||||
|
||||
The CSV download functionality allows users to download various types of data from the service. This documentation provides an overview of how CSV downloads work, the different types of downloads available, and common development tasks related to CSV downloads. |
||||
|
||||
## How CSV Downloads Work |
||||
|
||||
CSV downloads are generated based on the data accessible to the user in the service. Sales and lettings data must be downloaded for a specific year due to differences in templates and collected data. |
||||
|
||||
When a download is requested: |
||||
|
||||
1. The request is queued using **Sidekiq** and processed in the background. |
||||
2. The generated CSV file is stored in an **S3 bucket**. |
||||
3. Users receive an email with a link to the service, where they can download the file via a **presigned URL**, valid for 2 days. |
||||
|
||||
## Available Types of CSV Downloads |
||||
|
||||
### For Support Users |
||||
|
||||
Support users can download the following data: |
||||
|
||||
- **Lettings Logs**: Either _labels_ or _codes_ version, one download per specific year. |
||||
- **Sales Logs**: Either _labels_ or _codes_ version, one download per specific year. |
||||
- **Schemes** |
||||
- **Locations** |
||||
- **Combined Schemes and Locations**: Contains the same data as above, but joined. |
||||
- **Users** |
||||
|
||||
### For Non-Support Users |
||||
|
||||
Non-support users can download: |
||||
|
||||
- **Lettings Logs**: Logs owned or managed by their organisation (or merged organisations) in the _labels_ version only. One download per specific year. |
||||
- **Sales Logs**: Logs owned or reported by their organisation (or merged organisations) in the _labels_ version only. One download per specific year. |
||||
- **Schemes**: Available to their organisation. |
||||
- **Locations**: Available to their organisation. |
||||
- **Combined Schemes and Locations**: Available to their organisation. |
||||
|
||||
### Applying Filters |
||||
|
||||
Users can download a subset of this data by applying filters and search. **Year filter** is mandatory for logs downloads. |
||||
|
||||
--- |
||||
|
||||
## Labels vs. Codes in CSV Downloads |
||||
|
||||
### Labels |
||||
|
||||
Labels represent the verbal answer options displayed in the service. |
||||
|
||||
For a lettings log `reason` field with the data `"4" => { "value" => "Loss of tied accommodation" }`, the value in the _labels_ version would be `Loss of tied accommodation`. |
||||
|
||||
### Codes |
||||
|
||||
_Codes only_ exports use integers where possible as field values. |
||||
|
||||
For the same `reason` field above, the value in the codes CSV download version would be `4`. |
||||
|
||||
The integers for _codes only_ export should correspond to the numbers in bulk upload specification and CDS export. |
||||
|
||||
Most of the codes saved internally align with the values exported, meaning the exported codes are typically identical to their internal representations. |
||||
|
||||
In cases where internal values differ from the expected export format, such as the `rent_type` field (exported under the `renttype_detail` header), the values are mapped to the expected format directly in the CSV export service. In this case, the mapping is handled in the `renttype_detail_code` method. |
||||
|
||||
### Things to note |
||||
|
||||
- **Some fields are always exported as codes**: Such as `la`. |
||||
- **Some fields are always exported as labels**: Such as `la_label`. |
||||
- **Mapping**: For fields where internal values don’t match export requirements (e.g., `rent_type` - exported as `renttype_detail`), mappings are applied directly in the CSV export service. |
||||
- For fields without corresponding codes (e.g., `tenancycode`), the _codes_ version will have the same value as the _labels_ version. |
||||
|
||||
--- |
||||
|
||||
## Common Development Tasks |
||||
|
||||
### 1. Adding New Columns |
||||
|
||||
- **Logs (Lettings/Sales)**: |
||||
- By default all of the question fields from the specific form will be exported in the CSV download, unless they're manually removed in the `lettings_log_attributes` or `sales_log_attributes` method. |
||||
- Update `lettings_log_attributes` or `sales_log_attributes` methods to add or remove fields. |
||||
- **Schemes/Locations**: |
||||
- Exported scheme/location CSV fields are hardcoded in `scheme_attributes` and `location_attributes`. Update `scheme_attributes` or `location_attributes` methods to add or remove fields. |
||||
- **Users**: |
||||
- Users CSV download is generated in users model `self.to_csv` and exported attributes are defined in `self.download_attributes`. Modify the `self.download_attributes` method in the `User` model to add or remove fields. |
||||
|
||||
### 2. Reordering Columns |
||||
|
||||
- **Logs (Lettings/Sales)**: |
||||
- Logs download question order coresponds to the order of the questions in the form flow and any additional ordering is applied in the `lettings_log_attributes` or `sales_log_attributes` methods. |
||||
- Modify the order in `lettings_log_attributes` or `sales_log_attributes` to update field order. |
||||
- **Schemes/Locations**: |
||||
- Adjust order in `scheme_attributes` or `location_attributes`. |
||||
- **Users**: |
||||
- Update column order in `self.download_attributes`. |
||||
|
||||
### 3. Populating CSV Variable Definitions |
||||
|
||||
CSV variable definitions describe each header in the logs downloads. |
||||
Definitions are saved in a `csv_variable_definitions` table and have been populated with initial values on production. The definitions are expected to be updated manually from `/admin` page when an update is needed, this is so that the definitions could be updated by support users. |
||||
|
||||
To populate initial CSV variable definitions locally or on a review app run `data_import:add_variable_definitions` rake task with the folder path to the variable definitions `config/csv/definitions` |
||||
|
||||
``` |
||||
rake data_import:add_variable_definitions[config/csv/definitions] |
||||
``` |
||||
|
||||
## Viewing and Updating CSV variable definitions |
||||
|
||||
To locate the CSV variable definitions: |
||||
|
||||
- Log in as a support user |
||||
- Navigate to the admin page of the app `<base_url>/admin` |
||||
- Select `CSV Variable Definitions` table |
||||
|
||||
A list of all the CSV variable definitions for logs download should be displayed and can be edited directly from here. Editing these would have an instant effect, so it might be worth trying it on staging environments first. |
@ -0,0 +1,111 @@
|
||||
require "rails_helper" |
||||
|
||||
RSpec.describe "Home Page Features" do |
||||
include CollectionTimeHelper |
||||
|
||||
let!(:user) { FactoryBot.create(:user) } |
||||
let(:storage_service) { instance_double(Storage::S3Service, get_file_metadata: nil) } |
||||
|
||||
before do |
||||
sign_in user |
||||
allow(Storage::S3Service).to receive(:new).and_return(storage_service) |
||||
allow(storage_service).to receive(:configuration).and_return(OpenStruct.new(bucket_name: "core-test-collection-resources")) |
||||
end |
||||
|
||||
describe "_upcoming_deadlines" do |
||||
let(:current_collection_year) { 2024 } |
||||
let(:next_collection_year) { 2025 } |
||||
|
||||
context "when visiting during the current collection year" do |
||||
before do |
||||
allow(FormHandler.instance).to receive(:in_crossover_period?).and_return(false) |
||||
# rubocop:disable RSpec/AnyInstance |
||||
allow_any_instance_of(CollectionTimeHelper).to receive(:current_collection_start_year).and_return(current_collection_year) |
||||
allow_any_instance_of(CollectionTimeHelper).to receive(:current_collection_end_year).and_return(current_collection_year + 1) |
||||
# rubocop:enable RSpec/AnyInstance |
||||
end |
||||
|
||||
scenario "displays correct text for quarters" do |
||||
Timecop.freeze(Time.zone.local(current_collection_year, 4, 1)) do |
||||
visit root_path |
||||
find("span.govuk-details__summary-text", text: "Quarterly cut-off dates for 2024 to 2025").click |
||||
expect(page).to have_content("Q1 - Friday 12 July 2024") |
||||
expect(page).to have_content("Q2 - Friday 11 October 2024") |
||||
expect(page).to have_content("Q3 - Friday 10 January 2025") |
||||
expect(page).to have_content("End of year deadline - Friday 6 June 2025") |
||||
end |
||||
Timecop.return |
||||
end |
||||
|
||||
scenario "displays correct current quarter as Q1" do |
||||
Timecop.freeze(Time.zone.local(current_collection_year, 4, 1)) do |
||||
visit root_path |
||||
expect(page).to have_content("Q1 - Friday 12 July 2024") |
||||
end |
||||
Timecop.return |
||||
end |
||||
|
||||
scenario "displays correct current quarter as Q2" do |
||||
Timecop.freeze(Time.zone.local(current_collection_year, 8, 1)) do |
||||
visit root_path |
||||
expect(page).to have_content("Q2 - Friday 11 October 2024") |
||||
end |
||||
Timecop.return |
||||
end |
||||
|
||||
scenario "displays correct current quarter as Q3" do |
||||
Timecop.freeze(Time.zone.local(current_collection_year, 11, 1)) do |
||||
visit root_path |
||||
expect(page).to have_content("Q3 - Friday 10 January 2025") |
||||
end |
||||
Timecop.return |
||||
end |
||||
end |
||||
|
||||
context "when visiting during the next collection year" do |
||||
before do |
||||
allow(FormHandler.instance).to receive(:in_crossover_period?).and_return(false) |
||||
# rubocop:disable RSpec/AnyInstance |
||||
allow_any_instance_of(CollectionTimeHelper).to receive(:current_collection_start_year).and_return(next_collection_year) |
||||
allow_any_instance_of(CollectionTimeHelper).to receive(:current_collection_end_year).and_return(next_collection_year + 1) |
||||
# rubocop:enable RSpec/AnyInstance |
||||
end |
||||
|
||||
scenario "displays correct text for quarters" do |
||||
Timecop.freeze(Time.zone.local(next_collection_year, 4, 1)) do |
||||
visit root_path |
||||
find("span.govuk-details__summary-text", text: "Quarterly cut-off dates for 2025 to 2026").click |
||||
expect(page).to have_content("Q1 - Friday 11 July 2025") |
||||
expect(page).to have_content("Q2 - Friday 10 October 2025") |
||||
expect(page).to have_content("Q3 - Friday 16 January 2026") |
||||
expect(page).to have_content("End of year deadline - Friday 5 June 2026") |
||||
end |
||||
Timecop.return |
||||
end |
||||
|
||||
scenario "displays correct current quarter as Q1" do |
||||
Timecop.freeze(Time.zone.local(next_collection_year, 4, 1)) do |
||||
visit root_path |
||||
expect(page).to have_content("Q1 - Friday 11 July 2025") |
||||
end |
||||
Timecop.return |
||||
end |
||||
|
||||
scenario "displays correct current quarter as Q2" do |
||||
Timecop.freeze(Time.zone.local(next_collection_year, 8, 1)) do |
||||
visit root_path |
||||
expect(page).to have_content("Q2 - Friday 10 October 2025") |
||||
end |
||||
Timecop.return |
||||
end |
||||
|
||||
scenario "displays correct current quarter as Q3" do |
||||
Timecop.freeze(Time.zone.local(next_collection_year, 11, 1)) do |
||||
visit root_path |
||||
expect(page).to have_content("Q3 - Friday 16 January 2026") |
||||
end |
||||
Timecop.return |
||||
end |
||||
end |
||||
end |
||||
end |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,29 @@
|
||||
require "rails_helper" |
||||
|
||||
RSpec.describe CollectionDeadlineHelper do |
||||
let(:current_user) { create(:user, :data_coordinator) } |
||||
let(:user) { create(:user, :data_coordinator) } |
||||
|
||||
describe "#quarter_for_date" do |
||||
it "returns correct cutoff date for the first quarter of 2024/25" do |
||||
quarter = quarter_for_date(date: Time.zone.local(2024, 4, 1)) |
||||
expect(quarter.cutoff_date).to eq(Time.zone.local(2024, 7, 12)) |
||||
expect(quarter.quarter_start_date).to eq(Time.zone.local(2024, 4, 1)) |
||||
expect(quarter.quarter_end_date).to eq(Time.zone.local(2024, 6, 30)) |
||||
end |
||||
|
||||
it "returns correct cutoff date for the second quarter of 2024/25" do |
||||
quarter = quarter_for_date(date: Time.zone.local(2024, 9, 30)) |
||||
expect(quarter.cutoff_date).to eq(Time.zone.local(2024, 10, 11)) |
||||
expect(quarter.quarter_start_date).to eq(Time.zone.local(2024, 7, 1)) |
||||
expect(quarter.quarter_end_date).to eq(Time.zone.local(2024, 9, 30)) |
||||
end |
||||
|
||||
it "returns correct cutoff date for the third quarter of 2024/25" do |
||||
quarter = quarter_for_date(date: Time.zone.local(2024, 10, 25)) |
||||
expect(quarter.cutoff_date).to eq(Time.zone.local(2025, 1, 10)) |
||||
expect(quarter.quarter_start_date).to eq(Time.zone.local(2024, 10, 1)) |
||||
expect(quarter.quarter_end_date).to eq(Time.zone.local(2024, 12, 31)) |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue