Browse Source

Merge branch 'main' into CLDC-3640-collection-resources

pull/2673/head
kosiakkatrina 2 years ago committed by GitHub
parent
commit
7d8056df9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 37
      app/components/bulk_upload_summary_component.html.erb
  2. 72
      app/components/bulk_upload_summary_component.rb
  3. 9
      app/components/create_log_actions_component.html.erb
  4. 40
      app/components/create_log_actions_component.rb
  5. 27
      app/components/search_component.rb
  6. 1
      app/controllers/form_controller.rb
  7. 44
      app/controllers/lettings_logs_controller.rb
  8. 44
      app/controllers/sales_logs_controller.rb
  9. 8
      app/controllers/sessions_controller.rb
  10. 18
      app/frontend/styles/_bulk-uploads.scss
  11. 4
      app/frontend/styles/_tag.scss
  12. 15
      app/frontend/styles/application.scss
  13. 12
      app/helpers/bulk_upload_helper.rb
  14. 62
      app/helpers/filters_helper.rb
  15. 16
      app/helpers/tag_helper.rb
  16. 50
      app/models/bulk_upload.rb
  17. 4
      app/models/bulk_upload_error.rb
  18. 16
      app/models/user.rb
  19. 5
      app/models/validations/financial_validations.rb
  20. 4
      app/services/bulk_upload/downloader.rb
  21. 8
      app/services/bulk_upload/lettings/year2024/row_parser.rb
  22. 23
      app/services/bulk_upload/processor.rb
  23. 8
      app/services/bulk_upload/sales/year2024/row_parser.rb
  24. 32
      app/services/filter_manager.rb
  25. 4
      app/services/storage/s3_service.rb
  26. 9
      app/views/bulk_upload_lettings_results/show.html.erb
  27. 13
      app/views/bulk_upload_lettings_results/summary.html.erb
  28. 9
      app/views/bulk_upload_sales_results/show.html.erb
  29. 13
      app/views/bulk_upload_sales_results/summary.html.erb
  30. 78
      app/views/bulk_upload_shared/_upload_filters.html.erb
  31. 15
      app/views/bulk_upload_shared/_upload_list.html.erb
  32. 28
      app/views/bulk_upload_shared/uploads.html.erb
  33. 3
      app/views/layouts/application.html.erb
  34. 10
      app/views/logs/_create_for_org_actions.html.erb
  35. 2
      app/views/organisations/index.html.erb
  36. 2
      config/locales/en.yml
  37. 14
      config/routes.rb
  38. 8
      db/migrate/20241002163937_add_failure_reason_and_processing_to_bulk_uploads.rb
  39. 4
      db/schema.rb
  40. 148
      spec/components/bulk_upload_summary_component_spec.rb
  41. 28
      spec/components/create_log_actions_component_spec.rb
  42. 2
      spec/factories/bulk_upload.rb
  43. 1
      spec/factories/bulk_upload_error.rb
  44. 5
      spec/features/accessibility_spec.rb
  45. 31
      spec/features/lettings_log_spec.rb
  46. 8
      spec/features/organisation_spec.rb
  47. 31
      spec/features/sales_log_spec.rb
  48. 208
      spec/models/bulk_upload_spec.rb
  49. 12
      spec/models/user_spec.rb
  50. 16
      spec/models/validations/financial_validations_spec.rb
  51. 27
      spec/requests/check_errors_controller_spec.rb
  52. 12
      spec/services/bulk_upload/downloader_spec.rb
  53. 2
      spec/services/bulk_upload/lettings/year2023/row_parser_spec.rb
  54. 12
      spec/services/bulk_upload/lettings/year2024/row_parser_spec.rb
  55. 7
      spec/services/bulk_upload/processor_spec.rb
  56. 10
      spec/services/bulk_upload/sales/year2024/row_parser_spec.rb
  57. 3
      spec/views/bulk_upload_lettings_results/show.html.erb_spec.rb
  58. 3
      spec/views/bulk_upload_lettings_results/summary.html.erb_spec.rb
  59. 3
      spec/views/bulk_upload_sales_results/show.html.erb_spec.rb
  60. 3
      spec/views/bulk_upload_sales_results/summary.html.erb_spec.rb
  61. 8
      spec/views/logs/_create_for_org_actions.html.erb_spec.rb

37
app/components/bulk_upload_summary_component.html.erb

@ -0,0 +1,37 @@
<article class="app-log-summary">
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<header class="app-log-summary__header">
<h2 class="govuk-heading-m govuk-!-font-weight-regular govuk-!-margin-bottom-0 text-normal-break ">
<span class="govuk-!-margin-right-1"><%= bulk_upload.filename %></span>
<span class="app-metadata app-log-summary__details" style="white-space: nowrap;"><%= bulk_upload.year %>/<%= bulk_upload.year + 1 %></span>
</h2>
</header>
<div class="govuk-!-margin-bottom-2">
<p class="govuk-hint govuk-!-font-size-16 govuk-!-margin-bottom-1">Uploaded by: <%= bulk_upload.user.name %> (<%= bulk_upload.user.email %>)</p>
<p class="govuk-hint govuk-!-font-size-16 govuk-!-margin-bottom-1">Uploading organisation: <%= bulk_upload.organisation.name %></p>
<p class="govuk-hint govuk-!-font-size-16 govuk-!-margin-bottom-1">Time of upload: <%= bulk_upload.created_at.to_formatted_s(:govuk_date_and_time) %></p>
</div>
<p class="govuk-body govuk-!-margin-bottom-3">
<%= download_file_link(bulk_upload) %>
<%= view_error_report_link(bulk_upload) %>
<%= view_logs_link(bulk_upload) %>
</p>
</div>
<footer class="govuk-grid-column-one-third app-log-summary__footer">
<p class="govuk-body govuk-!-margin-bottom-3">
<%= upload_status %>
</p>
<% unless bulk_upload.processing %>
<div>
<%= counts(
[bulk_upload.total_logs_count, "total log"],
[setup_errors_count, "error on important questions", "errors on important questions"],
[critical_errors_count, "critical error"],
[potential_errors_count, "potential error"],
) %>
</div>
<% end %>
</footer>
</div>
</article>

72
app/components/bulk_upload_summary_component.rb

@ -0,0 +1,72 @@
class BulkUploadSummaryComponent < ViewComponent::Base
attr_reader :bulk_upload
def initialize(bulk_upload:)
@bulk_upload = bulk_upload
@bulk_upload_errors = bulk_upload.bulk_upload_errors
super
end
def upload_status
helpers.status_tag(bulk_upload.status, ["app-tag--small govuk-!-font-weight-regular no-max-width"])
end
def setup_errors_count
@bulk_upload_errors.where(category: "setup").count
end
def critical_errors_count
@bulk_upload_errors.where(category: [nil, "", "not_answered"]).count
end
def potential_errors_count
@bulk_upload_errors.where(category: "soft_validation").count
end
def formatted_count_text(count, singular_text, plural_text = nil)
return if count.nil? || count <= 0
text = count > 1 ? (plural_text || singular_text.pluralize(count)) : singular_text
content_tag(:p, class: "govuk-!-font-size-16 govuk-!-margin-bottom-1") do
concat(content_tag(:strong, count))
concat(" #{text}")
end
end
def counts(*counts_with_texts)
counts_with_texts.map { |count, singular_text, plural_text|
formatted_count_text(count, singular_text, plural_text) if count.present?
}.compact.join("").html_safe
end
def download_file_link(bulk_upload)
send("download_#{bulk_upload.log_type}_file_link", bulk_upload)
end
def download_lettings_file_link(bulk_upload)
govuk_link_to "Download file", download_lettings_bulk_upload_path(bulk_upload), class: "govuk-link govuk-!-margin-right-2"
end
def download_sales_file_link(bulk_upload)
govuk_link_to "Download file", download_sales_bulk_upload_path(bulk_upload), class: "govuk-link govuk-!-margin-right-2"
end
def view_error_report_link(bulk_upload)
status = bulk_upload.status.to_s
return unless %w[important_errors critical_errors potential_errors].include?(status)
path = if status == "important_errors"
"summary_bulk_upload_#{bulk_upload.log_type}_result_url"
else
"bulk_upload_#{bulk_upload.log_type}_result_path"
end
govuk_link_to "View error report", send(path, bulk_upload), class: "govuk-link"
end
def view_logs_link(bulk_upload)
return unless bulk_upload.status.to_s == "logs_uploaded_with_errors"
govuk_link_to "View logs with errors", send("#{bulk_upload.log_type}_logs_path", bulk_upload_id: [bulk_upload.id]), class: "govuk-link"
end
end

9
app/components/create_log_actions_component.html.erb

@ -1,10 +1,11 @@
<% if display_actions? %> <% if display_actions? %>
<div class="govuk-button-group app-filter-toggle govuk-!-margin-bottom-6"> <div class="govuk-button-group app-filter-toggle govuk-!-margin-bottom-6">
<% if create_button_href.present? %> <%= govuk_button_to create_button_copy, create_button_href, class: "govuk-!-margin-right-6" %>
<%= govuk_button_to create_button_copy, create_button_href, class: "govuk-!-margin-right-6" %> <% unless user.support? %>
<%= govuk_button_link_to upload_button_copy, upload_button_href, secondary: true %>
<% end %> <% end %>
<% if upload_button_href.present? && !user.support? %> <% if user.support? %>
<%= govuk_button_link_to upload_button_copy, upload_button_href, secondary: true %> <%= govuk_button_link_to view_uploads_button_copy, view_uploads_button_href, secondary: true %>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>

40
app/components/create_log_actions_component.rb

@ -18,39 +18,27 @@ class CreateLogActionsComponent < ViewComponent::Base
user.organisation.data_protection_confirmed? && user.organisation.organisation_or_stock_owner_signed_dsa_and_holds_own_stock? user.organisation.data_protection_confirmed? && user.organisation.organisation_or_stock_owner_signed_dsa_and_holds_own_stock?
end end
def create_button_href def create_button_copy
case log_type "Create a new #{log_type} log"
when "lettings"
lettings_logs_path
when "sales"
sales_logs_path
end
end end
def create_button_copy def create_button_href
case log_type send("#{log_type}_logs_path")
when "lettings"
"Create a new lettings log"
when "sales"
"Create a new sales log"
end
end end
def upload_button_copy def upload_button_copy
case log_type "Upload #{log_type} logs in bulk"
when "lettings"
"Upload lettings logs in bulk"
when "sales"
"Upload sales logs in bulk"
end
end end
def upload_button_href def upload_button_href
case log_type send("bulk_upload_#{log_type}_log_path", id: "start")
when "lettings" end
bulk_upload_lettings_log_path(id: "start")
when "sales" def view_uploads_button_copy
bulk_upload_sales_log_path(id: "start") "View #{log_type} bulk uploads"
end end
def view_uploads_button_href
send("bulk_uploads_#{log_type}_logs_path")
end end
end end

27
app/components/search_component.rb

