generated from DFE-Digital/govuk-rails-boilerplate
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[CPDNPQ-2210] Create statements in admin console
- Loading branch information
Showing
16 changed files
with
815 additions
and
0 deletions.
There are no files selected for viewing
75 changes: 75 additions & 0 deletions
75
app/controllers/npq_separation/admin/statements_controller.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
class NpqSeparation::Admin::StatementsController < NpqSeparation::AdminController | ||
before_action :ensure_super_admin | ||
before_action :set_cohort | ||
before_action :store_uploads, only: :create | ||
|
||
def new; end | ||
|
||
def create | ||
@service = Statements::BulkCreator.new( | ||
cohort: @cohort, | ||
statements_csv_id: bulk_creator_params[:statements_csv_id], | ||
contracts_csv_id: bulk_creator_params[:contracts_csv_id], | ||
) | ||
|
||
@statements = @service.call(dry_run:) | ||
|
||
if @service.errors.any? | ||
render :new | ||
elsif dry_run | ||
set_preview | ||
render | ||
else | ||
flash[:success] = "#{@statements.count} statements created successfully" | ||
redirect_to npq_separation_admin_cohort_path(@cohort) | ||
end | ||
end | ||
|
||
private | ||
|
||
def set_cohort | ||
@cohort = Cohort.find(params[:cohort_id]) | ||
end | ||
|
||
def set_preview | ||
@preview = { | ||
statements: @statements.uniq { [_1.year, _1.month] }, | ||
contracts: @statements.group_by { _1.lead_provider.name }.transform_values { _1.first.contracts }, | ||
lead_providers_count: @statements.uniq(&:lead_provider).count, | ||
} | ||
end | ||
|
||
def dry_run | ||
bulk_creator_params[:confirm] != "1" | ||
end | ||
|
||
def bulk_creator_params | ||
return {} if params[:statements_bulk_creator].blank? | ||
|
||
params.require(:statements_bulk_creator).permit(:statements_csv_id, :contracts_csv_id, :confirm) | ||
end | ||
|
||
def store_uploads | ||
return unless (p = params[:statements_bulk_creator]) | ||
|
||
{ | ||
statements_csv_file: :statements_csv_id, | ||
contracts_csv_file: :contracts_csv_id, | ||
}.each do |file_key, id_key| | ||
next unless (file = p.delete(file_key)) | ||
|
||
p[id_key] = store_file(file) | ||
end | ||
end | ||
|
||
def store_file(file) | ||
ActiveStorage::Blob.create_and_upload!(io: file, filename: file.original_filename).signed_id | ||
end | ||
|
||
def ensure_super_admin | ||
unless current_admin.super_admin? | ||
flash[:error] = "You must be a super admin to create statements" | ||
redirect_to npq_separation_admin_cohort_path(params[:cohort_id]) | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
module Statements | ||
class BulkCreator | ||
include ActiveModel::Validations | ||
|
||
attr_reader :cohort, :statements_csv_id, :contracts_csv_id | ||
|
||
validates :statements_csv_id, presence: { message: "must be provided" } | ||
validates :contracts_csv_id, presence: { message: "must be provided" } | ||
validate :validate_statements_csv | ||
validate :validate_contracts_csv | ||
validate :validate_statement_uniqueness | ||
|
||
def initialize(cohort:, statements_csv_id:, contracts_csv_id:) | ||
@cohort = cohort | ||
@statements_csv_id = statements_csv_id | ||
@contracts_csv_id = contracts_csv_id | ||
end | ||
|
||
def call(dry_run: true) | ||
return unless valid? | ||
|
||
ApplicationRecord.transaction do | ||
@result = create_statements | ||
raise ActiveRecord::Rollback if dry_run | ||
end | ||
|
||
@result | ||
end | ||
|
||
private | ||
|
||
def validate_statements_csv | ||
return if statements_csv_id.blank? | ||
|
||
statement_parser.errors.each { errors.add :statements_csv, _1 } | ||
end | ||
|
||
def validate_contracts_csv | ||
return if contracts_csv_id.blank? | ||
|
||
contract_parser.errors.each { errors.add :contracts_csv, _1 } | ||
end | ||
|
||
def validate_statement_uniqueness | ||
return if statements_csv_id.blank? || contracts_csv_id.blank? | ||
|
||
lead_providers = contract_parser.valid | ||
.uniq(&:lead_provider_name) | ||
.map { lead_provider_for _1 } | ||
|
||
statement_parser.valid.each.with_index(2) do |statement, line_number| | ||
lead_providers.each do |lead_provider| | ||
next unless Statement.exists?(cohort:, lead_provider:, year: statement.year, month: statement.month) | ||
|
||
errors.add(:statements_csv, "Statement already exists on line #{line_number}") | ||
break | ||
end | ||
end | ||
end | ||
|
||
def create_statements | ||
cache = {} | ||
|
||
statement_parser.each do |statement_row| | ||
contract_parser.each do |contract_row| | ||
statement_attributes = statement_attributes_for(statement_row, contract_row) | ||
contract_attributes = contract_attributes_for(contract_row) | ||
|
||
cache[statement_attributes] ||= Statement.create!(statement_attributes) | ||
cache[statement_attributes].contracts.create!(contract_attributes) | ||
end | ||
end | ||
|
||
cache.values | ||
end | ||
|
||
def statement_attributes_for(statement_row, contract_row) | ||
lead_provider = lead_provider_for(contract_row) | ||
|
||
statement_row.attributes.merge(cohort:, lead_provider:) | ||
end | ||
|
||
def contract_attributes_for(contract_row) | ||
course = course_for(contract_row) | ||
contract_template = contract_template_for(contract_row, course.course_group) | ||
|
||
{ contract_template:, course: } | ||
end | ||
|
||
def lead_provider_for(contract_row) | ||
name = contract_row.lead_provider_name | ||
|
||
@lead_provider_cache ||= {} | ||
@lead_provider_cache[name] ||= LeadProvider.find_by!(name:) | ||
end | ||
|
||
def course_for(contract_row) | ||
@course_cache ||= {} | ||
|
||
identifier = contract_row.course_identifier | ||
@course_cache[identifier] ||= Course.find_by!(identifier:) | ||
end | ||
|
||
def contract_template_for(contract_row, course_group) | ||
attributes = contract_row.contract_template_attributes | ||
.merge(contract_template_attributes_for(course_group)) | ||
|
||
@contract_template_cache ||= {} | ||
@contract_template_cache[attributes] ||= ContractTemplate.find_or_create_by!(attributes) | ||
end | ||
|
||
def contract_template_attributes_for(course_group) | ||
case course_group.name | ||
when "leadership" | ||
{ | ||
number_of_payment_periods: 4, | ||
service_fee_percentage: 40, | ||
output_payment_percentage: 60, | ||
} | ||
when "specialist" | ||
{ | ||
number_of_payment_periods: 3, | ||
service_fee_percentage: 40, | ||
output_payment_percentage: 60, | ||
} | ||
when "support" | ||
{ | ||
number_of_payment_periods: 4, | ||
service_fee_percentage: 0, | ||
output_payment_percentage: 100, | ||
} | ||
when "ehco" | ||
{ | ||
number_of_payment_periods: 4, | ||
service_fee_percentage: 0, | ||
output_payment_percentage: 100, | ||
} | ||
else | ||
raise ArgumentError, "Invalid course group name" | ||
end | ||
end | ||
|
||
def statement_parser | ||
@statement_parser ||= parse_blob(statements_csv_id, StatementRow) | ||
end | ||
|
||
def contract_parser | ||
@contract_parser ||= parse_blob(contracts_csv_id, ContractRow) | ||
end | ||
|
||
def parse_blob(signed_id, row_class) | ||
blob = ActiveStorage::Blob.find_signed!(signed_id) | ||
blob.open { |file| Parser.read(file, row_class) } | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
module Statements | ||
class BulkCreator | ||
class ContractRow | ||
include ActiveModel::Model | ||
include ActiveModel::Attributes | ||
|
||
CONTRACT_TEMPLATE_ATTRIBUTES = %w[ | ||
recruitment_target | ||
per_participant | ||
special_course | ||
monthly_service_fee | ||
service_fee_installments | ||
].freeze | ||
|
||
attribute :lead_provider_name, :string | ||
attribute :course_identifier, :string | ||
attribute :recruitment_target, :strict_integer | ||
attribute :per_participant, :strict_decimal, default: nil | ||
attribute :special_course, :boolean, default: false | ||
attribute :monthly_service_fee, :strict_decimal, default: nil | ||
attribute :service_fee_installments, :strict_integer, default: nil | ||
|
||
validates :lead_provider_name, inclusion: { in: -> { LeadProvider.pluck(:name) }, message: "is not recognised" } | ||
validates :course_identifier, inclusion: { in: -> { Course.pluck(:identifier) }, message: "is not recognised" } | ||
validates :recruitment_target, numericality: { greater_than: 0 } | ||
validates :per_participant, numericality: { greater_than: 0 } | ||
validates :monthly_service_fee, numericality: { greater_than_or_equal_to: 0 } | ||
validates :service_fee_installments, numericality: { greater_than_or_equal_to: 0 } | ||
|
||
def self.example_csv | ||
<<~CSV.strip | ||
lead_provider_name,course_identifier,recruitment_target,per_participant,service_fee_installments,special_course,monthly_service_fee | ||
"#{LeadProvider.first.name}",#{Course.first.identifier},30,1000,12,false,100 | ||
"#{LeadProvider.first.name}",#{Course.last.identifier},50,400,6,true,200 | ||
"#{LeadProvider.last.name}",#{Course.first.identifier},20,750,9,false,0 | ||
CSV | ||
end | ||
|
||
def contract_template_attributes | ||
attributes.slice(*CONTRACT_TEMPLATE_ATTRIBUTES) | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
module Statements | ||
class BulkCreator | ||
class Parser < Array | ||
attr_accessor :error | ||
|
||
def self.read(csv_file, row_class) | ||
lines = CSV.read(csv_file, headers: true, encoding: "bom|utf-8") # encoding needed for some Excel CSVs | ||
|
||
if lines.none? | ||
new.tap { _1.error = "No rows found" } | ||
elsif (missing_headers = row_class.attribute_names - lines.first.headers).any? | ||
new.tap { _1.error = "Missing headers: #{missing_headers.join(", ")}" } | ||
else | ||
new lines.map { row_class.new(_1.to_h.slice(*row_class.attribute_names)) } | ||
end | ||
end | ||
|
||
def valid? | ||
error.nil? && present? && valid.count == count | ||
end | ||
|
||
def valid | ||
select(&:valid?) | ||
end | ||
|
||
def errors | ||
return [] if valid? | ||
|
||
[error].compact + flat_map.with_index(2) do |object, line_number| | ||
object.errors.map { _1.full_message + " on line #{line_number}" } | ||
end | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
module Statements | ||
class BulkCreator | ||
class StatementRow | ||
include ActiveModel::Model | ||
include ActiveModel::Attributes | ||
|
||
attribute :year, :integer | ||
attribute :month, :integer | ||
attribute :deadline_date, :date | ||
attribute :payment_date, :date | ||
attribute :output_fee, :boolean, default: false | ||
|
||
validates :year, inclusion: { in: 2020..2040, message: "must be between 2020 and 2040" } | ||
validates :month, inclusion: { in: 1..12, message: "must be between 1 and 12" } | ||
validates :deadline_date, presence: { message: "must be a date (e.g. YYYY-MM-DD)" } | ||
validates :payment_date, presence: { message: "must be a date (e.g. YYYY-MM-DD)" } | ||
|
||
def self.example_csv | ||
<<~CSV.strip | ||
year,month,deadline_date,payment_date,output_fee | ||
2025,2,2024-12-25,2025-01-26,true | ||
2025,3,2025-01-26,2025-02-27,false | ||
2025,4,2025-02-24,2025-03-25,false | ||
CSV | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
16 changes: 16 additions & 0 deletions
16
app/views/npq_separation/admin/statements/_errors.html.erb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<div class="govuk-error-summary" data-module="govuk-error-summary"> | ||
<div role="alert"> | ||
<h2 class="govuk-error-summary__title">There is a problem</h2> | ||
<div class="govuk-error-summary__body"> | ||
<ul class="govuk-list govuk-error-summary__list"> | ||
<% errors.full_messages.first(20).each do |message| %> | ||
<li><%= message.gsub(' csv ', ' CSV: ') %></li> | ||
<% end %> | ||
<% if errors.count > 20 %> | ||
<li>...and <%= errors.count - 20 %> more errors</li> | ||
<% end %> | ||
</ul> | ||
<p class="govuk-body">Please check the provided files and try again.</p> | ||
</div> | ||
</div> | ||
</div> |
Oops, something went wrong.