@ -9,17 +9,9 @@ class SearchComponent < ViewComponent::Base
end end
def path(current_user) def path(current_user)
if request.path.include?("organisations") && request.path.include?("users") return request.path if matching_path_conditions?
request.path
elsif request.path.include?("organisations") && request.path.include?("logs") if request.path.include?("users")
request.path
elsif request.path.include?("organisations") && request.path.include?("schemes")
request.path
elsif request.path.include?("organisations") && request.path.include?("stock-owners")
request.path
elsif request.path.include?("organisations") && request.path.include?("managing-agents")
request.path
elsif request.path.include?("users")
user_path(current_user) user_path(current_user)
elsif request.path.include?("organisations") elsif request.path.include?("organisations")
organisations_path organisations_path
@ -35,4 +27,17 @@ private
def user_path(current_user) def user_path(current_user)
current_user.support? ? users_path : users_organisation_path(current_user.organisation) current_user.support? ? users_path : users_organisation_path(current_user.organisation)
end end
def matching_path_conditions?
[
%r{organisations/\d+/users},
%r{organisations/\d+/lettings-logs},
%r{organisations/\d+/sales-logs},
%r{organisations/\d+/schemes},
%r{organisations/\d+/stock-owners},
%r{organisations/\d+/managing-agents},
%r{sales-logs/bulk-uploads},
%r{lettings-logs/bulk-uploads},
].any? { |pattern| request.path.match?(pattern) }
end
end end

1
app/controllers/form_controller.rb

@ -409,6 +409,7 @@ private
next if question.subsection.id == "setup" next if question.subsection.id == "setup"
question.page.questions.map(&:id).each { |id| @log[id] = nil } question.page.questions.map(&:id).each { |id| @log[id] = nil }
@log.previous_la_known = nil if question.id == "ppostcode_full"
end end
@log.save! @log.save!
@questions = params[@log.model_name.param_key].keys.reject { |id| %w[clear_question_ids page].include?(id) }.map { |id| @log.form.get_question(id, @log) } @questions = params[@log.model_name.param_key].keys.reject { |id| %w[clear_question_ids page].include?(id) }.map { |id| @log.form.get_question(id, @log) }

44
app/controllers/lettings_logs_controller.rb

@ -5,8 +5,8 @@ class LettingsLogsController < LogsController
before_action :find_resource, only: %i[update show] before_action :find_resource, only: %i[update show]
before_action :session_filters, if: :current_user, only: %i[index email_csv download_csv] before_action :session_filters, if: :current_user, only: %i[index email_csv download_csv bulk_uploads]
before_action -> { filter_manager.serialize_filters_to_session }, if: :current_user, only: %i[index email_csv download_csv] before_action -> { filter_manager.serialize_filters_to_session }, if: :current_user, only: %i[index email_csv download_csv bulk_uploads]
before_action :authenticate_scope!, only: %i[download_csv email_csv] before_action :authenticate_scope!, only: %i[download_csv email_csv]
before_action :extract_bulk_upload_from_session_filters, only: [:index] before_action :extract_bulk_upload_from_session_filters, only: [:index]
@ -115,6 +115,40 @@ class LettingsLogsController < LogsController
end end
end end
def bulk_uploads
return render_not_authorized unless current_user.support?
@filter_type = "lettings_bulk_uploads"
if params[:organisation_id].present? && params[:clear_old_filters].present?
redirect_to clear_filters_path(filter_type: @filter_type, organisation_id: params[:organisation_id]) and return
end
uploads = BulkUpload.lettings.where("created_at >= ?", 30.days.ago)
unpaginated_filtered_uploads = filter_manager.filtered_uploads(uploads, search_term, filter_manager.session_filters)
@pagy, @bulk_uploads = pagy(unpaginated_filtered_uploads)
@search_term = search_term
@total_count = uploads.size
@searched = search_term.presence
render "bulk_upload_shared/uploads"
end
def download_bulk_upload
return render_not_authorized unless current_user.support?
bulk_upload = BulkUpload.find(params[:id])
downloader = BulkUpload::Downloader.new(bulk_upload:)
if Rails.env.development?
downloader.call
send_file downloader.path, filename: bulk_upload.filename, type: "text/csv"
else
presigned_url = downloader.presigned_url
redirect_to presigned_url, allow_other_host: true
end
end
private private
def session_filters def session_filters
@ -122,7 +156,11 @@ private
end end
def filter_manager def filter_manager
FilterManager.new(current_user:, session:, params:, filter_type: "lettings_logs") if request.path.include?("bulk-uploads")
FilterManager.new(current_user:, session:, params:, filter_type: "lettings_bulk_uploads")
else
FilterManager.new(current_user:, session:, params:, filter_type: "lettings_logs")
end
end end
def authenticate_scope! def authenticate_scope!

44
app/controllers/sales_logs_controller.rb

@ -3,8 +3,8 @@ class SalesLogsController < LogsController
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
before_action :session_filters, if: :current_user, only: %i[index email_csv download_csv] before_action :session_filters, if: :current_user, only: %i[index email_csv download_csv bulk_uploads]
before_action -> { filter_manager.serialize_filters_to_session }, if: :current_user, only: %i[index email_csv download_csv] before_action -> { filter_manager.serialize_filters_to_session }, if: :current_user, only: %i[index email_csv download_csv bulk_uploads]
before_action :authenticate_scope!, only: %i[download_csv email_csv] before_action :authenticate_scope!, only: %i[download_csv email_csv]
before_action :extract_bulk_upload_from_session_filters, only: [:index] before_action :extract_bulk_upload_from_session_filters, only: [:index]
@ -85,6 +85,40 @@ class SalesLogsController < LogsController
params.require(:sales_log).permit(SalesLog.editable_fields) params.require(:sales_log).permit(SalesLog.editable_fields)
end end
def bulk_uploads
return render_not_authorized unless current_user.support?
@filter_type = "sales_bulk_uploads"
if params[:organisation_id].present? && params[:clear_old_filters].present?
redirect_to clear_filters_path(filter_type: @filter_type, organisation_id: params[:organisation_id]) and return
end
uploads = BulkUpload.sales.where("created_at >= ?", 30.days.ago)
unpaginated_filtered_uploads = filter_manager.filtered_uploads(uploads, search_term, session_filters)
@pagy, @bulk_uploads = pagy(unpaginated_filtered_uploads)
@search_term = search_term
@total_count = uploads.size
@searched = search_term.presence
render "bulk_upload_shared/uploads"
end
def download_bulk_upload
return render_not_authorized unless current_user.support?
bulk_upload = BulkUpload.find(params[:id])
downloader = BulkUpload::Downloader.new(bulk_upload:)
if Rails.env.development?
downloader.call
send_file downloader.path, filename: bulk_upload.filename, type: "text/csv"
else
presigned_url = downloader.presigned_url
redirect_to presigned_url, allow_other_host: true
end
end
private private
def session_filters def session_filters
@ -92,7 +126,11 @@ private
end end
def filter_manager def filter_manager
FilterManager.new(current_user:, session:, params:, filter_type: "sales_logs") if request.path.include?("bulk-uploads")
FilterManager.new(current_user:, session:, params:, filter_type: "sales_bulk_uploads")
else
FilterManager.new(current_user:, session:, params:, filter_type: "sales_logs")
end
end end
def extract_bulk_upload_from_session_filters def extract_bulk_upload_from_session_filters

8
app/controllers/sessions_controller.rb

@ -5,6 +5,14 @@ class SessionsController < ApplicationController
if path_params[:organisation_id].present? if path_params[:organisation_id].present?
redirect_to send("#{params[:filter_type]}_organisation_path", id: path_params[:organisation_id], scheme_id: path_params[:scheme_id], search: path_params[:search]) redirect_to send("#{params[:filter_type]}_organisation_path", id: path_params[:organisation_id], scheme_id: path_params[:scheme_id], search: path_params[:search])
elsif params[:filter_type].include?("bulk_uploads")
bulk_upload_type = params[:filter_type].split("_").first
uploading_organisation = params[:organisation_id].presence
if uploading_organisation.present?
redirect_to send("bulk_uploads_#{bulk_upload_type}_logs_path", search: path_params[:search], uploading_organisation:)
else
redirect_to send("bulk_uploads_#{bulk_upload_type}_logs_path", search: path_params[:search])
end
else else
redirect_to send("#{params[:filter_type]}_path", scheme_id: path_params[:scheme_id], search: path_params[:search]) redirect_to send("#{params[:filter_type]}_path", scheme_id: path_params[:scheme_id], search: path_params[:search])
end end

18
app/frontend/styles/_bulk-uploads.scss

@ -0,0 +1,18 @@
.grouped-rows td {
border-top: none;
border-bottom: none;
}
.grouped-rows.first-row td {
border-top: 1px solid #b1b4b6;
}
.grouped-rows.last-row td,
.grouped-rows .grouped-multirow-cell {
border-bottom: 1px solid #b1b4b6;
}
.text-normal-break {
white-space: normal;
word-break: break-all;
}

4
app/frontend/styles/_tag.scss

@ -5,3 +5,7 @@
padding-bottom: 2px; padding-bottom: 2px;
padding-left: 6px; padding-left: 6px;
} }
.no-max-width {
max-width: none;
}

15
app/frontend/styles/application.scss

@ -23,6 +23,7 @@ $govuk-breakpoints: (
@import "govuk-prototype-styles"; @import "govuk-prototype-styles";
@import "accessible-autocomplete"; @import "accessible-autocomplete";
@import "bulk-uploads";
@import "button"; @import "button";
@import "card"; @import "card";
@import "data_box"; @import "data_box";
@ -82,20 +83,6 @@ $govuk-breakpoints: (
border-top: govuk-spacing(2) solid $govuk-brand-colour; border-top: govuk-spacing(2) solid $govuk-brand-colour;
} }
.grouped-rows td {
border-top: none;
border-bottom: none;
}
.grouped-rows.first-row td {
border-top: 1px solid #b1b4b6;
}
.grouped-rows.last-row td,
.grouped-rows .grouped-multirow-cell {
border-bottom: 1px solid #b1b4b6;
}
.govuk-notification-banner__content > * { .govuk-notification-banner__content > * {
max-width: fit-content; max-width: fit-content;
} }

12
app/helpers/bulk_upload_helper.rb

@ -0,0 +1,12 @@
module BulkUploadHelper
def bulk_upload_title(controller_name)
case controller_name
when "lettings_logs"
"Lettings bulk uploads"
when "sales_logs"
"Sales bulk uploads"
else
"Bulk uploads"
end
end
end

62
app/helpers/filters_helper.rb

@ -5,18 +5,21 @@ module FiltersHelper
return false unless session[session_name_for(filter_type)] return false unless session[session_name_for(filter_type)]
selected_filters = JSON.parse(session[session_name_for(filter_type)]) selected_filters = JSON.parse(session[session_name_for(filter_type)])
return true if !selected_filters.key?("user") && filter == "assigned_to" && value == :all
return true if selected_filters["assigned_to"] == "specific_user" && filter == "assigned_to" && value == :specific_user
return true if !selected_filters.key?("owning_organisation") && filter == "owning_organisation_select" && value == :all case filter
return true if !selected_filters.key?("managing_organisation") && filter == "managing_organisation_select" && value == :all when "assigned_to"
assigned_to_filter_selected?(selected_filters, value)
return true if (selected_filters["owning_organisation"].present? || selected_filters["owning_organisation_text_search"].present?) && filter == "owning_organisation_select" && value == :specific_org when "owning_organisation_select"
return true if (selected_filters["managing_organisation"].present? || selected_filters["managing_organisation_text_search"].present?) && filter == "managing_organisation_select" && value == :specific_org owning_organisation_filter_selected?(selected_filters, value)
when "managing_organisation_select"
return false if selected_filters[filter].blank? managing_organisation_filter_selected?(selected_filters, value)
when "uploaded_by"
selected_filters[filter].include?(value.to_s) uploaded_by_filter_selected?(selected_filters, value)
when "uploading_organisation_select"
uploading_organisation_filter_selected?(selected_filters, value)
else
selected_filters[filter]&.include?(value.to_s) || false
end
end end
def any_filter_selected?(filter_type) def any_filter_selected?(filter_type)
@ -119,6 +122,11 @@ module FiltersHelper
[OpenStruct.new(id: "", name: "Select an option", hint: "")] [OpenStruct.new(id: "", name: "Select an option", hint: "")]
end end
def uploaded_by_filter_options
user_options = User.all
[OpenStruct.new(id: "", name: "Select an option", hint: "")] + user_options.map { |user_option| OpenStruct.new(id: user_option.id, name: user_option.name, hint: user_option.email) }
end
def filter_search_url(category) def filter_search_url(category)
case category case category
when :user when :user
@ -270,7 +278,7 @@ private
filters.each.sum do |category, category_filters| filters.each.sum do |category, category_filters|
if %w[years status needstypes bulk_upload_id].include?(category) if %w[years status needstypes bulk_upload_id].include?(category)
category_filters.count(&:present?) category_filters.count(&:present?)
elsif %w[user owning_organisation managing_organisation user_text_search owning_organisation_text_search managing_organisation_text_search].include?(category) elsif %w[user owning_organisation managing_organisation user_text_search owning_organisation_text_search managing_organisation_text_search uploading_organisation].include?(category)
1 1
else else
0 0
@ -335,4 +343,34 @@ private
def unanswered_filter_value def unanswered_filter_value
"<span class=\"app-!-colour-muted\">You didn’t answer this filter</span>".html_safe "<span class=\"app-!-colour-muted\">You didn’t answer this filter</span>".html_safe
end end
def assigned_to_filter_selected?(selected_filters, value)
return true if !selected_filters.key?("user") && value == :all
selected_filters["assigned_to"] == value.to_s
end
def owning_organisation_filter_selected?(selected_filters, value)
return true if !selected_filters.key?("owning_organisation") && value == :all
(selected_filters["owning_organisation"].present? || selected_filters["owning_organisation_text_search"].present?) && value == :specific_org
end
def managing_organisation_filter_selected?(selected_filters, value)
return true if !selected_filters.key?("managing_organisation") && value == :all
(selected_filters["managing_organisation"].present? || selected_filters["managing_organisation_text_search"].present?) && value == :specific_org
end
def uploaded_by_filter_selected?(selected_filters, value)
return true if !selected_filters.key?("user") && value == :all
selected_filters["uploaded_by"] == value.to_s
end
def uploading_organisation_filter_selected?(selected_filters, value)
return true if !selected_filters.key?("uploading_organisation") && value == :all
(selected_filters["uploading_organisation"].present? || selected_filters["uploading_organisation_text_search"].present?) && value == :specific_org
end
end end

16
app/helpers/tag_helper.rb

@ -19,6 +19,14 @@ module TagHelper
request_merged: "Merged", request_merged: "Merged",
ready_to_merge: "Ready to merge", ready_to_merge: "Ready to merge",
processing: "Processing", processing: "Processing",
blank_template: "Blank template",
wrong_template: "Wrong template used",
important_errors: "Errors on important questions in CSV",
critical_errors: "Critical errors in CSV",
potential_errors: "Potential errors in CSV",
logs_uploaded_with_errors: "Logs uploaded with errors",
errors_fixed_in_service: "Errors fixed on site",
logs_uploaded_no_errors: "Logs uploaded with no errors",
}.freeze }.freeze
COLOUR = { COLOUR = {
@ -39,6 +47,14 @@ module TagHelper
request_merged: "green", request_merged: "green",
ready_to_merge: "blue", ready_to_merge: "blue",
processing: "yellow", processing: "yellow",
blank_template: "red",
wrong_template: "red",
important_errors: "red",
critical_errors: "red",
potential_errors: "red",
logs_uploaded_with_errors: "blue",
errors_fixed_in_service: "green",
logs_uploaded_no_errors: "green",
}.freeze }.freeze
def status_tag(status, classes = []) def status_tag(status, classes = [])

50
app/models/bulk_upload.rb

@ -1,6 +1,7 @@
class BulkUpload < ApplicationRecord class BulkUpload < ApplicationRecord
enum log_type: { lettings: "lettings", sales: "sales" } enum log_type: { lettings: "lettings", sales: "sales" }
enum rent_type_fix_status: { not_applied: "not_applied", applied: "applied", not_needed: "not_needed" } enum rent_type_fix_status: { not_applied: "not_applied", applied: "applied", not_needed: "not_needed" }
enum failure_reason: { blank_template: "blank_template", wrong_template: "wrong_template" }
belongs_to :user belongs_to :user
@ -10,12 +11,53 @@ class BulkUpload < ApplicationRecord
has_many :sales_logs has_many :sales_logs
after_initialize :generate_identifier, unless: :identifier after_initialize :generate_identifier, unless: :identifier
after_initialize :initialize_processing, if: :new_record?
scope :search_by_filename, ->(filename) { where("filename ILIKE ?", "%#{filename}%") }
scope :search_by_user_name, ->(name) { where(user_id: User.where("name ILIKE ?", "%#{name}%").select(:id)) }
scope :search_by_user_email, ->(email) { where(user_id: User.where("email ILIKE ?", "%#{email}%").select(:id)) }
scope :search_by_organisation_name, ->(name) { where(organisation_id: Organisation.where("name ILIKE ?", "%#{name}%").select(:id)) }
scope :search_by, lambda { |param|
search_by_filename(param)
.or(search_by_user_name(param))
.or(search_by_user_email(param))
.or(search_by_organisation_name(param))
}
scope :filter_by_id, ->(id) { where(id:) }
scope :filter_by_years, ->(years, _user = nil) { where(year: years) }
scope :filter_by_uploaded_by, ->(user_id, _user = nil) { where(user_id:) }
scope :filter_by_user_text_search, ->(param, _user = nil) { where(user_id: User.search_by(param).select(:id)) }
scope :filter_by_user, ->(user_id, _user = nil) { user_id.present? ? where(user_id:) : all }
scope :filter_by_uploading_organisation, ->(organisation_id, _user = nil) { where(organisation_id:) }
def completed? def completed?
incomplete_logs = logs.where.not(status: "completed") incomplete_logs = logs.where.not(status: "completed")
!incomplete_logs.exists? !incomplete_logs.exists?
end end
def status
return :processing if processing
return :blank_template if failure_reason == "blank_template"
return :wrong_template if failure_reason == "wrong_template"
if logs.visible.exists?
return :errors_fixed_in_service if completed? && bulk_upload_errors.any?
return :logs_uploaded_with_errors if bulk_upload_errors.any?
end
if bulk_upload_errors.important.any?
:important_errors
elsif bulk_upload_errors.critical.any?
:critical_errors
elsif bulk_upload_errors.potential.any?
:potential_errors
else
:logs_uploaded_no_errors
end
end
def year_combo def year_combo
"#{year}/#{year - 2000 + 1}" "#{year}/#{year - 2000 + 1}"
end end
@ -105,9 +147,17 @@ class BulkUpload < ApplicationRecord
User.find_by(id: moved_user_id)&.name User.find_by(id: moved_user_id)&.name
end end
def organisation
Organisation.find_by(id: organisation_id)
end
private private
def generate_identifier def generate_identifier
self.identifier ||= SecureRandom.uuid self.identifier ||= SecureRandom.uuid
end end
def initialize_processing
self.processing = true if processing.nil?
end
end end

4
app/models/bulk_upload_error.rb

@ -4,4 +4,8 @@ class BulkUploadError < ApplicationRecord
scope :order_by_row, -> { order("row::integer ASC") } scope :order_by_row, -> { order("row::integer ASC") }
scope :order_by_cell, -> { order(Arel.sql("LPAD(cell, 10, '0')")) } scope :order_by_cell, -> { order(Arel.sql("LPAD(cell, 10, '0')")) }
scope :order_by_col, -> { order(Arel.sql("LPAD(col, 10, '0')")) } scope :order_by_col, -> { order(Arel.sql("LPAD(col, 10, '0')")) }
scope :important, -> { where(category: "setup") }
scope :potential, -> { where(category: "soft_validation") }
scope :critical, -> { where(category: nil).or(where.not(category: %w[setup soft_validation])) }
scope :critical_or_important, -> { critical.or(important) }
end end

16
app/models/user.rb

@ -224,12 +224,26 @@ class User < ApplicationRecord
def logs_filters(specific_org: false) def logs_filters(specific_org: false)
if (support? && !specific_org) || organisation.has_managing_agents? || organisation.has_stock_owners? if (support? && !specific_org) || organisation.has_managing_agents? || organisation.has_stock_owners?
%w[years status needstypes assigned_to user managing_organisation owning_organisation bulk_upload_id user_text_search owning_organisation_text_search managing_organisation_text_search] %w[years status needstypes assigned_to user owning_organisation managing_organisation bulk_upload_id user_text_search owning_organisation_text_search managing_organisation_text_search]
else else
%w[years status needstypes assigned_to user bulk_upload_id user_text_search] %w[years status needstypes assigned_to user bulk_upload_id user_text_search]
end end
end end
def scheme_filters(specific_org: false)
if (support? && !specific_org) || organisation.has_managing_agents? || organisation.has_stock_owners?
%w[status owning_organisation owning_organisation_text_search]
else
%w[status]
end
end
def bulk_uploads_filters(specific_org: false)
return [] unless support? && !specific_org
%w[user years uploaded_by uploading_organisation user_text_search uploading_organisation_text_search]
end
delegate :name, to: :organisation, prefix: true delegate :name, to: :organisation, prefix: true
def self.download_attributes def self.download_attributes

5
app/models/validations/financial_validations.rb

@ -121,6 +121,11 @@ module Validations::FinancialValidations
def validate_rent_amount(record) def validate_rent_amount(record)
if record.wtshortfall if record.wtshortfall
if record.is_supported_housing? && record.wchchrg && (record.wtshortfall > record.wchchrg)
record.errors.add :tshortfall, message: I18n.t("validations.financial.tshortfall.more_than_carehome_charge")
record.errors.add :chcharge, I18n.t("validations.financial.carehome.less_than_shortfall")
end
if record.wtcharge && (record.wtshortfall > record.wtcharge) if record.wtcharge && (record.wtshortfall > record.wtcharge)
record.errors.add :tshortfall, :more_than_rent, message: I18n.t("validations.financial.tshortfall.more_than_total_charge") record.errors.add :tshortfall, :more_than_rent, message: I18n.t("validations.financial.tshortfall.more_than_total_charge")
record.errors.add :tcharge, I18n.t("validations.financial.tcharge.less_than_shortfall") record.errors.add :tcharge, I18n.t("validations.financial.tcharge.less_than_shortfall")

4
app/services/bulk_upload/downloader.rb

@ -15,6 +15,10 @@ class BulkUpload::Downloader
file.unlink file.unlink
end end
def presigned_url
s3_storage_service.get_presigned_url(bulk_upload.identifier, 60, response_content_disposition: "attachment; filename=#{bulk_upload.filename}")
end
private private
def download def download

8
app/services/bulk_upload/lettings/year2024/row_parser.rb

@ -456,12 +456,12 @@ class BulkUpload::Lettings::Year2024::RowParser
return @valid = true if blank_row? return @valid = true if blank_row?
super(:before_log) super(:before_log)
before_errors = errors.dup @before_errors = errors.dup
log.valid? log.valid?
super(:after_log) super(:after_log)
errors.merge!(before_errors) errors.merge!(@before_errors)
log.errors.each do |error| log.errors.each do |error|
fields = field_mapping_for_errors[error.attribute] || [] fields = field_mapping_for_errors[error.attribute] || []
@ -815,13 +815,13 @@ private
if setup_question?(question) if setup_question?(question)
fields.each do |field| fields.each do |field|
if errors.select { |e| fields.include?(e.attribute) }.none? && field.present? if field.present? && errors.none? { |e| fields.include?(e.attribute) } && @before_errors.none? { |e| fields.include?(e.attribute) }
errors.add(field, question.unanswered_error_message, category: :setup) errors.add(field, question.unanswered_error_message, category: :setup)
end end
end end
else else
fields.each do |field| fields.each do |field|
unless errors.any? { |e| fields.include?(e.attribute) } if errors.none? { |e| fields.include?(e.attribute) } && @before_errors.none? { |e| fields.include?(e.attribute) }
errors.add(field, question.unanswered_error_message) errors.add(field, question.unanswered_error_message)
end end
end end

23
app/services/bulk_upload/processor.rb

@ -1,6 +1,16 @@
class BulkUpload::Processor class BulkUpload::Processor
attr_reader :bulk_upload attr_reader :bulk_upload
BLANK_TEMPLATE_ERRORS = [
I18n.t("activemodel.errors.models.bulk_upload/lettings/validator.attributes.base.blank_file"),
I18n.t("activemodel.errors.models.bulk_upload/sales/validator.attributes.base.blank_file"),
].freeze
WRONG_TEMPLATE_ERRORS = [
*I18n.t("activemodel.errors.models.bulk_upload/lettings/validator.attributes.base", default: {}).values,
*I18n.t("activemodel.errors.models.bulk_upload/sales/validator.attributes.base", default: {}).values,
].freeze
def initialize(bulk_upload:) def initialize(bulk_upload:)
@bulk_upload = bulk_upload @bulk_upload = bulk_upload
end end
@ -11,7 +21,7 @@ class BulkUpload::Processor
download download
@bulk_upload.update!(total_logs_count: validator.total_logs_count) @bulk_upload.update!(total_logs_count: validator.total_logs_count)
return send_failure_mail(errors: validator.errors.full_messages) if validator.invalid? return handle_invalid_validator if validator.invalid?
validator.call validator.call
@ -37,6 +47,7 @@ class BulkUpload::Processor
send_failure_mail send_failure_mail
ensure ensure
downloader.delete_local_file! downloader.delete_local_file!
bulk_upload.update!(processing: false)
end end
def approve def approve
@ -144,4 +155,14 @@ private
raise "Validator not found for #{bulk_upload.log_type}" raise "Validator not found for #{bulk_upload.log_type}"
end end
end end
def handle_invalid_validator
if BLANK_TEMPLATE_ERRORS.any? { |error| validator.errors.full_messages.include?(error) }
@bulk_upload.update!(failure_reason: "blank_template")
elsif WRONG_TEMPLATE_ERRORS.any? { |error| validator.errors.full_messages.include?(error) }
@bulk_upload.update!(failure_reason: "wrong_template")
end
send_failure_mail(errors: validator.errors.full_messages)
end
end end

8
app/services/bulk_upload/sales/year2024/row_parser.rb

@ -504,12 +504,12 @@ class BulkUpload::Sales::Year2024::RowParser
return true if blank_row? return true if blank_row?
super(:before_log) super(:before_log)
before_errors = errors.dup @before_errors = errors.dup
log.valid? log.valid?
super(:after_log) super(:after_log)
errors.merge!(before_errors) errors.merge!(@before_errors)
log.errors.each do |error| log.errors.each do |error|
fields = field_mapping_for_errors[error.attribute] || [] fields = field_mapping_for_errors[error.attribute] || []
@ -1377,13 +1377,13 @@ private
if setup_question?(question) if setup_question?(question)
fields.each do |field| fields.each do |field|
unless errors.any? { |e| fields.include?(e.attribute) } if errors.none? { |e| fields.include?(e.attribute) } && @before_errors.none? { |e| fields.include?(e.attribute) }
errors.add(field, question.unanswered_error_message, category: :setup) errors.add(field, question.unanswered_error_message, category: :setup)
end end
end end
else else
fields.each do |field| fields.each do |field|
unless errors.any? { |e| fields.include?(e.attribute) } if errors.none? { |e| fields.include?(e.attribute) } && @before_errors.none? { |e| fields.include?(e.attribute) }
errors.add(field, question.unanswered_error_message) errors.add(field, question.unanswered_error_message)
end end
end end

32
app/services/filter_manager.rb

@ -76,6 +76,21 @@ class FilterManager
locations.order(created_at: :desc) locations.order(created_at: :desc)
end end
def self.filter_uploads(uploads, search_term, filters, all_orgs, user)
uploads = filter_by_search(uploads, search_term)
filters.each do |category, values|
next if Array(values).reject(&:empty?).blank?
next if category == "uploading_organisation" && all_orgs
next if category == "uploading_organisation_text_search" && all_orgs
next if category == "uploaded_by"
next if category == "uploaded_by_text_search" && filters["uploaded_by"] != "specific_user"
uploads = uploads.public_send("filter_by_#{category}", values, user)
end
uploads.order(created_at: :desc)
end
def serialize_filters_to_session(specific_org: false) def serialize_filters_to_session(specific_org: false)
session[session_name_for(filter_type)] = session_filters(specific_org:).to_json session[session_name_for(filter_type)] = session_filters(specific_org:).to_json
end end
@ -91,7 +106,6 @@ class FilterManager
else else
{} {}
end end
if filter_type.include?("logs") if filter_type.include?("logs")
current_user.logs_filters(specific_org:).each do |filter| current_user.logs_filters(specific_org:).each do |filter|
new_filters[filter] = params[filter] if params[filter].present? new_filters[filter] = params[filter] if params[filter].present?
@ -117,13 +131,21 @@ class FilterManager
end end
if filter_type.include?("schemes") if filter_type.include?("schemes")
current_user.logs_filters(specific_org:).each do |filter| current_user.scheme_filters(specific_org:).each do |filter|
new_filters[filter] = params[filter] if params[filter].present? new_filters[filter] = params[filter] if params[filter].present?
end end
new_filters = new_filters.except("owning_organisation") if params["owning_organisation_select"] == "all" new_filters = new_filters.except("owning_organisation") if params["owning_organisation_select"] == "all"
end end
if filter_type.include?("bulk_uploads")
current_user.bulk_uploads_filters(specific_org:).each do |filter|
new_filters[filter] = params[filter] if params[filter].present?
end
new_filters = new_filters.except("uploading_organisation") if params["uploading_organisation_select"] == "all"
new_filters = new_filters.except("user") if params["uploaded_by"] == "all"
new_filters["user"] = current_user.id.to_s if params["uploaded_by"] == "you"
end
new_filters new_filters
end end
@ -152,6 +174,12 @@ class FilterManager
@bulk_upload ||= current_user.bulk_uploads.find_by(id:) @bulk_upload ||= current_user.bulk_uploads.find_by(id:)
end end
def filtered_uploads(uploads, search_term, filters)
all_orgs = params["uploading_organisation_select"] == "all"
FilterManager.filter_uploads(uploads, search_term, filters, all_orgs, current_user)
end
private private
def logs_filters def logs_filters

4
app/services/storage/s3_service.rb

@ -20,10 +20,10 @@ module Storage
response.key_count == 1 response.key_count == 1
end end
def get_presigned_url(file_name, duration) def get_presigned_url(file_name, duration, response_content_disposition: nil)
Aws::S3::Presigner Aws::S3::Presigner
.new({ client: @client }) .new({ client: @client })
.presigned_url(:get_object, bucket: @configuration.bucket_name, key: file_name, expires_in: duration) .presigned_url(:get_object, bucket: @configuration.bucket_name, key: file_name, expires_in: duration, response_content_disposition:)
end end
def get_file_io(file_name) def get_file_io(file_name)

9
app/views/bulk_upload_lettings_results/show.html.erb

@ -13,7 +13,14 @@
Here’s a list of everything that you need to fix your spreadsheet. You can download the <%= govuk_link_to "specification", Forms::BulkUploadLettings::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file. Here’s a list of everything that you need to fix your spreadsheet. You can download the <%= govuk_link_to "specification", Forms::BulkUploadLettings::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file.
</div> </div>
<h2 class="govuk-heading-m">File: <%= @bulk_upload.filename %></h2> <p class="govuk-!-font-size-19 govuk-!-margin-bottom-2"><strong>File name: </strong><%= @bulk_upload.filename %></p>
<% if current_user.support? %>
<div class="govuk-!-margin-bottom-7">
<%= govuk_link_to "Download file", download_lettings_bulk_upload_path(@bulk_upload) %>
</div>
<% end %>
</div> </div>
</div> </div>

13
app/views/bulk_upload_lettings_results/summary.html.erb

@ -5,13 +5,18 @@
<span class="govuk-caption-l">Bulk upload for lettings (<%= @bulk_upload.year_combo %>)</span> <span class="govuk-caption-l">Bulk upload for lettings (<%= @bulk_upload.year_combo %>)</span>
<h1 class="govuk-heading-l">Fix <%= pluralize(@bulk_upload.bulk_upload_errors.count, "error") %> and upload file again</h1> <h1 class="govuk-heading-l">Fix <%= pluralize(@bulk_upload.bulk_upload_errors.count, "error") %> and upload file again</h1>
<p class="govuk-body-l"> <p class="govuk-body">
We could not create logs from your bulk upload because of the following errors. Download the <%= govuk_link_to "specification", Forms::BulkUploadLettings::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file. We could not create logs from your bulk upload because of the following errors. Download the <%= govuk_link_to "specification", Forms::BulkUploadLettings::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file.
</p> </p>
<p class="govuk-body-l"> <p class="govuk-!-font-size-19 govuk-!-margin-bottom-2"><strong>File name: </strong><%= @bulk_upload.filename %></p>
File: <%= @bulk_upload.filename %>
</p> <% if current_user.support? %>
<div class="govuk-!-margin-bottom-7">
<%= govuk_link_to "Download file", download_lettings_bulk_upload_path(@bulk_upload) %>
</div>
<% end %>
</div> </div>
</div> </div>

9
app/views/bulk_upload_sales_results/show.html.erb

@ -13,7 +13,14 @@
Here’s a list of everything that you need to fix your spreadsheet. You can download the <%= govuk_link_to "specification", Forms::BulkUploadSales::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file. Here’s a list of everything that you need to fix your spreadsheet. You can download the <%= govuk_link_to "specification", Forms::BulkUploadSales::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file.
</div> </div>
<h2 class="govuk-heading-m">File: <%= @bulk_upload.filename %></h2> <p class="govuk-!-font-size-19 govuk-!-margin-bottom-2"><strong>File name: </strong><%= @bulk_upload.filename %></p>
<% if current_user.support? %>
<div class="govuk-!-margin-bottom-7">
<%= govuk_link_to "Download file", download_sales_bulk_upload_path(@bulk_upload) %>
</div>
<% end %>
</div> </div>
</div> </div>

13
app/views/bulk_upload_sales_results/summary.html.erb

@ -5,13 +5,18 @@
<span class="govuk-caption-l">Bulk upload for sales (<%= @bulk_upload.year_combo %>)</span> <span class="govuk-caption-l">Bulk upload for sales (<%= @bulk_upload.year_combo %>)</span>
<h1 class="govuk-heading-l">Fix <%= pluralize(@bulk_upload.bulk_upload_errors.count, "error") %> and upload file again</h1> <h1 class="govuk-heading-l">Fix <%= pluralize(@bulk_upload.bulk_upload_errors.count, "error") %> and upload file again</h1>
<p class="govuk-body-l"> <p class="govuk-body">
We could not create logs from your bulk upload because of the following errors. Download the <%= govuk_link_to "specification", Forms::BulkUploadSales::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file. We could not create logs from your bulk upload because of the following errors. Download the <%= govuk_link_to "specification", Forms::BulkUploadSales::PrepareYourFile.new(year: @bulk_upload.year).specification_path, target: "_blank" %> to help you fix the cells in your CSV file.
</p> </p>
<p class="govuk-body-l"> <p class="govuk-!-font-size-19 govuk-!-margin-bottom-2"><strong>File name: </strong><%= @bulk_upload.filename %></p>
File: <%= @bulk_upload.filename %>
</p> <% if current_user.support? %>
<div class="govuk-!-margin-bottom-7">
<%= govuk_link_to "Download file", download_sales_bulk_upload_path(@bulk_upload) %>
</div>
<% end %>
</div> </div>
</div> </div>

78
app/views/bulk_upload_shared/_upload_filters.html.erb

@ -0,0 +1,78 @@
<div class="app-filter-layout__filter">
<div class="app-filter">
<div class="app-filter__header">
<h2 class="govuk-heading-m">Filters</h2>
</div>
<div class="app-filter__content">
<%= form_with html: { method: :get } do |f| %>
<div class="govuk-grid-row" style="white-space: nowrap">
<p class="govuk-grid-column-one-half">
<%= filters_applied_text(@filter_type) %>
</p>
<p class="govuk-!-text-align-right govuk-grid-column-one-half">
<%= reset_filters_link(@filter_type, { search: request.params["search"] }.compact) %>
</p>
</div>
<%= render partial: "filters/checkbox_filter",
locals: {
f:,
options: collection_year_options,
label: "Collection year",
category: "years",
size: "s",
} %>
<%= render partial: "filters/radio_filter",
locals: {
f:,
options: {
"all": { label: "Any user" },
"you": { label: "You" },
"specific_user": {
label: "Specific user",
conditional_filter: {
type: "text_select",
label: "User",
category: "user",
options: uploaded_by_filter_options,
caption_text: "User's name or email",
},
},
},
label: "Uploaded by",
category: "uploaded_by",
size: "s",
} %>
<%= render partial: "filters/radio_filter", locals: {
f:,
options: {
"all": { label: "Any organisation" },
"specific_org": {
label: "Specific organisation",
conditional_filter: {
type: "select",
label: "Uploading Organisation",
category: "uploading_organisation",
options: all_owning_organisation_filter_options(current_user),
caption_text: "Organisation name",
},
},
},
label: "Uploading organisation",
category: "uploading_organisation_select",
size: "s",
} %>
<% if request.params["search"].present? %>
<%= f.hidden_field :search, value: request.params["search"] %>
<% end %>
<%= f.govuk_submit "Apply filters", class: "govuk-!-margin-bottom-0" %>
<% end %>
</div>
</div>
</div>

15
app/views/bulk_upload_shared/_upload_list.html.erb

@ -0,0 +1,15 @@
<h2 class="govuk-body">
<div class="govuk-grid-row app-search__caption">
<div class="govuk-grid-column-three-quarters">
<%= render(SearchResultCaptionComponent.new(searched:, count: pagy.count, item_label:, total_count:, item: "files uploaded in the last 30 days", filters_count: applied_filters_count(@filter_type))) %>
</div>
<div class="govuk-grid-column-one-quarter govuk-!-text-align-right">
<% if searched || applied_filters_count(@filter_type).positive? %>
<br>
<% end %>
</div>
</div>
</h2>
<% bulk_uploads.map do |bulk_upload| %>
<%= render BulkUploadSummaryComponent.new(bulk_upload:) %>
<% end %>

28
app/views/bulk_upload_shared/uploads.html.erb

@ -0,0 +1,28 @@
<% item_label = format_label(@pagy.count, "uploads") %>
<% title = format_title(@searched, bulk_upload_title(controller.controller_name), current_user, item_label, @pagy.count, nil) %>
<% content_for :title, title %>
<h1 class="govuk-heading-l govuk-!-margin-bottom-7">
<%= bulk_upload_title(controller.controller_name) %>
</h1>
<div class="app-filter-layout" data-controller="filter-layout">
<%= render partial: "bulk_upload_shared/upload_filters" %>
<div class="app-filter-layout__content">
<%= render SearchComponent.new(current_user:, search_label: "Search by file name, user's name or email, or organisation", value: @searched) %>
<%= govuk_section_break(visible: true, size: "m") %>
<%= render partial: "bulk_upload_shared/upload_list",
locals: {
bulk_uploads: @bulk_uploads,
title: "Bulk uploads",
pagy: @pagy,
searched: @searched,
item_label:,
total_count: @total_count,
filter_type: @filter_type,
} %>
<%== render partial: "pagy/nav", locals: { pagy: @pagy, item_name: "bulk uploads" } %>
</div>
</div>

3
app/views/layouts/application.html.erb

@ -60,9 +60,6 @@
</script> </script>
<% end %> <% end %>
<% if Rails.env.production? && ENV["APP_HOST"].present? %>
<script defer data-domain="<%= ENV["APP_HOST"].split("https://")[1] %>" src="https://plausible.io/js/plausible.js"></script>
<% end %>
</head> </head>
<body class="govuk-template__body app-template--wide"> <body class="govuk-template__body app-template--wide">

10
app/views/logs/_create_for_org_actions.html.erb

@ -1,12 +1,14 @@
<div class="govuk-button-group app-filter-toggle"> <div class="govuk-button-group app-filter-toggle">
<% if @organisation.data_protection_confirmed? %> <% if @organisation.data_protection_confirmed? %>
<% if current_page?(controller: 'organisations', action: 'lettings_logs') %> <% if current_page?(controller: 'organisations', action: 'lettings_logs') %>
<%= govuk_button_to "Create a new lettings log for this organisation", lettings_logs_path(lettings_log: { owning_organisation_id: @organisation.id }, method: :post), class: "govuk-!-margin-right-6" %> <%= govuk_button_to "Create a new lettings log", lettings_logs_path(lettings_log: { owning_organisation_id: @organisation.id }, method: :post), class: "govuk-!-margin-right-6" %>
<%= govuk_button_link_to "Upload lettings logs in bulk", bulk_upload_lettings_log_path(id: "start", organisation_id: @organisation.id), secondary: true %> <%= govuk_button_link_to "Upload lettings logs in bulk", bulk_upload_lettings_log_path(id: "start", organisation_id: @organisation.id), secondary: true, class: "govuk-!-margin-right-6" %>
<%= govuk_button_link_to "View lettings bulk uploads", bulk_uploads_lettings_logs_path(organisation_id: @organisation.id, clear_old_filters: true), secondary: true %>
<% end %> <% end %>
<% if current_page?(controller: 'organisations', action: 'sales_logs') %> <% if current_page?(controller: 'organisations', action: 'sales_logs') %>
<%= govuk_button_to "Create a new sales log for this organisation", sales_logs_path(sales_log: { owning_organisation_id: @organisation.id }, method: :post), class: "govuk-!-margin-right-6" %> <%= govuk_button_to "Create a new sales log", sales_logs_path(sales_log: { owning_organisation_id: @organisation.id }, method: :post), class: "govuk-!-margin-right-6" %>
<%= govuk_button_link_to "Upload sales logs in bulk", bulk_upload_sales_log_path(id: "start", organisation_id: @organisation.id), secondary: true %> <%= govuk_button_link_to "Upload sales logs in bulk", bulk_upload_sales_log_path(id: "start", organisation_id: @organisation.id), secondary: true, class: "govuk-!-margin-right-6" %>
<%= govuk_button_link_to "View sales bulk uploads", bulk_uploads_sales_logs_path(organisation_id: @organisation.id, clear_old_filters: true), secondary: true %>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>

2
app/views/organisations/index.html.erb

@ -6,7 +6,7 @@
<%= render partial: "organisations/headings", locals: request.path == organisations_path ? { main: "Organisations", sub: nil } : { main: @organisation.name, sub: "Organisations" } %> <%= render partial: "organisations/headings", locals: request.path == organisations_path ? { main: "Organisations", sub: nil } : { main: @organisation.name, sub: "Organisations" } %>
<div class="app-tab__list-view" data-controller="tabs"> <div class="app-tab__list-view" data-controller="tabs">
<%= govuk_tabs(title: "Collection resources", classes: %w[app-tab__large-headers]) do |c| %> <%= govuk_tabs(title: "Organisations", classes: %w[app-tab__large-headers]) do |c| %>
<% c.with_tab(label: "All organisations") do %> <% c.with_tab(label: "All organisations") do %>
<%= govuk_button_link_to "Create a new organisation", new_organisation_path, html: { method: :get } %> <%= govuk_button_link_to "Create a new organisation", new_organisation_path, html: { method: :get } %>
<%= render SearchComponent.new(current_user:, search_label: "Search by organisation name", value: @searched) %> <%= render SearchComponent.new(current_user:, search_label: "Search by organisation name", value: @searched) %>

2
config/locales/en.yml

@ -392,6 +392,7 @@ en:
tshortfall: tshortfall:
outstanding_amount_not_expected: "You cannot answer the outstanding amount question if you don’t have outstanding rent or charges." outstanding_amount_not_expected: "You cannot answer the outstanding amount question if you don’t have outstanding rent or charges."
more_than_total_charge: "Enter a value less than the total charge." more_than_total_charge: "Enter a value less than the total charge."
more_than_carehome_charge: "Enter a value less than the care home charge."
must_be_positive: "Enter a value over £0.01 as you told us there is an outstanding amount." must_be_positive: "Enter a value over £0.01 as you told us there is an outstanding amount."
hbrentshortfall: hbrentshortfall:
outstanding_amount_not_expected: "Answer must be ‘yes’ as you have answered the outstanding amount question." outstanding_amount_not_expected: "Answer must be ‘yes’ as you have answered the outstanding amount question."
@ -464,6 +465,7 @@ en:
carehome: carehome:
out_of_range: "Household rent and other charges must be between %{min_chcharge} and %{max_chcharge} if paying %{period}." out_of_range: "Household rent and other charges must be between %{min_chcharge} and %{max_chcharge} if paying %{period}."
not_provided: "Enter how much rent and other charges the household pays %{period}." not_provided: "Enter how much rent and other charges the household pays %{period}."
less_than_shortfall: "The care home charge must be more than the outstanding amount."
cash_discount_invalid: "Cash discount must be £0 - £999,999." cash_discount_invalid: "Cash discount must be £0 - £999,999."
staircasing: staircasing:
percentage_bought_must_be_greater_than_percentage_owned: "Total percentage %{buyer_now_owns} must be more than percentage bought in this transaction." percentage_bought_must_be_greater_than_percentage_owned: "Total percentage %{buyer_now_owns} must be more than percentage bought in this transaction."

14
config/routes.rb

@ -232,6 +232,7 @@ Rails.application.routes.draw do
get "csv-download", to: "lettings_logs#download_csv" get "csv-download", to: "lettings_logs#download_csv"
post "email-csv", to: "lettings_logs#email_csv" post "email-csv", to: "lettings_logs#email_csv"
get "csv-confirmation", to: "lettings_logs#csv_confirmation" get "csv-confirmation", to: "lettings_logs#csv_confirmation"
get "bulk-uploads", to: "lettings_logs#bulk_uploads"
get "delete-logs", to: "delete_logs#delete_lettings_logs" get "delete-logs", to: "delete_logs#delete_lettings_logs"
post "delete-logs", to: "delete_logs#delete_lettings_logs_with_selected_ids" post "delete-logs", to: "delete_logs#delete_lettings_logs_with_selected_ids"
@ -273,6 +274,12 @@ Rails.application.routes.draw do
end end
end end
resources :bulk_uploads, path: "bulk-uploads", only: [] do
member do
get "download", to: "lettings_logs#download_bulk_upload", as: "download_lettings"
end
end
get "update-logs", to: "lettings_logs#update_logs" get "update-logs", to: "lettings_logs#update_logs"
end end
@ -304,6 +311,7 @@ Rails.application.routes.draw do
get "csv-download", to: "sales_logs#download_csv" get "csv-download", to: "sales_logs#download_csv"
post "email-csv", to: "sales_logs#email_csv" post "email-csv", to: "sales_logs#email_csv"
get "csv-confirmation", to: "sales_logs#csv_confirmation" get "csv-confirmation", to: "sales_logs#csv_confirmation"
get "bulk-uploads", to: "sales_logs#bulk_uploads"
get "delete-logs", to: "delete_logs#delete_sales_logs" get "delete-logs", to: "delete_logs#delete_sales_logs"
post "delete-logs", to: "delete_logs#delete_sales_logs_with_selected_ids" post "delete-logs", to: "delete_logs#delete_sales_logs_with_selected_ids"
@ -344,6 +352,12 @@ Rails.application.routes.draw do
patch "*page", to: "bulk_upload_sales_soft_validations_check#update" patch "*page", to: "bulk_upload_sales_soft_validations_check#update"
end end
end end
resources :bulk_uploads, path: "bulk-uploads", only: [] do
member do
get "download", to: "sales_logs#download_bulk_upload", as: "download-sales"
end
end
end end
member do member do

8
db/migrate/20241002163937_add_failure_reason_and_processing_to_bulk_uploads.rb

@ -0,0 +1,8 @@
class AddFailureReasonAndProcessingToBulkUploads < ActiveRecord::Migration[7.0]
def change
change_table :bulk_uploads, bulk: true do |t|
t.string :failure_reason
t.boolean :processing
end
end
end

4
db/schema.rb

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_09_23_145326) do ActiveRecord::Schema[7.0].define(version: 2024_10_02_163937) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -44,6 +44,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_23_145326) do
t.string "rent_type_fix_status", default: "not_applied" t.string "rent_type_fix_status", default: "not_applied"
t.integer "organisation_id" t.integer "organisation_id"
t.integer "moved_user_id" t.integer "moved_user_id"
t.string "failure_reason"
t.boolean "processing"
t.index ["identifier"], name: "index_bulk_uploads_on_identifier", unique: true t.index ["identifier"], name: "index_bulk_uploads_on_identifier", unique: true
t.index ["user_id"], name: "index_bulk_uploads_on_user_id" t.index ["user_id"], name: "index_bulk_uploads_on_user_id"
end end

148
spec/components/bulk_upload_summary_component_spec.rb

@ -0,0 +1,148 @@
require "rails_helper"
RSpec.describe BulkUploadSummaryComponent, type: :component do
let(:user) { create(:user) }
let(:support_user) { create(:user, :support) }
let(:bulk_upload) { create(:bulk_upload, :lettings, user:, year: 2024, total_logs_count: 10) }
it "shows the file name" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content(bulk_upload.filename)
end
it "shows the collection year" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("2024/2025")
end
it "includes a download file link" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_link("Download file", href: "/lettings-logs/bulk-uploads/#{bulk_upload.id}/download")
end
it "shows the total log count" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("10 total logs")
end
it "shows the uploaded by user" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Uploaded by: #{bulk_upload.user.name}")
end
it "shows the uploading organisation" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Uploading organisation: #{bulk_upload.user.organisation.name}")
end
it "shows the time of upload" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Time of upload: #{bulk_upload.created_at.to_formatted_s(:govuk_date_and_time)}")
end
context "when bulk upload has only critical errors" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: nil) }
let(:bulk_upload) { create(:bulk_upload, :lettings, user:, bulk_upload_errors:, total_logs_count: 10) }
it "shows the critical errors status and error count" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Critical errors in CSV")
expect(result).to have_content("2 critical errors")
expect(result).to have_no_content("errors on important")
expect(result).to have_no_content("potential")
end
it "includes a view error report link" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_link("View error report", href: "/lettings-logs/bulk-upload-results/#{bulk_upload.id}")
end
end
context "when bulk upload has only potential errors" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: "soft_validation") }
let(:bulk_upload) { create(:bulk_upload, :lettings, user:, bulk_upload_errors:, total_logs_count: 16) }
it "shows the potential errors status and error count" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Potential errors in CSV")
expect(result).to have_content("2 potential errors")
expect(result).to have_content("16 total logs")
expect(result).to have_no_content("errors on important")
expect(result).to have_no_content("critical")
end
end
context "when bulk upload has only errors on important questions" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: "setup") }
let(:bulk_upload) { create(:bulk_upload, :lettings, user:, bulk_upload_errors:, total_logs_count: 16) }
it "shows the errors on important questions status and error count" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Errors on important questions in CSV")
expect(result).to have_content("2 errors on important questions")
expect(result).to have_content("16 total logs")
expect(result).to have_no_content("potential")
expect(result).to have_no_content("critical")
end
it "includes a view error report link to the summary page" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_link("View error report", href: %r{.*/lettings-logs/bulk-upload-results/#{bulk_upload.id}/summary})
end
end
context "when a bulk upload is uploaded with no errors" do
let(:bulk_upload) { create(:bulk_upload, :sales, user:, total_logs_count: 1) }
it "shows the logs uploaded with no errors status and no error counts" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Logs uploaded with no errors")
expect(result).to have_content("1 total log")
expect(result).to have_no_content("important questions")
expect(result).to have_no_content("potential")
expect(result).to have_no_content("critical")
end
end
context "when a bulk upload is uploaded with errors" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 1) }
let(:bulk_upload) { create(:bulk_upload, :sales, user:, bulk_upload_errors:, total_logs_count: 21) }
before do
create_list(:sales_log, 21, bulk_upload:)
end
it "shows the logs upload with errors status and error count" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Logs uploaded with errors")
expect(result).to have_content("21 total logs")
expect(result).to have_content("1 critical error")
expect(result).to have_no_content("important questions")
expect(result).to have_no_content("potential")
end
end
context "when a bulk upload uses the wrong template" do
let(:bulk_upload) { create(:bulk_upload, :sales, user:, failure_reason: "wrong_template") }
it "shows the wrong template status and no error counts" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Wrong template")
expect(result).to have_no_content("important questions")
expect(result).to have_no_content("potential")
expect(result).to have_no_content("critical")
end
end
context "when a bulk upload uses a blank template" do
let(:bulk_upload) { create(:bulk_upload, :sales, user:, failure_reason: "blank_template") }
it "shows the wrong template status and no error counts" do
result = render_inline(described_class.new(bulk_upload:))
expect(result).to have_content("Blank template")
expect(result).to have_no_content("important questions")
expect(result).to have_no_content("potential")
expect(result).to have_no_content("critical")
end
end
end

28
spec/components/create_log_actions_component_spec.rb

@ -37,13 +37,23 @@ RSpec.describe CreateLogActionsComponent, type: :component do
expect(component.create_button_href).to eq("/lettings-logs") expect(component.create_button_href).to eq("/lettings-logs")
end end
it "returns upload button copy" do it "does not show the upload button" do
expect(component.upload_button_copy).to eq("Upload lettings logs in bulk") render_inline(component)
expect(rendered_content).not_to have_link("Upload lettings logs in bulk", href: "/lettings-logs/bulk-upload-logs/start")
end end
it "returns upload button href" do it "returns view uploads button copy" do
expect(component.view_uploads_button_copy).to eq("View lettings bulk uploads")
end
it "returns view uploads button href" do
render render
expect(component.upload_button_href).to eq("/lettings-logs/bulk-upload-logs/start") expect(component.view_uploads_button_href).to eq("/lettings-logs/bulk-uploads")
end
it "shows the view uploads button" do
render_inline(component)
expect(rendered_content).to have_link("View lettings bulk uploads", href: "/lettings-logs/bulk-uploads")
end end
context "when sales log type" do context "when sales log type" do
@ -61,6 +71,16 @@ RSpec.describe CreateLogActionsComponent, type: :component do
render render
expect(component.create_button_href).to eq("/sales-logs") expect(component.create_button_href).to eq("/sales-logs")
end end
it "does not show the upload button" do
render_inline(component)
expect(rendered_content).not_to have_link("Upload sales logs in bulk", href: "/sales-logs/bulk-upload-logs/start")
end
it "shows the view uploads button" do
render_inline(component)
expect(rendered_content).to have_link("View sales bulk uploads", href: "/sales-logs/bulk-uploads")
end
end end
end end

2
spec/factories/bulk_upload.rb

@ -10,6 +10,8 @@ FactoryBot.define do
needstype { 1 } needstype { 1 }
rent_type_fix_status { BulkUpload.rent_type_fix_statuses.values.sample } rent_type_fix_status { BulkUpload.rent_type_fix_statuses.values.sample }
organisation_id { user.organisation_id } organisation_id { user.organisation_id }
total_logs_count { Faker::Number.number(digits: 2) }
processing { false }
trait(:sales) do trait(:sales) do
log_type { BulkUpload.log_types[:sales] } log_type { BulkUpload.log_types[:sales] }

1
spec/factories/bulk_upload_error.rb

@ -11,5 +11,6 @@ FactoryBot.define do
purchaser_code { SecureRandom.hex(4) } purchaser_code { SecureRandom.hex(4) }
field { "field_#{rand(1..134)}" } field { "field_#{rand(1..134)}" }
error { "some error" } error { "some error" }
category { nil }
end end
end end

5
spec/features/accessibility_spec.rb

@ -110,6 +110,7 @@ RSpec.describe "Accessibility", js: true do
log.save(validate: false) log.save(validate: false)
end end
allow(FormHandler.instance).to receive(:in_crossover_period?).and_return(true) allow(FormHandler.instance).to receive(:in_crossover_period?).and_return(true)
allow(storage_service).to receive(:get_presigned_url).with(bulk_upload.identifier, 60, response_content_disposition: "attachment; filename=#{bulk_upload.filename}").and_return("http://example.com/lettings-logs/bulk-uploads/#{bulk_upload.id}/download")
end end
it "is has accessible pages" do it "is has accessible pages" do
@ -148,6 +149,10 @@ RSpec.describe "Accessibility", js: true do
}.uniq }.uniq
end end
before do
allow(storage_service).to receive(:get_presigned_url).with(bulk_upload.identifier, 60, response_content_disposition: "attachment; filename=#{bulk_upload.filename}").and_return("http://example.com/sales-logs/bulk-uploads/#{bulk_upload.id}/download")
end
it "is has accessible pages" do it "is has accessible pages" do
sales_log_paths.each do |path| sales_log_paths.each do |path|
path += "?original_log_id=#{sales_log.id}" if path.include?("duplicate") path += "?original_log_id=#{sales_log.id}" if path.include?("duplicate")

31
spec/features/lettings_log_spec.rb

@ -415,6 +415,37 @@ RSpec.describe "Lettings Log Features" do
expect(deleted_log.status).to eq "deleted" expect(deleted_log.status).to eq "deleted"
expect(deleted_log.discarded_at).not_to be nil expect(deleted_log.discarded_at).not_to be nil
end end
context "when visiting the bulk uploads page" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: nil) }
let(:bulk_upload) { create(:bulk_upload, :lettings, user: support_user, bulk_upload_errors:, total_logs_count: 10) }
let(:mock_storage_service) { instance_double("S3Service") }
before do
allow(Storage::S3Service).to receive(:new).and_return(mock_storage_service)
allow(mock_storage_service).to receive(:get_presigned_url).with(bulk_upload.identifier, 60, response_content_disposition: "attachment; filename=#{bulk_upload.filename}").and_return("/lettings-logs/bulk-uploads")
bulk_upload
visit("/lettings-logs/bulk-uploads")
end
it "displays the right title" do
expect(page).to have_content("Lettings bulk uploads")
end
it "shows the bulk upload file name" do
expect(page).to have_content(bulk_upload.filename)
end
it "redirects to the error report page when clicking 'View error report'" do
click_link("View error report")
expect(page).to have_current_path("/lettings-logs/bulk-upload-results/#{bulk_upload.id}")
end
it "allows the user to download the file" do
click_link("Download file", href: "/lettings-logs/bulk-uploads/#{bulk_upload.id}/download")
expect(page).to have_current_path("/lettings-logs/bulk-uploads")
end
end
end end
context "when the signed is user is not a Support user" do context "when the signed is user is not a Support user" do

8
spec/features/organisation_spec.rb

@ -139,7 +139,7 @@ RSpec.describe "User Features" do
end end
it "shows a create button for that organisation" do it "shows a create button for that organisation" do
expect(page).to have_button("Create a new lettings log for this organisation") expect(page).to have_button("Create a new lettings log")
end end
it "shows a upload lettings logs in bulk link" do it "shows a upload lettings logs in bulk link" do
@ -148,7 +148,7 @@ RSpec.describe "User Features" do
context "when creating a log for that organisation" do context "when creating a log for that organisation" do
it "pre-fills the value for owning organisation for that log" do it "pre-fills the value for owning organisation for that log" do
click_button("Create a new lettings log for this organisation") click_button("Create a new lettings log")
click_link("Set up this lettings log") click_link("Set up this lettings log")
expect(page).to have_content(org_name) expect(page).to have_content(org_name)
end end
@ -234,7 +234,7 @@ RSpec.describe "User Features" do
end end
it "shows a create button for that organisation" do it "shows a create button for that organisation" do
expect(page).to have_button("Create a new sales log for this organisation") expect(page).to have_button("Create a new sales log")
end end
it "shows a upload sales logs in bulk link" do it "shows a upload sales logs in bulk link" do
@ -243,7 +243,7 @@ RSpec.describe "User Features" do
context "when creating a log for that organisation" do context "when creating a log for that organisation" do
it "pre-fills the value for owning organisation for that log" do it "pre-fills the value for owning organisation for that log" do
click_button("Create a new sales log for this organisation") click_button("Create a new sales log")
click_link("Set up this sales log") click_link("Set up this sales log")
expect(page).to have_content(org_name) expect(page).to have_content(org_name)
end end

31
spec/features/sales_log_spec.rb

@ -279,6 +279,37 @@ RSpec.describe "Sales Log Features" do
expect(breadcrumbs[2][:href]).to eq sales_log_path(sales_log.id) expect(breadcrumbs[2][:href]).to eq sales_log_path(sales_log.id)
end end
end end
context "when visiting the bulk uploads page" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: nil) }
let(:bulk_upload) { create(:bulk_upload, :sales, user:, bulk_upload_errors:, total_logs_count: 10) }
let(:mock_storage_service) { instance_double("S3Service") }
before do
allow(Storage::S3Service).to receive(:new).and_return(mock_storage_service)
allow(mock_storage_service).to receive(:get_presigned_url).with(bulk_upload.identifier, 60, response_content_disposition: "attachment; filename=#{bulk_upload.filename}").and_return("/sales-logs/bulk-uploads")
bulk_upload
visit("/sales-logs/bulk-uploads")
end
it "displays the right title" do
expect(page).to have_content("Sales bulk uploads")
end
it "shows the bulk upload file name" do
expect(page).to have_content(bulk_upload.filename)
end
it "redirects to the error report page when clicking 'View error report'" do
click_link("View error report")
expect(page).to have_current_path("/sales-logs/bulk-upload-results/#{bulk_upload.id}")
end
it "allows the user to download the file" do
click_link("Download file", href: "/sales-logs/bulk-uploads/#{bulk_upload.id}/download")
expect(page).to have_current_path("/sales-logs/bulk-uploads")
end
end
end end
context "when a log becomes a duplicate" do context "when a log becomes a duplicate" do

208
spec/models/bulk_upload_spec.rb

@ -46,9 +46,215 @@ RSpec.describe BulkUpload, type: :model do
let(:bulk_upload) { build(:bulk_upload, year: test_case[:year]) } let(:bulk_upload) { build(:bulk_upload, year: test_case[:year]) }
it "returns the expected year combination string" do it "returns the expected year combination string" do
expect(bulk_upload.year_combo).to eql(test_case[:expected_value]) expect(bulk_upload.year_combo).to eq(test_case[:expected_value])
end end
end end
end end
end end
describe "scopes" do
let!(:lettings_bulk_upload_1) { create(:bulk_upload, log_type: "lettings") }
let!(:lettings_bulk_upload_2) { create(:bulk_upload, log_type: "lettings") }
let!(:sales_bulk_upload_1) { create(:bulk_upload, log_type: "sales") }
let!(:sales_bulk_upload_2) { create(:bulk_upload, log_type: "sales") }
describe ".lettings" do
it "returns only lettings bulk uploads" do
expect(described_class.lettings).to match_array([lettings_bulk_upload_1, lettings_bulk_upload_2])
end
end
describe ".sales" do
it "returns only sales bulk uploads" do
expect(described_class.sales).to match_array([sales_bulk_upload_1, sales_bulk_upload_2])
end
end
describe ".search_by_filename" do
it "returns the correct bulk upload" do
expect(described_class.search_by_filename(lettings_bulk_upload_1.filename).first).to eq(lettings_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.search_by_filename(lettings_bulk_upload_1.filename).first).not_to eq(lettings_bulk_upload_2)
end
end
describe ".search_by_user_name" do
it "returns the correct bulk upload" do
expect(described_class.search_by_user_name(lettings_bulk_upload_1.user.name).first).to eq(lettings_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.search_by_user_name(lettings_bulk_upload_1.user.name).first).not_to eq(lettings_bulk_upload_2)
end
end
describe ".search_by_user_email" do
it "returns the correct bulk upload" do
expect(described_class.search_by_user_email(sales_bulk_upload_1.user.email).first).to eq(sales_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.search_by_user_email(sales_bulk_upload_1.user.email).first).not_to eq(sales_bulk_upload_2)
end
end
describe ".search_by_organisation_name" do
it "returns the correct bulk upload" do
expect(described_class.search_by_organisation_name(lettings_bulk_upload_1.user.organisation.name).first).to eq(lettings_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.search_by_organisation_name(lettings_bulk_upload_1.user.organisation.name).first).not_to eq(lettings_bulk_upload_2)
end
end
describe ".filter_by_id" do
it "returns the correct bulk upload" do
expect(described_class.filter_by_id(lettings_bulk_upload_1.id).first).to eq(lettings_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.filter_by_id(lettings_bulk_upload_1.id).first).not_to eq(lettings_bulk_upload_2)
end
end
describe ".filter_by_years" do
it "returns the correct bulk upload" do
expect(described_class.filter_by_years([lettings_bulk_upload_1.year]).first).to eq(lettings_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.filter_by_years([lettings_bulk_upload_1.year]).first).not_to eq(lettings_bulk_upload_2)
end
end
describe ".filter_by_uploaded_by" do
it "returns the correct bulk upload" do
expect(described_class.filter_by_uploaded_by(sales_bulk_upload_1.user.id).first).to eq(sales_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.filter_by_uploaded_by(sales_bulk_upload_1.user.id).first).not_to eq(sales_bulk_upload_2)
end
end
describe ".filter_by_user_text_search" do
it "returns the correct bulk upload" do
expect(described_class.filter_by_user_text_search(lettings_bulk_upload_1.user.name).first).to eq(lettings_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.filter_by_user_text_search(lettings_bulk_upload_1.user.name).first).not_to eq(lettings_bulk_upload_2)
end
end
describe ".filter_by_user" do
it "returns the correct bulk upload" do
expect(described_class.filter_by_user(sales_bulk_upload_1.user.id).first).to eq(sales_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.filter_by_user(sales_bulk_upload_1.user.id).first).not_to eq(sales_bulk_upload_2)
end
end
describe ".filter_by_uploading_organisation" do
it "returns the correct bulk upload" do
expect(described_class.filter_by_uploading_organisation(lettings_bulk_upload_1.user.organisation.id).first).to eq(lettings_bulk_upload_1)
end
it "does not return the incorrect bulk upload" do
expect(described_class.filter_by_uploading_organisation(lettings_bulk_upload_1.user.organisation.id).first).not_to eq(lettings_bulk_upload_2)
end
end
end
describe "#status" do
context "when the bulk upload was uploaded with a blank template" do
let(:bulk_upload) { create(:bulk_upload, failure_reason: "blank_template") }
it "returns the correct status" do
expect(bulk_upload.status).to eq(:blank_template)
end
end
context "when the bulk upload was uploaded with the wrong template" do
let(:bulk_upload) { create(:bulk_upload, failure_reason: "wrong_template") }
it "returns the correct status" do
expect(bulk_upload.status).to eq(:wrong_template)
end
end
context "when the bulk upload is processing" do
let(:bulk_upload) { create(:bulk_upload, processing: true) }
it "returns the correct status" do
expect(bulk_upload.status).to eq(:processing)
end
end
context "when the bulk upload has potential errors" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: "soft_validation") }
let(:bulk_upload) { create(:bulk_upload, bulk_upload_errors:) }
it "returns the correct status" do
expect(bulk_upload.status).to eq(:potential_errors)
end
end
context "when the bulk upload has critical errors" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: nil) }
let(:bulk_upload) { create(:bulk_upload, bulk_upload_errors:) }
it "returns the correct status" do
expect(bulk_upload.status).to eq(:critical_errors)
end
end
context "when the bulk upload has important errors" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: "setup") }
let(:bulk_upload) { create(:bulk_upload, bulk_upload_errors:) }
it "returns the correct status" do
expect(bulk_upload.status).to eq(:important_errors)
end
end
context "when the bulk upload has no errors" do
let(:bulk_upload) { create(:bulk_upload) }
it "returns the correct status" do
expect(bulk_upload.status).to eq(:logs_uploaded_no_errors)
end
end
context "when the bulk upload has visible logs, errors and is not complete" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: "soft_validation") }
let(:bulk_upload) { create(:bulk_upload, :lettings, bulk_upload_errors:) }
before do
create(:lettings_log, :in_progress, bulk_upload:)
end
it "returns logs_uploaded_with_errors" do
expect(bulk_upload.status).to eq(:logs_uploaded_with_errors)
end
end
context "when the bulk upload has visible logs, errors and is complete" do
let(:bulk_upload_errors) { create_list(:bulk_upload_error, 2, category: "soft_validation") }
let(:bulk_upload) { create(:bulk_upload, :lettings, bulk_upload_errors:) }
before do
create(:lettings_log, :completed, bulk_upload:)
end
it "returns errors_fixed_in_service" do
expect(bulk_upload.status).to eq(:errors_fixed_in_service)
end
end
end
end end

12
spec/models/user_spec.rb

@ -176,6 +176,10 @@ RSpec.describe User, type: :model do
it "can filter lettings logs by user, year, status, managing_organisation and owning_organisation" do it "can filter lettings logs by user, year, status, managing_organisation and owning_organisation" do
expect(user.logs_filters).to match_array(%w[years status needstypes assigned_to user managing_organisation owning_organisation bulk_upload_id managing_organisation_text_search owning_organisation_text_search user_text_search]) expect(user.logs_filters).to match_array(%w[years status needstypes assigned_to user managing_organisation owning_organisation bulk_upload_id managing_organisation_text_search owning_organisation_text_search user_text_search])
end end
it "can filter schemes by status and owning_organisation" do
expect(user.scheme_filters).to match_array(%w[status owning_organisation owning_organisation_text_search])
end
end end
end end
@ -217,6 +221,14 @@ RSpec.describe User, type: :model do
it "can filter lettings logs by user, year, status, managing_organisation and owning_organisation" do it "can filter lettings logs by user, year, status, managing_organisation and owning_organisation" do
expect(user.logs_filters).to match_array(%w[years status needstypes assigned_to user owning_organisation managing_organisation bulk_upload_id managing_organisation_text_search owning_organisation_text_search user_text_search]) expect(user.logs_filters).to match_array(%w[years status needstypes assigned_to user owning_organisation managing_organisation bulk_upload_id managing_organisation_text_search owning_organisation_text_search user_text_search])
end end
it "can filter bulk uploads by year, uploaded_by and uploading_organisation " do
expect(user.bulk_uploads_filters).to match_array(%w[user years uploaded_by uploading_organisation user_text_search uploading_organisation_text_search])
end
it "can filter schemes by status and owning_organisation" do
expect(user.scheme_filters).to match_array(%w[status owning_organisation owning_organisation_text_search])
end
end end
context "when the user is in development environment" do context "when the user is in development environment" do

16
spec/models/validations/financial_validations_spec.rb

@ -123,6 +123,22 @@ RSpec.describe Validations::FinancialValidations do
.to include(match I18n.t("validations.financial.tshortfall.more_than_total_charge")) .to include(match I18n.t("validations.financial.tshortfall.more_than_total_charge"))
end end
it "validates that carehome charge is no less than the shortfall" do
record.hb = 6
record.hbrentshortfall = 1
record.tshortfall_known = 0
record.tshortfall = 299.50
record.chcharge = 198
record.needstype = 2
record.period = 2
record.set_derived_fields!
financial_validator.validate_rent_amount(record)
expect(record.errors["chcharge"])
.to include(match I18n.t("validations.financial.carehome.less_than_shortfall"))
expect(record.errors["tshortfall"])
.to include(match I18n.t("validations.financial.tshortfall.more_than_carehome_charge"))
end
it "expects that rent can be less than the shortfall if total charge is higher" do it "expects that rent can be less than the shortfall if total charge is higher" do
record.hb = 6 record.hb = 6
record.hbrentshortfall = 1 record.hbrentshortfall = 1

27
spec/requests/check_errors_controller_spec.rb

@ -300,6 +300,33 @@ RSpec.describe CheckErrorsController, type: :request do
end end
end end
context "and clearing ppostcode_full when previous_la_known is yes" do
let(:params) do
{
id: lettings_log.id,
lettings_log: {
layear: "1",
clear_question_ids: "ppostcode_full",
page: "time_lived_in_local_authority",
},
check_errors: "",
}
end
before do
lettings_log.update!(previous_la_known: 1, ppcodenk: 0, ppostcode_full: "AA11AA")
sign_in user
post "/lettings-logs/#{lettings_log.id}/time-lived-in-local-authority", params:
end
it "clears related previous location fields" do
expect(lettings_log.reload.prevloc).to eq(nil)
expect(lettings_log.reload.previous_la_known).to eq(nil)
expect(lettings_log.reload.ppostcode_full).to eq(nil)
expect(lettings_log.reload.ppcodenk).to eq(nil)
end
end
context "and clearing specific sales question" do context "and clearing specific sales question" do
let(:params) do let(:params) do
{ {

12
spec/services/bulk_upload/downloader_spec.rb

@ -45,4 +45,16 @@ RSpec.describe BulkUpload::Downloader do
expect(File).not_to exist(path) expect(File).not_to exist(path)
end end
end end
describe "#presigned_url" do
let(:mock_storage_service) { instance_double(Storage::S3Service, get_presigned_url: "https://example.com") }
before do
allow(Storage::S3Service).to receive(:new).and_return(mock_storage_service)
end
it "returns a presigned URL" do
expect(downloader.presigned_url).to eql("https://example.com")
end
end
end end

2
spec/services/bulk_upload/lettings/year2023/row_parser_spec.rb

@ -215,7 +215,7 @@ RSpec.describe BulkUpload::Lettings::Year2023::RowParser do
field_131: "101.11", field_131: "101.11",
field_132: "1500.19", field_132: "1500.19",
field_133: "1", field_133: "1",
field_134: "234.56", field_134: "34.56",
field_27: "15", field_27: "15",
field_28: "0", field_28: "0",

12
spec/services/bulk_upload/lettings/year2024/row_parser_spec.rb

@ -235,7 +235,7 @@ RSpec.describe BulkUpload::Lettings::Year2024::RowParser do
field_127: "13.14", field_127: "13.14",
field_128: "101.11", field_128: "101.11",
field_129: "1", field_129: "1",
field_130: "234.56", field_130: "34.56",
field_24: "15", field_24: "15",
field_30: now.day.to_s, field_30: now.day.to_s,
@ -782,6 +782,16 @@ RSpec.describe BulkUpload::Lettings::Year2024::RowParser do
expect(parser.errors[:field_112]).to eql(["You must answer was the letting made under the Choice-Based Lettings (CBL)?"]) expect(parser.errors[:field_112]).to eql(["You must answer was the letting made under the Choice-Based Lettings (CBL)?"])
end end
end end
context "when an invalid value error has been added" do
let(:attributes) { setup_section_params.merge({ field_116: "100" }) }
it "does not add an additional error" do
parser.valid?
expect(parser.errors[:field_116].length).to eq(1)
expect(parser.errors[:field_116]).to include(match "Enter a valid value for")
end
end
end end
end end

7
spec/services/bulk_upload/processor_spec.rb

@ -43,6 +43,13 @@ RSpec.describe BulkUpload::Processor do
end end
describe "#call" do describe "#call" do
it "changes processing from true to false" do
bulk_upload.update!(processing: true)
expect {
processor.call
}.to change { bulk_upload.reload.processing }.from(true).to(false)
end
context "when errors exist from prior job run" do context "when errors exist from prior job run" do
let!(:existing_error) { create(:bulk_upload_error, bulk_upload:) } let!(:existing_error) { create(:bulk_upload_error, bulk_upload:) }

10
spec/services/bulk_upload/sales/year2024/row_parser_spec.rb

@ -313,6 +313,16 @@ RSpec.describe BulkUpload::Sales::Year2024::RowParser do
expect(parser.errors[:field_23]).to eql(["You must answer address line 1"]) expect(parser.errors[:field_23]).to eql(["You must answer address line 1"])
end end
end end
context "when an invalid value error has been added" do
let(:attributes) { setup_section_params.merge({ field_35: "100" }) }
it "does not add an additional error" do
parser.valid?
expect(parser.errors[:field_35].length).to eq(1)
expect(parser.errors[:field_35]).to include(match "Enter a valid value for")
end
end
end end
end end

3
spec/views/bulk_upload_lettings_results/show.html.erb_spec.rb

@ -1,10 +1,12 @@
require "rails_helper" require "rails_helper"
RSpec.describe "bulk_upload_lettings_results/show.html.erb" do RSpec.describe "bulk_upload_lettings_results/show.html.erb" do
let(:user) { create(:user) }
let(:bulk_upload) { create(:bulk_upload, :lettings) } let(:bulk_upload) { create(:bulk_upload, :lettings) }
context "when mutiple rows in wrong order" do context "when mutiple rows in wrong order" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C") create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C")
create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D") create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D")
end end
@ -22,6 +24,7 @@ RSpec.describe "bulk_upload_lettings_results/show.html.erb" do
context "when 1 row with 2 errors" do context "when 1 row with 2 errors" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA") create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA")
create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z") create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z")
end end

3
spec/views/bulk_upload_lettings_results/summary.html.erb_spec.rb

@ -1,10 +1,12 @@
require "rails_helper" require "rails_helper"
RSpec.describe "bulk_upload_lettings_results/summary.html.erb" do RSpec.describe "bulk_upload_lettings_results/summary.html.erb" do
let(:user) { create(:user) }
let(:bulk_upload) { create(:bulk_upload, :lettings) } let(:bulk_upload) { create(:bulk_upload, :lettings) }
context "when mutiple rows in wrong order" do context "when mutiple rows in wrong order" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C") create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C")
create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D") create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D")
end end
@ -22,6 +24,7 @@ RSpec.describe "bulk_upload_lettings_results/summary.html.erb" do
context "when 1 row with 2 errors" do context "when 1 row with 2 errors" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA") create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA")
create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z") create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z")
end end

3
spec/views/bulk_upload_sales_results/show.html.erb_spec.rb

@ -1,10 +1,12 @@
require "rails_helper" require "rails_helper"
RSpec.describe "bulk_upload_sales_results/show.html.erb" do RSpec.describe "bulk_upload_sales_results/show.html.erb" do
let(:user) { create(:user) }
let(:bulk_upload) { create(:bulk_upload, :sales) } let(:bulk_upload) { create(:bulk_upload, :sales) }
context "when mutiple rows in wrong order" do context "when mutiple rows in wrong order" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C") create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C")
create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D") create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D")
end end
@ -22,6 +24,7 @@ RSpec.describe "bulk_upload_sales_results/show.html.erb" do
context "when 1 row with 2 errors" do context "when 1 row with 2 errors" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA") create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA")
create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z") create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z")
end end

3
spec/views/bulk_upload_sales_results/summary.html.erb_spec.rb

@ -1,10 +1,12 @@
require "rails_helper" require "rails_helper"
RSpec.describe "bulk_upload_sales_results/summary.html.erb" do RSpec.describe "bulk_upload_sales_results/summary.html.erb" do
let(:user) { create(:user) }
let(:bulk_upload) { create(:bulk_upload, :sales) } let(:bulk_upload) { create(:bulk_upload, :sales) }
context "when mutiple rows in wrong order" do context "when mutiple rows in wrong order" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C") create(:bulk_upload_error, bulk_upload:, cell: "C14", row: "14", col: "C")
create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D") create(:bulk_upload_error, bulk_upload:, cell: "D10", row: "10", col: "D")
end end
@ -22,6 +24,7 @@ RSpec.describe "bulk_upload_sales_results/summary.html.erb" do
context "when 1 row with 2 errors" do context "when 1 row with 2 errors" do
before do before do
allow(view).to receive(:current_user).and_return(user)
create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA") create(:bulk_upload_error, bulk_upload:, cell: "AA100", row: "100", col: "AA")
create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z") create(:bulk_upload_error, bulk_upload:, cell: "Z100", row: "100", col: "Z")
end end

8
spec/views/logs/_create_for_org_actions.html.erb_spec.rb

@ -14,8 +14,8 @@ RSpec.describe "logs/_create_for_org_actions.html.erb" do
context "with data sharing agreement" do context "with data sharing agreement" do
it "does include create log buttons" do it "does include create log buttons" do
render render
expect(fragment).to have_button("Create a new lettings log for this organisation") expect(fragment).to have_button("Create a new lettings log")
expect(fragment).to have_button("Create a new sales log for this organisation") expect(fragment).to have_button("Create a new sales log")
end end
end end
@ -24,8 +24,8 @@ RSpec.describe "logs/_create_for_org_actions.html.erb" do
it "does not include create log buttons" do it "does not include create log buttons" do
render render
expect(fragment).not_to have_button("Create a new lettings log for this organisation") expect(fragment).not_to have_button("Create a new lettings log")
expect(fragment).not_to have_button("Create a new sales log for this organisation") expect(fragment).not_to have_button("Create a new sales log")
end end
end end
end end

Loading…
Cancel
Save