Browse Source

Merge branch 'main' into CLDC-3758-Add-shared-ownership-staircasing-transaction

# Conflicts:
#	app/models/derived_variables/sales_log_variables.rb
#	app/models/form/sales/pages/value_shared_ownership.rb
#	app/models/form/sales/questions/value.rb
#	app/models/form/sales/sections/sale_information.rb
#	config/locales/forms/2025/sales/sale_information.en.yml
#	db/schema.rb
pull/2788/head
Manny Dinssa 11 months ago
parent
commit
2b63801939
  1. 4
      Gemfile
  2. 5
      app/components/create_log_actions_component.html.erb
  3. 8
      app/components/create_log_actions_component.rb
  4. 27
      app/controllers/csv_downloads_controller.rb
  5. 14
      app/controllers/lettings_logs_controller.rb
  6. 1
      app/controllers/merge_requests_controller.rb
  7. 14
      app/controllers/sales_logs_controller.rb
  8. 2
      app/controllers/schemes_controller.rb
  9. 4
      app/helpers/merge_requests_helper.rb
  10. 13
      app/jobs/email_csv_job.rb
  11. 13
      app/jobs/scheme_email_csv_job.rb
  12. 10
      app/models/csv_download.rb
  13. 5
      app/models/derived_variables/lettings_log_variables.rb
  14. 5
      app/models/derived_variables/sales_log_variables.rb
  15. 1
      app/models/form/lettings/subsections/household_needs.rb
  16. 2
      app/models/form/page.rb
  17. 2
      app/models/form/question.rb
  18. 2
      app/models/form/sales/pages/buyer_interview.rb
  19. 1
      app/models/form/sales/pages/deposit.rb
  20. 1
      app/models/form/sales/pages/deposit_discount.rb
  21. 1
      app/models/form/sales/pages/discount.rb
  22. 1
      app/models/form/sales/pages/equity.rb
  23. 13
      app/models/form/sales/pages/estate_management_fee.rb
  24. 1
      app/models/form/sales/pages/extra_borrowing.rb
  25. 1
      app/models/form/sales/pages/grant.rb
  26. 12
      app/models/form/sales/pages/living_before_purchase.rb
  27. 1
      app/models/form/sales/pages/monthly_rent.rb
  28. 1
      app/models/form/sales/pages/mortgage_amount.rb
  29. 1
      app/models/form/sales/pages/mortgage_lender.rb
  30. 1
      app/models/form/sales/pages/mortgage_lender_other.rb
  31. 1
      app/models/form/sales/pages/mortgage_length.rb
  32. 1
      app/models/form/sales/pages/mortgageused.rb
  33. 1
      app/models/form/sales/pages/previous_bedrooms.rb
  34. 1
      app/models/form/sales/pages/previous_property_type.rb
  35. 1
      app/models/form/sales/pages/previous_tenure.rb
  36. 2
      app/models/form/sales/pages/privacy_notice.rb
  37. 1
      app/models/form/sales/pages/resale.rb
  38. 2
      app/models/form/sales/pages/staircase.rb
  39. 2
      app/models/form/sales/questions/buyer_interview.rb
  40. 1
      app/models/form/sales/questions/deposit_amount.rb
  41. 1
      app/models/form/sales/questions/deposit_discount.rb
  42. 1
      app/models/form/sales/questions/discount.rb
  43. 1
      app/models/form/sales/questions/equity.rb
  44. 1
      app/models/form/sales/questions/extra_borrowing.rb
  45. 1
      app/models/form/sales/questions/fromprop.rb
  46. 1
      app/models/form/sales/questions/grant.rb
  47. 24
      app/models/form/sales/questions/has_management_fee.rb
  48. 12
      app/models/form/sales/questions/management_fee.rb
  49. 1
      app/models/form/sales/questions/monthly_rent.rb
  50. 1
      app/models/form/sales/questions/mortgage_amount.rb
  51. 1
      app/models/form/sales/questions/mortgage_lender.rb
  52. 1
      app/models/form/sales/questions/mortgage_lender_other.rb
  53. 1
      app/models/form/sales/questions/mortgage_length.rb
  54. 1
      app/models/form/sales/questions/mortgageused.rb
  55. 1
      app/models/form/sales/questions/previous_bedrooms.rb
  56. 1
      app/models/form/sales/questions/previous_tenure.rb
  57. 2
      app/models/form/sales/questions/privacy_notice.rb
  58. 1
      app/models/form/sales/questions/resale.rb
  59. 1
      app/models/form/sales/sections/sale_information.rb
  60. 1
      app/models/form/sales/subsections/discounted_ownership_scheme.rb
  61. 1
      app/models/form/sales/subsections/outright_sale.rb
  62. 48
      app/models/form/sales/subsections/shared_ownership_initial_purchase.rb
  63. 2
      app/models/form/sales/subsections/shared_ownership_scheme.rb
  64. 7
      app/models/merge_request_organisation.rb
  65. 16
      app/policies/csv_download_policy.rb
  66. 2
      app/services/bulk_upload/lettings/year2024/csv_parser.rb
  67. 2
      app/services/bulk_upload/sales/year2024/csv_parser.rb
  68. 50
      app/services/csv/downloader.rb
  69. 4
      app/services/feature_toggle.rb
  70. 2
      app/services/mandatory_collection_resources_service.rb
  71. 10
      app/views/csv_downloads/show.html.erb
  72. 8
      app/views/errors/download_link_expired.html.erb
  73. 6
      app/views/form/guidance/_financial_calculations_shared_ownership.html.erb
  74. 8
      app/views/merge_requests/_notification_banners.html.erb
  75. 2
      app/views/merge_requests/show.html.erb
  76. 4
      config/locales/en.yml
  77. 2
      config/locales/forms/2024/lettings/setup.en.yml
  78. 25
      config/locales/forms/2025/sales/sale_information.en.yml
  79. 12
      config/routes.rb
  80. 8
      db/migrate/20241114154215_add_management_fee_fields.rb
  81. 12
      db/migrate/20241118104046_add_csv_download_table.rb
  82. 16
      db/schema.rb
  83. 2
      db/seeds.rb
  84. 1
      docs/Gemfile.lock
  85. 2
      docs/app_api.md
  86. 16
      docs/exports.md
  87. 182
      docs/form/builder.md
  88. 35
      docs/form/definition.md
  89. 12
      docs/form/index.md
  90. 67
      docs/form/page.md
  91. 86
      docs/form/question.md
  92. 30
      docs/form/section.md
  93. 32
      docs/form/subsection.md
  94. 14
      docs/setup.md
  95. 2
      spec/factories/collection_resource.rb
  96. 9
      spec/factories/csv_download.rb
  97. 5
      spec/features/accessibility_spec.rb
  98. 2
      spec/features/schemes_spec.rb
  99. 14
      spec/helpers/collection_resources_helper_spec.rb
  100. 25
      spec/jobs/email_csv_job_spec.rb
  101. Some files were not shown because too many files have changed in this diff Show More

4
Gemfile

@ -62,6 +62,8 @@ gem "possessive"
# Strip whitespace from active record attributes
gem "auto_strip_attributes"
# Use sidekiq for background processing
gem "factory_bot_rails"
gem "faker"
gem "method_source", "~> 1.1"
gem "rails_admin", "~> 3.1"
gem "ruby-openai"
@ -75,8 +77,6 @@ group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem "byebug", platforms: %i[mri mingw x64_mingw]
gem "dotenv-rails"
gem "factory_bot_rails"
gem "faker"
gem "pry-byebug"
gem "parallel_tests"

5
app/components/create_log_actions_component.html.erb

@ -7,5 +7,10 @@
<% if user.support? %>
<%= govuk_button_link_to view_uploads_button_copy, view_uploads_button_href, secondary: true %>
<% end %>
<% if FeatureToggle.create_test_logs_enabled? %>
<%= govuk_button_link_to "Create test log", create_test_log_href, secondary: true %>
<%= govuk_button_link_to "Create test log (setup only)", create_setup_test_log_href, secondary: true %>
<% end %>
<% end %>
</div>

8
app/components/create_log_actions_component.rb

@ -34,6 +34,14 @@ class CreateLogActionsComponent < ViewComponent::Base
send("bulk_upload_#{log_type}_log_path", id: "start")
end
def create_test_log_href
send("create_test_#{log_type}_log_path")
end
def create_setup_test_log_href
send("create_setup_test_#{log_type}_log_path")
end
def view_uploads_button_copy
"View #{log_type} bulk uploads"
end

27
app/controllers/csv_downloads_controller.rb

@ -0,0 +1,27 @@
class CsvDownloadsController < ApplicationController
before_action :authenticate_user!
def show
@csv_download = CsvDownload.find(params[:id])
authorize @csv_download
return render "errors/download_link_expired" if @csv_download.expired?
end
def download
csv_download = CsvDownload.find(params[:id])
authorize csv_download
return render "errors/download_link_expired" if csv_download.expired?
downloader = Csv::Downloader.new(csv_download:)
if Rails.env.development?
downloader.call
send_file downloader.path, filename: csv_download.filename, type: "text/csv"
else
presigned_url = downloader.presigned_url
redirect_to presigned_url, allow_other_host: true
end
end
end

14
app/controllers/lettings_logs_controller.rb

@ -149,6 +149,20 @@ class LettingsLogsController < LogsController
end
end
def create_test_log
return render_not_found unless FeatureToggle.create_test_logs_enabled?
log = FactoryBot.create(:lettings_log, :completed, assigned_to: current_user, ppostcode_full: "SW1A 1AA")
redirect_to lettings_log_path(log)
end
def create_setup_test_log
return render_not_found unless FeatureToggle.create_test_logs_enabled?
log = FactoryBot.create(:lettings_log, :setup_completed, assigned_to: current_user)
redirect_to lettings_log_path(log)
end
private
def session_filters

1
app/controllers/merge_requests_controller.rb

@ -143,6 +143,7 @@ private
if [day, month, year].none?(&:blank?) && Date.valid_date?(year.to_i, month.to_i, day.to_i)
merge_request_params["merge_date"] = Time.zone.local(year.to_i, month.to_i, day.to_i)
@merge_request.errors.add(:merge_date, :more_than_year_from_today) if Time.zone.local(year.to_i, month.to_i, day.to_i) - 1.year > Time.zone.today
else
@merge_request.errors.add(:merge_date, :invalid)
end

14
app/controllers/sales_logs_controller.rb

@ -119,6 +119,20 @@ class SalesLogsController < LogsController
end
end
def create_test_log
return render_not_found unless FeatureToggle.create_test_logs_enabled?
log = FactoryBot.create(:sales_log, :completed, assigned_to: current_user)
redirect_to sales_log_path(log)
end
def create_setup_test_log
return render_not_found unless FeatureToggle.create_test_logs_enabled?
log = FactoryBot.create(:sales_log, :shared_ownership_setup_complete, assigned_to: current_user)
redirect_to sales_log_path(log)
end
private
def session_filters

2
app/controllers/schemes_controller.rb

@ -152,7 +152,7 @@ class SchemesController < ApplicationController
flash[:notice] = if scheme_previously_confirmed
"#{@scheme.service_name} has been updated."
else
"#{@scheme.service_name} has been created. It does not require helpdesk approval."
"#{@scheme.service_name} has been created."
end
redirect_to scheme_path(@scheme)
end

4
app/helpers/merge_requests_helper.rb

@ -276,4 +276,8 @@ module MergeRequestsHelper
def any_organisations_share_logs?(organisations, type)
organisations.any? { |organisation| organisation.send("#{type}_logs").filter_by_managing_organisation(organisations.where.not(id: organisation.id)).exists? }
end
def begin_merge_disabled?(merge_request)
merge_request.status != "ready_to_merge" || merge_request.merge_date.future?
end
end

13
app/jobs/email_csv_job.rb

@ -1,9 +1,10 @@
class EmailCsvJob < ApplicationJob
include Rails.application.routes.url_helpers
queue_as :default
BYTE_ORDER_MARK = "\uFEFF".freeze # Required to ensure Excel always reads CSV as UTF-8
EXPIRATION_TIME = 24.hours.to_i
EXPIRATION_TIME = 48.hours.to_i
def perform(user, search_term = nil, filters = {}, all_orgs = false, organisation = nil, codes_only_export = false, log_type = "lettings", year = nil) # rubocop:disable Style/OptionalBooleanParameter - sidekiq can't serialise named params
export_type = codes_only_export ? "codes" : "labels"
@ -20,10 +21,16 @@ class EmailCsvJob < ApplicationJob
filename = "#{[log_type, 'logs', organisation&.name, Time.zone.now].compact.join('-')}.csv"
storage_service = Storage::S3Service.new(Configuration::EnvConfigurationService.new, ENV["BULK_UPLOAD_BUCKET"])
storage_service = if FeatureToggle.upload_enabled?
Storage::S3Service.new(Configuration::EnvConfigurationService.new, ENV["BULK_UPLOAD_BUCKET"])
else
Storage::LocalDiskService.new
end
storage_service.write_file(filename, BYTE_ORDER_MARK + csv_string)
csv_download = CsvDownload.create!(user:, organisation: user.organisation, filename:, download_type: log_type, expiration_time: EXPIRATION_TIME)
url = storage_service.get_presigned_url(filename, EXPIRATION_TIME)
url = csv_download_url(csv_download.id, host: ENV["APP_HOST"])
CsvDownloadMailer.new.send_csv_download_mail(user, url, EXPIRATION_TIME)
end

13
app/jobs/scheme_email_csv_job.rb

@ -1,9 +1,10 @@
class SchemeEmailCsvJob < ApplicationJob
include Rails.application.routes.url_helpers
queue_as :default
BYTE_ORDER_MARK = "\uFEFF".freeze # Required to ensure Excel always reads CSV as UTF-8
EXPIRATION_TIME = 24.hours.to_i
EXPIRATION_TIME = 48.hours.to_i
def perform(user, search_term = nil, filters = {}, all_orgs = false, organisation = nil, download_type = "combined") # rubocop:disable Style/OptionalBooleanParameter - sidekiq can't serialise named params
unfiltered_schemes = if organisation.present? && user.support?
@ -23,10 +24,16 @@ class SchemeEmailCsvJob < ApplicationJob
filename = "#{['schemes-and-locations', organisation&.name, Time.zone.now].compact.join('-')}.csv"
end
storage_service = Storage::S3Service.new(Configuration::EnvConfigurationService.new, ENV["BULK_UPLOAD_BUCKET"])
storage_service = if FeatureToggle.upload_enabled?
Storage::S3Service.new(Configuration::EnvConfigurationService.new, ENV["BULK_UPLOAD_BUCKET"])
else
Storage::LocalDiskService.new
end
storage_service.write_file(filename, BYTE_ORDER_MARK + csv_string)
csv_download = CsvDownload.create!(user:, organisation: user.organisation, filename:, download_type:, expiration_time: EXPIRATION_TIME)
url = storage_service.get_presigned_url(filename, EXPIRATION_TIME)
url = csv_download_url(csv_download.id, host: ENV["APP_HOST"])
CsvDownloadMailer.new.send_csv_download_mail(user, url, EXPIRATION_TIME)
end

10
app/models/csv_download.rb

@ -0,0 +1,10 @@
class CsvDownload < ApplicationRecord
enum download_type: { lettings: "lettings", sales: "sales", schemes: "schemes", locations: "locations", combined: "combined" }
belongs_to :user
belongs_to :organisation
def expired?
created_at < expiration_time.seconds.ago
end
end

5
app/models/derived_variables/lettings_log_variables.rb

@ -124,6 +124,11 @@ module DerivedVariables::LettingsLogVariables
self.nationality_all = nationality_all_group if nationality_uk_or_prefers_not_to_say?
if startdate_changed? && !LocalAuthority.active(startdate).where(code: la).exists?
self.la = nil
self.is_la_inferred = false
end
reset_address_fields! if is_supported_housing?
end

5
app/models/derived_variables/sales_log_variables.rb

@ -75,6 +75,11 @@ module DerivedVariables::SalesLogVariables
self.nationality_all = nationality_all_group if nationality_uk_or_prefers_not_to_say?
self.nationality_all_buyer2 = nationality_all_buyer2_group if nationality2_uk_or_prefers_not_to_say?
if saledate_changed? && !LocalAuthority.active(saledate).where(code: la).exists?
self.la = nil
self.is_la_inferred = false
end
self.numstair = is_firststair? ? 1 : nil if numstair == 1 && firststair_changed?
self.mrent = 0 if stairowned_100?

1
app/models/form/lettings/subsections/household_needs.rb

@ -2,7 +2,6 @@ class Form::Lettings::Subsections::HouseholdNeeds < ::Form::Subsection
def initialize(id, hsh, section)
super
@id = "household_needs"
@copy_key = "lettings.household_needs.housingneeds_type"
@label = "Household needs"
@depends_on = [{ "non_location_setup_questions_completed?" => true }]
end

2
app/models/form/page.rb

@ -25,7 +25,7 @@ class Form::Page
delegate :form, to: :subsection
def copy_key
@copy_key ||= "#{form.type}.#{subsection.id}.#{questions[0].id}"
@copy_key ||= "#{form.type}.#{subsection.copy_key}.#{questions[0].id}"
end
def header

2
app/models/form/question.rb

@ -51,7 +51,7 @@ class Form::Question
delegate :form, to: :subsection
def copy_key
@copy_key ||= "#{form.type}.#{subsection.id}.#{id}"
@copy_key ||= "#{form.type}.#{subsection.copy_key}.#{id}"
end
def check_answer_label

2
app/models/form/sales/pages/buyer_interview.rb

@ -2,7 +2,7 @@ class Form::Sales::Pages::BuyerInterview < ::Form::Page
def initialize(id, hsh, subsection, joint_purchase:)
super(id, hsh, subsection)
@joint_purchase = joint_purchase
@copy_key = "sales.#{subsection.id}.noint.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
@copy_key = "sales.#{subsection.copy_key}.noint.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
end
def questions

1
app/models/form/sales/pages/deposit.rb

@ -3,7 +3,6 @@ class Form::Sales::Pages::Deposit < ::Form::Page
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@optional = optional
@copy_key = "sales.sale_information.deposit"
end
def questions

1
app/models/form/sales/pages/deposit_discount.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::DepositDiscount < ::Form::Page
def initialize(id, hsh, subsection, optional:)
super(id, hsh, subsection)
@optional = optional
@copy_key = "sales.sale_information.cashdis"
end
def questions

1
app/models/form/sales/pages/discount.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::Discount < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "discount"
@copy_key = "sales.sale_information.discount"
@depends_on = [{
"right_to_buy?" => true,
}]

1
app/models/form/sales/pages/equity.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::Equity < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "equity"
@copy_key = "sales.sale_information.equity"
end
def questions

13
app/models/form/sales/pages/estate_management_fee.rb

@ -0,0 +1,13 @@
class Form::Sales::Pages::EstateManagementFee < ::Form::Page
def initialize(id, hsh, subsection)
super
@copy_key = "sales.sale_information.management_fee"
end
def questions
@questions ||= [
Form::Sales::Questions::HasManagementFee.new(nil, nil, self),
Form::Sales::Questions::ManagementFee.new(nil, nil, self),
]
end
end

1
app/models/form/sales/pages/extra_borrowing.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::ExtraBorrowing < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@copy_key = "sales.sale_information.extrabor"
@description = ""
@subsection = subsection
@depends_on = [{

1
app/models/form/sales/pages/grant.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::Grant < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "grant"
@copy_key = "sales.sale_information.grant"
@depends_on = [{
"right_to_buy?" => false,
"rent_to_buy_full_ownership?" => false,

12
app/models/form/sales/pages/living_before_purchase.rb

@ -19,11 +19,17 @@ class Form::Sales::Pages::LivingBeforePurchase < ::Form::Page
end
end
def depends_on
def routed_to?(log, _user)
super && page_routed_to?(log)
end
def page_routed_to?(log)
return false if form.start_year_2025_or_later? && log.resale != 2
if @joint_purchase
[{ "joint_purchase?" => true }]
log.joint_purchase?
else
[{ "not_joint_purchase?" => true }, { "jointpur" => nil }]
log.not_joint_purchase? || log.jointpur.nil?
end
end
end

1
app/models/form/sales/pages/monthly_rent.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::MonthlyRent < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "monthly_rent"
@copy_key = "sales.sale_information.mrent"
end
def questions

1
app/models/form/sales/pages/mortgage_amount.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::MortgageAmount < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@copy_key = "sales.sale_information.mortgage"
@depends_on = [{ "mortgage_used?" => true }]
end

1
app/models/form/sales/pages/mortgage_lender.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::MortgageLender < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@copy_key = "sales.sale_information.mortgagelender"
@description = ""
@subsection = subsection
@depends_on = [{

1
app/models/form/sales/pages/mortgage_lender_other.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::MortgageLenderOther < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@copy_key = "sales.sale_information.mortgagelenderother"
@description = ""
@subsection = subsection
@depends_on = [{

1
app/models/form/sales/pages/mortgage_length.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::MortgageLength < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@copy_key = "sales.sale_information.mortlen"
@depends_on = [{
"mortgageused" => 1,
}]

1
app/models/form/sales/pages/mortgageused.rb

@ -1,7 +1,6 @@
class Form::Sales::Pages::Mortgageused < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@copy_key = "sales.sale_information.mortgageused"
@ownershipsch = ownershipsch
end

1
app/models/form/sales/pages/previous_bedrooms.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::PreviousBedrooms < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "previous_bedrooms"
@copy_key = "sales.sale_information.frombeds"
@depends_on = [
{
"soctenant" => 1,

1
app/models/form/sales/pages/previous_property_type.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::PreviousPropertyType < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "previous_property_type"
@copy_key = "sales.sale_information.fromprop"
@description = ""
@subsection = subsection
@depends_on = [

1
app/models/form/sales/pages/previous_tenure.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::PreviousTenure < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "shared_ownership_previous_tenure"
@copy_key = "sales.sale_information.socprevten"
@header = ""
@description = ""
@subsection = subsection

2
app/models/form/sales/pages/privacy_notice.rb

@ -1,7 +1,7 @@
class Form::Sales::Pages::PrivacyNotice < ::Form::Page
def initialize(id, hsh, subsection, joint_purchase:)
super(id, hsh, subsection)
@copy_key = "sales.#{subsection.id}.privacynotice.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
@copy_key = "sales.#{subsection.copy_key}.privacynotice.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
@joint_purchase = joint_purchase
end

1
app/models/form/sales/pages/resale.rb

@ -2,7 +2,6 @@ class Form::Sales::Pages::Resale < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "resale"
@copy_key = "sales.sale_information.resale"
@depends_on = [
{
"staircase" => 2,

2
app/models/form/sales/pages/staircase.rb

@ -3,7 +3,7 @@ class Form::Sales::Pages::Staircase < ::Form::Page
super
@id = "staircasing"
@depends_on = [{ "ownershipsch" => 1 }]
@copy_key = "sales.#{subsection.id}.staircasing"
@copy_key = "sales.#{subsection.copy_key}.staircasing"
end
def questions

2
app/models/form/sales/questions/buyer_interview.rb

@ -2,7 +2,7 @@ class Form::Sales::Questions::BuyerInterview < ::Form::Question
def initialize(id, hsh, page, joint_purchase:)
super(id, hsh, page)
@id = "noint"
@copy_key = "sales.#{subsection.id}.noint.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
@copy_key = "sales.#{subsection.copy_key}.noint.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]

1
app/models/form/sales/questions/deposit_amount.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::DepositAmount < ::Form::Question
def initialize(id, hsh, subsection, ownershipsch:, optional:)
super(id, hsh, subsection)
@id = "deposit"
@copy_key = "sales.sale_information.deposit"
@type = "numeric"
@min = 0
@max = 999_999

1
app/models/form/sales/questions/deposit_discount.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::DepositDiscount < ::Form::Question
def initialize(id, hsh, page)
super
@id = "cashdis"
@copy_key = "sales.sale_information.cashdis"
@type = "numeric"
@min = 0
@max = 999_999

1
app/models/form/sales/questions/discount.rb

@ -3,7 +3,6 @@ class Form::Sales::Questions::Discount < ::Form::Question
super
@id = "discount"
@type = "numeric"
@copy_key = "sales.sale_information.discount"
@min = 0
@max = form.start_year_2024_or_later? ? 70 : 100
@step = 0.1

1
app/models/form/sales/questions/equity.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::Equity < ::Form::Question
def initialize(id, hsh, page)
super
@id = "equity"
@copy_key = "sales.sale_information.equity"
@type = "numeric"
@min = 0
@max = 100

1
app/models/form/sales/questions/extra_borrowing.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::ExtraBorrowing < ::Form::Question
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@id = "extrabor"
@copy_key = "sales.sale_information.extrabor"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@page = page

1
app/models/form/sales/questions/fromprop.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::Fromprop < ::Form::Question
def initialize(id, hsh, page)
super
@id = "fromprop"
@copy_key = "sales.sale_information.fromprop"
@type = "radio"
@page = page
@answer_options = ANSWER_OPTIONS

1
app/models/form/sales/questions/grant.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::Grant < ::Form::Question
def initialize(id, hsh, page)
super
@id = "grant"
@copy_key = "sales.sale_information.grant"
@type = "numeric"
@min = 0
@max = 999_999

24
app/models/form/sales/questions/has_management_fee.rb

@ -0,0 +1,24 @@
class Form::Sales::Questions::HasManagementFee < ::Form::Question
def initialize(id, hsh, subsection)
super
@id = "has_management_fee"
@copy_key = "sales.sale_information.management_fee.has_management_fee"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@conditional_for = {
"management_fee" => [1],
}
@hidden_in_check_answers = {
"depends_on" => [
{
"has_management_fee" => 1,
},
],
}
end
ANSWER_OPTIONS = {
"1" => { "value" => "Yes" },
"0" => { "value" => "No" },
}.freeze
end

12
app/models/form/sales/questions/management_fee.rb

@ -0,0 +1,12 @@
class Form::Sales::Questions::ManagementFee < ::Form::Question
def initialize(id, hsh, subsection)
super
@id = "management_fee"
@copy_key = "sales.sale_information.management_fee.management_fee"
@type = "numeric"
@min = 1
@step = 0.01
@width = 5
@prefix = "£"
end
end

1
app/models/form/sales/questions/monthly_rent.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::MonthlyRent < ::Form::Question
def initialize(id, hsh, page)
super
@id = "mrent"
@copy_key = "sales.sale_information.mrent"
@type = "numeric"
@min = 0
@step = 0.01

1
app/models/form/sales/questions/mortgage_amount.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::MortgageAmount < ::Form::Question
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@id = "mortgage"
@copy_key = "sales.sale_information.mortgage"
@type = "numeric"
@min = 1
@step = 1

1
app/models/form/sales/questions/mortgage_lender.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::MortgageLender < ::Form::Question
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@id = "mortgagelender"
@copy_key = "sales.sale_information.mortgagelender"
@type = "select"
@page = page
@bottom_guidance_partial = "mortgage_lender"

1
app/models/form/sales/questions/mortgage_lender_other.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::MortgageLenderOther < ::Form::Question
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@id = "mortgagelenderother"
@copy_key = "sales.sale_information.mortgagelenderother"
@type = "text"
@page = page
@ownershipsch = ownershipsch

1
app/models/form/sales/questions/mortgage_length.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::MortgageLength < ::Form::Question
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@id = "mortlen"
@copy_key = "sales.sale_information.mortlen"
@type = "numeric"
@min = 0
@max = 60

1
app/models/form/sales/questions/mortgageused.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::Mortgageused < ::Form::Question
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@id = "mortgageused"
@copy_key = "sales.sale_information.mortgageused"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@ownershipsch = ownershipsch

1
app/models/form/sales/questions/previous_bedrooms.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::PreviousBedrooms < ::Form::Question
def initialize(id, hsh, page)
super
@id = "frombeds"
@copy_key = "sales.sale_information.frombeds"
@type = "numeric"
@width = 5
@min = 1

1
app/models/form/sales/questions/previous_tenure.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::PreviousTenure < ::Form::Question
def initialize(id, hsh, page)
super
@id = "socprevten"
@copy_key = "sales.sale_information.socprevten"
@type = "radio"
@page = page
@answer_options = ANSWER_OPTIONS

2
app/models/form/sales/questions/privacy_notice.rb

@ -2,7 +2,7 @@ class Form::Sales::Questions::PrivacyNotice < ::Form::Question
def initialize(id, hsh, page, joint_purchase:)
super(id, hsh, page)
@id = "privacynotice"
@copy_key = "sales.#{subsection.id}.privacynotice.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
@copy_key = "sales.#{subsection.copy_key}.privacynotice.#{joint_purchase ? 'joint_purchase' : 'not_joint_purchase'}"
@type = "checkbox"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@joint_purchase = joint_purchase

1
app/models/form/sales/questions/resale.rb

@ -2,7 +2,6 @@ class Form::Sales::Questions::Resale < ::Form::Question
def initialize(id, hsh, page)
super
@id = "resale"
@copy_key = "sales.sale_information.resale"
@type = "radio"
@answer_options = ANSWER_OPTIONS
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]

1
app/models/form/sales/sections/sale_information.rb

@ -13,6 +13,7 @@ class Form::Sales::Sections::SaleInformation < ::Form::Section
def shared_ownership_scheme_subsection
if form.start_year_2025_or_later?
Form::Sales::Subsections::SharedOwnershipInitialPurchase.new(nil, nil, self)
Form::Sales::Subsections::SharedOwnershipStaircasingTransaction.new(nil, nil, self)
else
Form::Sales::Subsections::SharedOwnershipScheme.new(nil, nil, self)

1
app/models/form/sales/subsections/discounted_ownership_scheme.rb

@ -4,6 +4,7 @@ class Form::Sales::Subsections::DiscountedOwnershipScheme < ::Form::Subsection
@id = "discounted_ownership_scheme"
@label = "Discounted ownership scheme"
@depends_on = [{ "ownershipsch" => 2, "setup_completed?" => true }]
@copy_key = "sale_information"
end
def pages

1
app/models/form/sales/subsections/outright_sale.rb

@ -4,6 +4,7 @@ class Form::Sales::Subsections::OutrightSale < ::Form::Subsection
@id = "outright_sale"
@label = "Outright sale"
@depends_on = [{ "ownershipsch" => 3, "setup_completed?" => true }]
@copy_key = "sale_information"
end
def pages

48
app/models/form/sales/subsections/shared_ownership_initial_purchase.rb

@ -0,0 +1,48 @@
class Form::Sales::Subsections::SharedOwnershipInitialPurchase < ::Form::Subsection
def initialize(id, hsh, section)
super
@id = "shared_ownership_initial_purchase"
@label = "Shared ownership - initial purchase"
@depends_on = [{ "ownershipsch" => 1, "setup_completed?" => true, "staircase" => 2 }]
end
def pages
@pages ||= [
Form::Sales::Pages::Resale.new(nil, nil, self),
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_shared_ownership_joint_purchase", nil, self, ownershipsch: 1, joint_purchase: true),
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_shared_ownership", nil, self, ownershipsch: 1, joint_purchase: false),
Form::Sales::Pages::HandoverDate.new(nil, nil, self),
Form::Sales::Pages::HandoverDateCheck.new(nil, nil, self),
Form::Sales::Pages::BuyerPrevious.new("buyer_previous_joint_purchase", nil, self, joint_purchase: true),
Form::Sales::Pages::BuyerPrevious.new("buyer_previous_not_joint_purchase", nil, self, joint_purchase: false),
Form::Sales::Pages::PreviousBedrooms.new(nil, nil, self),
Form::Sales::Pages::PreviousPropertyType.new(nil, nil, self),
Form::Sales::Pages::PreviousTenure.new(nil, nil, self),
Form::Sales::Pages::ValueSharedOwnership.new(nil, nil, self),
Form::Sales::Pages::AboutPriceValueCheck.new("about_price_shared_ownership_value_check", nil, self),
Form::Sales::Pages::Equity.new(nil, nil, self),
Form::Sales::Pages::SharedOwnershipDepositValueCheck.new("shared_ownership_equity_value_check", nil, self),
Form::Sales::Pages::Mortgageused.new("mortgage_used_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::MortgageValueCheck.new("mortgage_used_mortgage_value_check", nil, self),
Form::Sales::Pages::MortgageAmount.new("mortgage_amount_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::SharedOwnershipDepositValueCheck.new("shared_ownership_mortgage_amount_value_check", nil, self),
Form::Sales::Pages::MortgageValueCheck.new("mortgage_amount_mortgage_value_check", nil, self),
Form::Sales::Pages::MortgageLength.new("mortgage_length_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::Deposit.new("deposit_shared_ownership", nil, self, ownershipsch: 1, optional: false),
Form::Sales::Pages::Deposit.new("deposit_shared_ownership_optional", nil, self, ownershipsch: 1, optional: true),
Form::Sales::Pages::DepositValueCheck.new("deposit_joint_purchase_value_check", nil, self, joint_purchase: true),
Form::Sales::Pages::DepositValueCheck.new("deposit_value_check", nil, self, joint_purchase: false),
Form::Sales::Pages::DepositDiscount.new("deposit_discount", nil, self, optional: false),
Form::Sales::Pages::DepositDiscount.new("deposit_discount_optional", nil, self, optional: true),
Form::Sales::Pages::SharedOwnershipDepositValueCheck.new("shared_ownership_deposit_value_check", nil, self),
Form::Sales::Pages::MonthlyRent.new(nil, nil, self),
Form::Sales::Pages::LeaseholdCharges.new("leasehold_charges_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::MonthlyChargesValueCheck.new("monthly_charges_shared_ownership_value_check", nil, self),
Form::Sales::Pages::EstateManagementFee.new("estate_management_fee", nil, self),
].compact
end
def displayed_in_tasklist?(log)
log.staircase == 2 && (log.ownershipsch.nil? || log.ownershipsch == 1)
end
end

2
app/models/form/sales/subsections/shared_ownership_scheme.rb

@ -11,7 +11,7 @@ class Form::Sales::Subsections::SharedOwnershipScheme < ::Form::Subsection
@pages ||= [
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_shared_ownership_joint_purchase", nil, self, ownershipsch: 1, joint_purchase: true),
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_shared_ownership", nil, self, ownershipsch: 1, joint_purchase: false),
(Form::Sales::Pages::Staircase.new(nil, nil, self) unless form.start_year_2025_or_later?),
Form::Sales::Pages::Staircase.new(nil, nil, self),
Form::Sales::Pages::AboutStaircase.new("about_staircasing_joint_purchase", nil, self, joint_purchase: true),
Form::Sales::Pages::AboutStaircase.new("about_staircasing_not_joint_purchase", nil, self, joint_purchase: false),
Form::Sales::Pages::StaircaseBoughtValueCheck.new(nil, nil, self),

7
app/models/merge_request_organisation.rb

@ -29,5 +29,12 @@ private
if merging_organisation_id.blank? || !Organisation.where(id: merging_organisation_id).exists?
merge_request.errors.add(:merging_organisation, I18n.t("validations.merge_request.organisation_not_selected"))
end
existing_merges = MergeRequestOrganisation.with_merging_organisation(merging_organisation)
if existing_merges.count.positive?
existing_merge_request = existing_merges.first.merge_request
errors.add(:merging_organisation, I18n.t("validations.merge_request.organisation_part_of_another_merge"))
merge_request.errors.add(:merging_organisation, I18n.t("validations.merge_request.organisation_part_of_another_incomplete_merge", organisation: merging_organisation.name, absorbing_organisation: existing_merge_request.absorbing_organisation&.name, merge_date: existing_merge_request.merge_date&.to_fs(:govuk_date)))
end
end
end

16
app/policies/csv_download_policy.rb

@ -0,0 +1,16 @@
class CsvDownloadPolicy
attr_reader :current_user, :csv_download
def initialize(current_user, csv_download)
@current_user = current_user
@csv_download = csv_download
end
def show?
@current_user == @csv_download.user || @current_user.support? || @current_user.organisation == @csv_download.organisation
end
def download?
@current_user == @csv_download.user || @current_user.support? || @current_user.organisation == @csv_download.organisation
end
end

2
app/services/bulk_upload/lettings/year2024/csv_parser.rb

@ -15,7 +15,7 @@ class BulkUpload::Lettings::Year2024::CsvParser
def row_offset
if with_headers?
rows.find_index { |row| row[0].match(/field number/i) } + 1
rows.find_index { |row| row[0].present? && row[0].match(/field number/i) } + 1
else
0
end

2
app/services/bulk_upload/sales/year2024/csv_parser.rb

@ -15,7 +15,7 @@ class BulkUpload::Sales::Year2024::CsvParser
def row_offset
if with_headers?
rows.find_index { |row| row[0].match(/field number/i) } + 1
rows.find_index { |row| row[0].present? && row[0].match(/field number/i) } + 1
else
0
end

50
app/services/csv/downloader.rb

@ -0,0 +1,50 @@
class Csv::Downloader
attr_reader :csv_download
delegate :path, to: :file
def initialize(csv_download:)
@csv_download = csv_download
end
def call
download
end
def delete_local_file!
file.unlink
end
def presigned_url
s3_storage_service.get_presigned_url(csv_download.filename, 60, response_content_disposition: "attachment; filename=#{csv_download.filename}")
end
private
def download
io = storage_service.get_file_io(csv_download.filename)
file.write(io.read)
io.close
file.close
end
def file
@file ||= Tempfile.new
end
def storage_service
@storage_service ||= if FeatureToggle.upload_enabled?
s3_storage_service
else
local_disk_storage_service
end
end
def s3_storage_service
Storage::S3Service.new(Configuration::EnvConfigurationService.new, ENV["BULK_UPLOAD_BUCKET"])
end
def local_disk_storage_service
Storage::LocalDiskService.new
end
end

4
app/services/feature_toggle.rb

@ -46,4 +46,8 @@ class FeatureToggle
def self.managing_resources_enabled?
!Rails.env.production?
end
def self.create_test_logs_enabled?
Rails.env.development? || Rails.env.review?
end
end

2
app/services/mandatory_collection_resources_service.rb

@ -46,7 +46,7 @@ class MandatoryCollectionResourcesService
year_range = "#{year} to #{year + 1}"
case resource
when "paper_form"
"#{log_type} log for tenants (#{year_range})"
"#{log_type} paper form (#{year_range})"
when "bulk_upload_template"
"#{log_type} bulk upload template (#{year_range})"
when "bulk_upload_specification"

10
app/views/csv_downloads/show.html.erb

@ -0,0 +1,10 @@
<% title = "Downlaod CSV file" %>
<% content_for :title, title %>
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds-from-desktop">
<h1 class="govuk-heading-l govuk-!-margin-bottom-6">You are about to download a CSV file</h1>
<p class="govuk-body-m">Filename: <%= @csv_download.filename %></p>
<%= govuk_button_link_to "Download CSV", download_csv_download_path(@csv_download) %>
</div>
</div>

8
app/views/errors/download_link_expired.html.erb

@ -0,0 +1,8 @@
<% content_for :title, "This link has expired" %>
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1 class="govuk-heading-xl govuk-!-margin-bottom-6">This link has expired.</h1>
<p class="govuk-body-m">Download the logs again to get a new link.</p>
</div>
</div>

6
app/views/form/guidance/_financial_calculations_shared_ownership.html.erb

@ -20,11 +20,11 @@
<% end %>
must equal
the purchase price <%= question_link("value", log, current_user) %>
<% stairbought_page = log.form.get_question("stairbought", log).page %>
<% if stairbought_page.routed_to?(log, current_user) %>
<% stairbought_page = log.form.get_question("stairbought", log)&.page %>
<% if stairbought_page&.routed_to?(log, current_user) %>
multiplied by the percentage bought <%= question_link("stairbought", log, current_user) %>
<% else %>
multiplied by the percentage equity stake <%= question_link("equity", log, current_user) %>
multiplied by the percentage equity share <%= question_link("equity", log, current_user) %>
<% end %>
</p>
<% end %>

8
app/views/merge_requests/_notification_banners.html.erb

@ -19,3 +19,11 @@
No changes have been made. Try beginning the merge again.
<% end %>
<% end %>
<% if @merge_request.merge_date&.future? %>
<%= govuk_notification_banner(title_text: "Important") do %>
<p class="govuk-notification-banner__heading govuk-!-width-full" style="max-width: fit-content">
This merge is happening in the future. Wait until the merge date to begin this merge.
</p>
<% end %>
<% end %>

2
app/views/merge_requests/show.html.erb

@ -12,7 +12,7 @@
</h1>
<% unless @merge_request.status == "request_merged" || @merge_request.status == "processing" %>
<div class="govuk-button-group">
<%= govuk_button_link_to "Begin merge", merge_start_confirmation_merge_request_path(@merge_request), disabled: @merge_request.status != "ready_to_merge" %>
<%= govuk_button_link_to "Begin merge", merge_start_confirmation_merge_request_path(@merge_request), disabled: begin_merge_disabled?(@merge_request) %>
<%= govuk_button_link_to "Delete merge request", delete_confirmation_merge_request_path(@merge_request), warning: true %>
</div>
<% end %>

4
config/locales/en.yml

@ -183,8 +183,11 @@ en:
merge_date:
blank: "Enter a merge date."
invalid: "Enter a valid merge date."
more_than_year_from_today: "The merge date must not be later than a year from today’s date."
existing_absorbing_organisation:
blank: "You must answer absorbing organisation already active?"
merging_organisation_id:
part_of_another_merge: "Another merge request records %{organisation} as merging into %{absorbing_organisation} on %{merge_date}. Select another organisation or remove this organisation from the other merge request."
notification:
attributes:
title:
@ -370,6 +373,7 @@ en:
during_deactivated_period: "The location is already deactivated during this date, please enter a different date."
merge_request:
organisation_part_of_another_merge: "This organisation is part of another merge - select a different one."
organisation_part_of_another_incomplete_merge: "Another merge request records %{organisation} as merging into %{absorbing_organisation} on %{merge_date}. Select another organisation or remove this organisation from the other merge request."
organisation_not_selected: "Select an organisation from the search list."
soft_validations:

2
config/locales/forms/2024/lettings/setup.en.yml

@ -30,7 +30,7 @@ en:
scheme_id:
page_header: "Scheme"
check_answer_label: "Scheme name"
hint_text: "Enter postcode or scheme name.<br><br>A supported housing scheme provides shared or self-contained housing for a particular client group, for example younger or vulnerable people."
hint_text: "Enter postcode, scheme name, or scheme code (for example, S123).<br><br>A supported housing scheme provides shared or self-contained housing for a particular client group, for example younger or vulnerable people."
question_text: "What scheme is this log for?"
location_id:

25
config/locales/forms/2025/sales/sale_information.en.yml

@ -122,7 +122,7 @@ en:
hint_text: ""
question_text: "What was the previous tenure of the buyer?"
value:
value: #TODO rename non-staircasing and staircasing versions
page_header: "About the price of the property"
value_shared_ownership:
check_answer_label: "Full purchase price"
@ -133,11 +133,11 @@ en:
hint_text: "Enter the full purchase price paid for the equity bought in this staircasing transaction (this is equal to the value of the share bought by the purchaser)."
question_text: "What was the full purchase price for this staircasing transaction?"
equity:
equity: #TODO split non-staircasing and staircasing versions
page_header: "About the price of the property"
check_answer_label: "Initial percentage equity stake"
hint_text: "Enter the amount of initial equity held by the purchaser (for example, 25% or 50%)"
question_text: "What was the percentage shared purchased in the initial transaction?"
check_answer_label: "Initial percentage equity share"
hint_text: "Enter the amount of initial equity share held by the purchaser (for example, 25% or 50%)"
question_text: "What was the initial percentage share purchased?"
mortgageused:
page_header: "Mortgage Amount"
@ -207,9 +207,9 @@ en:
leaseholdcharges:
page_header: ""
has_mscharge:
check_answer_label: "Does the property have any monthly leasehold charges?"
check_answer_label: "Does the property have any service charges?"
hint_text: "For example, service and management charges"
question_text: "Does the property have any monthly leasehold charges?"
question_text: "Does the property have any service charges?"
mscharge:
check_answer_label: "Monthly leasehold charges"
hint_text: ""
@ -238,3 +238,14 @@ en:
check_answer_label: "Amount of any loan, grant or subsidy"
hint_text: "For all schemes except Right to Buy (RTB), Preserved Right to Buy (PRTB), Voluntary Right to Buy (VRTB) and Rent to Buy"
question_text: "What was the amount of any loan, grant, discount or subsidy given?"
management_fee:
page_header: ""
has_management_fee:
check_answer_label: "Does the property have an estate management fee?"
hint_text: "Estate management fees are typically used for the maintenance of communal gardens, payments, private roads, car parks and/or play areas within new build estates."
question_text: "Does the property have an estate management fee?"
management_fee:
check_answer_label: "Monthly estate management fee"
hint_text: ""
question_text: "Enter the total monthly management fee"

12
config/routes.rb

@ -382,6 +382,18 @@ Rails.application.routes.draw do
end
end
resources :csv_downloads, path: "csv-downloads" do
member do
get "/", to: "csv_downloads#show", as: "show"
get "download", to: "csv_downloads#download"
end
end
get "create-test-lettings-log", to: "lettings_logs#create_test_log"
get "create-test-sales-log", to: "sales_logs#create_test_log"
get "create-setup-test-lettings-log", to: "lettings_logs#create_setup_test_log"
get "create-setup-test-sales-log", to: "sales_logs#create_setup_test_log"
scope via: :all do
match "/404", to: "errors#not_found"
match "/429", to: "errors#too_many_requests", status: 429

8
db/migrate/20241114154215_add_management_fee_fields.rb

@ -0,0 +1,8 @@
class AddManagementFeeFields < ActiveRecord::Migration[7.0]
def change
change_table :sales_logs, bulk: true do |t|
t.column :has_management_fee, :integer
t.column :management_fee, :decimal, precision: 10, scale: 2
end
end
end

12
db/migrate/20241118104046_add_csv_download_table.rb

@ -0,0 +1,12 @@
class AddCsvDownloadTable < ActiveRecord::Migration[7.0]
def change
create_table :csv_downloads do |t|
t.column :download_type, :string
t.column :filename, :string
t.column :expiration_time, :integer
t.timestamps
t.references :user
t.references :organisation
end
end
end

16
db/schema.rb

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_11_14_173226) do
ActiveRecord::Schema[7.0].define(version: 2024_11_18_104046) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -64,6 +64,18 @@ ActiveRecord::Schema[7.0].define(version: 2024_11_14_173226) do
t.datetime "discarded_at"
end
create_table "csv_downloads", force: :cascade do |t|
t.string "download_type"
t.string "filename"
t.integer "expiration_time"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "user_id"
t.bigint "organisation_id"
t.index ["organisation_id"], name: "index_csv_downloads_on_organisation_id"
t.index ["user_id"], name: "index_csv_downloads_on_user_id"
end
create_table "csv_variable_definitions", force: :cascade do |t|
t.string "variable", null: false
t.string "definition", null: false
@ -746,6 +758,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_11_14_173226) do
t.integer "partner_under_16_value_check"
t.integer "multiple_partners_value_check"
t.bigint "created_by_id"
t.integer "has_management_fee"
t.decimal "management_fee", precision: 10, scale: 2
t.integer "firststair"
t.integer "numstair"
t.decimal "mrentprestaircasing", precision: 10, scale: 2

2
db/seeds.rb

@ -15,6 +15,8 @@ def create_data_protection_confirmation(user)
signed_at: Time.zone.local(2019, 1, 1),
data_protection_officer_email: user.email,
data_protection_officer_name: user.name,
organisation_name: user.organisation.name,
organisation_address: user.organisation.address_row,
)
end

1
docs/Gemfile.lock

@ -255,6 +255,7 @@ GEM
PLATFORMS
arm64-darwin-21
arm64-darwin-23
x86_64-darwin-22
x86_64-linux

2
docs/app_api.md

@ -12,4 +12,4 @@ In order to use the app as an API, you will need to configure requests to the AP
- `Content-Type = application/json`
- `Action = application/json` N.B. If you use `*/*` instead, the request won't be recognised as an API request`
Currently only the logs controller is configured to accept and authenticate API requests, when the above API environment variables are set.
Currently, only the Logs Controller is configured to accept and authenticate API requests, provided that the specified API environment variables are set. Please note that the API has not been actively maintained for an extended period and may not function as expected. Additionally, the required environment variables are not configured on any of the environments deployed on AWS, rendering API requests to those environments non-functional.

16
docs/exports.md

@ -6,16 +6,14 @@ nav_order: 7
All data collected by the application needs to be exported to the Consolidated Data Store (CDS) which is a data warehouse based on MS SQL running in the DAP (Data Analytics Platform).
This is done via XML exports saved in an S3 bucket located in the DAP VPC using dedicated credentials shared out of band. The data mapping for this export can be found in `app/services/exports/lettings_log_export_service.rb`
This is done via XML exports saved in an S3 bucket.
We currently export lettings logs, users and organisations.
The data mapping for these exports can be found in:
- Lettings logs `app/services/exports/lettings_log_export_service.rb`
- Organisations `app/services/exports/organisation_export_service.rb`
- Users `app/services/exports/user_export_service.rb`
Initially the application database field names and field types were chosen to match the existing CDS data as closely as possible to minimise the amount of transformation needed. This has led to a less than optimal data model though and increasingly we should look to transform at the mapping layer where beneficial for our application.
We have a cron job triggering the export service daily at 5am.
The S3 bucket is located in the DAP VPC rather than the application VPC as DAP runs in an AWS account directly so access to the S3 bucket can be restricted to only the IPs used by the application. This is not possible the other way around as [Gov PaaS does not support restricting S3 access by IP](https://github.com/alphagov/paas-roadmap/issues/107).
## Other options previously considered
- CDC replication using a managed service such as [AWS DMS](https://aws.amazon.com/dms/)
- Would require VPC peering which [Gov PaaS does not currently support](https://github.com/alphagov/paas-roadmap/issues/105)
- Would require CDS to make changes to their ingestion model

182
docs/form/builder.md

@ -9,13 +9,11 @@ nav_order: 1
The setup this log section is treated slightly differently from the rest of the form. It is more accurately viewed as providing metadata about the form than as being part of the form itself. It also needs to know far more about the application specific context than other parts of the form such as who the current user is, what organisation they’re part of and what role they have etc.
As a result it’s not modelled as part of the config but rather as code. It still uses the same [Form Runner](/form/runner) components though.
## Features the Form Config supports
## Features the Form supports
- Defining sections, subsections, pages and questions that fit the GOV.UK task list pattern
- Auto-generated routes – URLs are automatically created from dasherized page names
- Auto-generated routes – URLs are automatically created from dasherized page names (ids)
- Data persistence requires a database field to exist which matches the name/id for each question (and answer option for checkbox questions)
@ -39,63 +37,84 @@ As a result it’s not modelled as part of the config but rather as code. It sti
- For complex HTML guidance partials can be referenced
## JSON Config
The form for this is driven by a JSON file in `/config/forms/{start_year}_{end_year}.json`
The JSON should follow the structure:
```jsonc
{
"form_type": "lettings" / "sales",
"start_year": Integer, // i.e. 2020
"end_year": Integer, // i.e. 2021
"sections": {
"[snake_case_section_name_string]": {
"label": String,
"description": String,
"subsections": {
"[snake_case_subsection_name_string]": {
"label": String,
"pages": {
"[snake_case_page_name_string]": {
"header": String,
"description": String,
"questions": {
"[snake_case_question_name_string]": {
"header": String,
"hint_text": String,
"check_answer_label": String,
"type": "text" / "numeric" / "radio" / "checkbox" / "date",
"min": Integer, // numeric only
"max": Integer, // numeric only
"step": Integer, // numeric only
"width": 2 / 3 / 4 / 5 / 10 / 20, // text and numeric only
"prefix": String, // numeric only
"suffix": String, //numeric only
"answer_options": { // checkbox and radio only
"0": String,
"1": String
},
"conditional_for": {
"[snake_case_question_to_enable_1_name_string]": ["condition-that-enables"],
"[snake_case_question_to_enable_2_name_string]": ["condition-that-enables"]
},
"inferred_answers": { "field_that_gets_inferred_from_current_field": { "is_that_field_inferred": true } },
"inferred_check_answers_value": [{
"condition": { "field_name_for_inferred_check_answers_condition": "field_value_for_inferred_check_answers_condition" },
"value": "Inferred value that gets displayed if condition is met"
}]
}
},
"depends_on": [{ "question_key": "answer_value_required_for_this_page_to_be_shown" }]
}
}
}
}
}
}
}
## Form definition
The Form should follow the structure:
```
SECTIONS = [
Form::Sales::Sections::Section
].freeze
Form.new(nil, start_year, SECTIONS, form_type - "lettings" / "sales")
class Form::Sales::Sections::Section < ::Form::Section
def initialize(id, hsh, form)
super
@id = [snake_case_section_name_string]
@label = [String]
@description = [String]
@subsections = [Form::Sales::Subsections::Subsection.new(nil, nil, self)]
end
end
class Form::Sales::Subsections::Subsection < ::Form::Subsection
def initialize(id, hsh, section)
super
@id = [snake_case_subsection_name_string]
@label = [String]
@depends_on = [{ "question_key/method_key": "answer_value_required_for_this_subsection_to_be_shown" }]
end
def pages
@pages ||= [Form::Sales::Pages::Page.new(nil, nil, self),]
end
end
class Form::Sales::Pages::Page < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = [snake_case_page_name_string]
@header = [String,]
@depends_on = [{ "question_key": "answer_value_required_for_this_page_to_be_shown" }]
end
def questions
@questions ||= [
Form::Sales::Questions::Question.new(nil, nil, self),
]
end
end
class Form::Sales::Questions::Question < ::Form::Question
def initialize(id, hsh, page)
super
@id = [snake_case_question_name_string]
@hint_text = [String,]
@check_answer_label = [String,]
@type = ["text" / "numeric" / "radio" / "checkbox" / "date",]
@min = [Integer, // numeric only]
@max = [Integer, // numeric only]
@step = [Integer, // numeric only]
@width = [2 / 3 / 4 / 5 / 10 / 20, // text and numeric only]
@prefix = [String, // numeric only]
@suffix = [String, //numeric only]
@answer_options = { // checkbox and radio only
"0": String,
"1": String
},
@conditional_for = {
"[snake_case_question_to_enable_1_name_string]": ["condition-that-enables"],
"[snake_case_question_to_enable_2_name_string]": ["condition-that-enables"]
},
@inferred_answers = { "field_that_gets_inferred_from_current_field": { "is_that_field_inferred": true } },
@inferred_check_answers_value = [{
"condition": { "field_name_for_inferred_check_answers_condition": "field_value_for_inferred_check_answers_condition" },
"value": "Inferred value that gets displayed if condition is met"
}]
@question_number = Integer
end
end
```
Assumptions made by the format:
@ -127,47 +146,8 @@ Assumptions made by the format:
Form navigation works by stepping sequentially through every page defined in the JSON form definition for the given subsection. For every page it checks if it has "depends_on" conditions. If it does, it evaluates them to determine whether that page should be show or not.
In this way we can build up whole branches by having:
```jsonc
"page_1": { "questions": { "question_1: "answer_options": ["A", "B"] } },
"page_2": { "questions": { "question_2: "answer_options": ["C", "D"] }, "depends_on": [{ "question_1": "A" }] },
"page_3": { "questions": { "question_3: "answer_options": ["E", "F"] }, "depends_on": [{ "question_1": "A" }] },
"page_4": { "questions": { "question_4: "answer_options": ["G", "H"] }, "depends_on": [{ "question_1": "B" }] },
```
## JSON form validation against Schema
To validate the form JSON against the schema you can run:
```bash
rake form_definition:validate["config/forms/2021_2022.json"]
```
Note: you may have to escape square brackets in zsh:
```bash
rake form_definition:validate\["config/forms/2021_2022.json"\]
```
This will validate the given form definition against the schema in `config/forms/schema/generic.json`.
You can also run:
```bash
rake form_definition:validate_all
```
This will validate all forms in directories `["config/forms", "spec/fixtures/forms"]`
We can also define custom `routed_to?` methods on pages for more complex routing logic.
## Form models and definition
For information about the form model and related models (section, subsection, page, question) and how these relate to each other see [form definition](/form/definition).
## Improvements that could be made
- JSON schema definition could be expanded such that we can better automatically validate that a given config is valid and internally consistent
- Generators could parse a given valid JSON form and generate the required database migrations to ensure all the expected fields exist and are of a compatible type
- The parsed form could be visualised using something like GraphViz to help manually verify the coded config meets requirements

35
docs/form/definition.md

@ -6,26 +6,15 @@ nav_order: 3
# Form definition
The current system is built around a form definition written in JSON. At the top level every form will expect to have the following attributes:
The current system is built around a form definition constructed from various Form subclasses. At the top level every form will expect to have the following attributes:
- Form type: this is to define whether the form is a lettings form or a sales form. The questions will differ between the types.
- Start date: the start of the collection window for the form, this will usually be in April.
- End date: the end date of the collection window for the form, this will usually be in July, a year after the start date.
- Submission deadline: the official end date of the collection window for the form, this will usually be in July, a year after the start date.
- New logs end date: the end date for creating any new logs for this form
- Edit end date: the end date for editing any existing logs for this form
- Sections: the sections in the form, this block is where the bulk of the form definition will be.
An example of this might look like the following:
```json
{
"form_type": "lettings",
"start_date": "2021-04-01T00:00:00.000+01:00",
"end_date": "2022-07-01T00:00:00.000+01:00",
"sections": {
...
}
}
```
Note that the end date of one form will overlap the start date of another to allow for late submissions. This means that every year there will be a period of time in which two forms are running simultaneously.
A form is split up is as follows:
@ -39,24 +28,24 @@ Rails uses the model, view, controller (MVC) pattern which we follow.
## Form model
There is no need to manually initialise a form object as this is handled by the FormHandler class at boot time. If a new form needs to be added then a JSON file containing the form definition should be added to `config/forms` where the FormHandler will be able to locate it and instantiate it.
There is no need to manually initialise a form object as this is handled by the FormHandler class at boot time.
A form has the following attributes:
- `name`: The name of the form
- `setup_sections`: The setup section (this is not defined in the JSON, for more information see this)
- `form_definition`: The parsed form JSON
- `form_sections`: The sections found within the form definition JSON
- `setup_sections`: The setup section
- `form_sections`: The sections passed to form on init
- `type`: The type of form (this is used to indicate if the form is for a sale or a letting)
- `sections`: The combination of the setup section with those found in the JSON definition
- `sections`: The combination of the setup section with form sections
- `subsections`: The subsections of the form (these live under the sections)
- `pages`: The pages of the form (these live under the subsections)
- `questions`: The questions of the form (these live under the pages)
- `start_date`: The start date of the form, in ISO 8601 format
- `end_date`: The end date of the form, in ISO 8601 format
- `submission_deadline`: The official end date of the form, in ISO 8601 format
- `new_logs_end_date`: The new logs end date of the form, in ISO 8601 format
- `edit_end_date`: The edit end date of the form, in ISO 8601 format
Each form has an `end_date` which for JSON forms is defined in the form definition JSON file and for code defined forms it is set to 1st July, 1 year after the start year.
Logs with a form that has `end_date` in the past can no longer be edited through the UI.
Logs with a form that has `edit_end_date` in the past can no longer be edited through the UI.
## Form views

12
docs/form/index.md

@ -13,18 +13,12 @@ A paper form is produced for guidance and to help data providers collect the dat
Data is accepted for a collection window for up to 3 months after it’s finished to allow for late data submission. This means that between April and July 2 versions of the form run simultaneously.
Other considerations that went into our design are being able to re-use as much of this solution for other data collections, and possibly having the ability to generate the form and/or form changes from a user interface.
Other initial considerations that went into our design are being able to re-use as much of this solution for other data collections, and possibly having the ability to generate the form and/or form changes from a user interface.
We haven’t used micro-services, preferring to deploy a single application but we have modelled the form itself as configuration in the form of a JSON structure that acts as a sort of DSL/form builder for the form.
Each form has historically been defined as a JSON configuration, but has since been replaced with subsection, page and question classes that contruct a form in code due to increased complexity.
The idea is to decouple the code that creates the required routes, controller methods, views etc to display the form from the actual wording of questions or order of pages such that it becomes possible to make changes to the form with little or no code changes.
This should also mean that in the future it could be possible to create an interface that can construct the JSON config, which would open up the ability to make form changes to a wider audience. Doing this fully would require generating and running the necessary migrations for data storage, generating the required ActiveRecord methods to validate the data server side, and generating/updating API endpoints and documentation. All of this is likely to be beyond the scope of initial MVP but could be looked at in the future.
Since initially the JSON config will not create database migrations or ActiveRecord model validations, it will instead assume that these have been correctly created for the config provided. The reasoning for this is the following assumptions:
To allow for easier content changes, the copy for questions has been extracted into translation files. The reasoning for this is the following assumptions:
- The form will be tweaked regularly (amending questions wording, changing the order of questions or the page a question is displayed on)
- The actual data collected will change very infrequently. Time series continuity is very important to ADD (Analysis and Data Directorate) so the actual data collected should stay largely consistent i.e. in general we can change the question wording in ways that makes the intent clearer or easier to understand, but not in ways that would make the data provider give a different answer.
A form parser class will parse this config into ruby objects/methods that can be used as an API by the rest of the application, such that we could change the underlying config if needed (for example swap JSON for YAML or for DataBase objects) without needing to change the rest of the application. We’ll call this the Form Runner part of the application.

67
docs/form/page.md

@ -10,43 +10,44 @@ Pages sit below the [`Subsection`](subsection) level of a form definition.
An example page might look something like this:
```json
"property_postcode": {
"header": "",
"description": "",
"questions": {
...
},
"depends_on": [
{
"needstype": 1
```
class Form::Sales::Pages::PropertyPostcode < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = property_postcode
@depends_on = [{ "needstype" => 1 }]
@title_text = {
"translation": "translation1",
"arguments": [
{
"key": "some_general_field",
"label": true,
"i18n_template": "template1"
}
]
}
],
"title_text": {
"translation": "translation1",
"arguments": [
{
"key": "some_general_field",
"label": true,
"i18n_template": "template1"
}
]
},
"informative_text": {
"translation": "translation2",
"arguments": [
{
"key": "some_currency_method",
"label": false,
"i18n_template": "template2",
"currency": true,
}
@informative_text": {
"translation": "translation2",
"arguments": [
{
"key": "some_currency_method",
"label": false,
"i18n_template": "template2",
"currency": true,
}
]
}
end
def questions
@questions ||= [
Form::Sales::Questions::Question.new(nil, nil, self),
]
},
}
end
end
```
In the above example the the subsection has the id `property_postcode`. This id is used for the url of the web page, but the underscore is replaced with a hash, so the url for this page would be `[environment-url]/logs/[log-id]/property-postcode` e.g. on staging this url might look like the following: `https://dluhc-core-staging.london.cloudapps.digital/logs/1234/property-postcode`.
In the above example the the subsection has the id `property_postcode`. This id is used for the url of the web page, but the underscore is replaced with a dash, so the url for this page would be `[environment-url]/logs/[log-id]/property-postcode` e.g. on staging this url might look like the following: `https://staging.submit-social-housing-data.communities.gov.uk/logs/1234/property-postcode`.
The header is optional but if provided is used for the heading displayed on the page.

86
docs/form/question.md

@ -10,25 +10,25 @@ Questions are under the page level of the form definition.
An example question might look something like this:
```json
"postcode_known": {
"check_answer_label": "Do you know the property postcode?",
"header": "Do you know the property’s postcode?",
"hint_text": "",
"type": "radio",
"answer_options": {
"1": {
"value": "Yes"
```
class Form::Sales::Questions::PostcodeKnown < ::Form::Question
def initialize(id, hsh, page)
super
@id = postcode_known
@hint_text = ""
@header = "Do you know the property postcode?"
@check_answer_label = "Do you know the property postcode?"
@type = "radio"
@answer_options = {
"1" => { "value" => "Yes" },
"0" => { "value" => "No" }
},
"0": {
"value": "No"
}
},
"conditional_for": {
"postcode_full": [1]
},
"hidden_in_check_answers": true
}
@conditional_for = {
"postcode_full" => [1]
},
@hidden_in_check_answers = true
end
end
```
In the above example the the question has the id `postcode_known`.
@ -45,15 +45,11 @@ The `conditional_for` contains the value needed to be selected by the data input
the `hidden_in_check_answers` is used to hide a value from displaying on the check answers page. You only need to provide this if you want to set it to true in order to hide the value for some reason e.g. it's one of two questions appearing on a page and the other question is displayed on the check answers page. It's also worth noting that you can declare this as a with a `depends_on` which can be useful for conditionally displaying values on the check answers page. For example:
```json
"hidden_in_check_answers": {
"depends_on": [
{
"age6_known": 0
},
{
"age6_known": 1
}
```
@hidden_in_check_answers = {
"depends_on" => [
{ "age6_known" => 0 },
{ "age6_known" => 1 }
]
}
```
@ -62,25 +58,25 @@ Would mean the question the above is attached to would be hidden in the check an
The answer the data inputter provides to some questions allows us to infer the values of other questions we might have asked in the form, allowing us to save the data inputters some time. An example of how this might look is as follows:
```json
"postcode_full": {
"check_answer_label": "Postcode",
"header": "What is the property’s postcode?",
"hint_text": "",
"type": "text",
"width": 5,
"inferred_answers": {
"la": {
"is_la_inferred": true
```
class Form::Sales::Questions::PostcodeFull < ::Form::Question
def initialize(id, hsh, page)
super
@id = postcode_full
@hint_text = ""
@header = "What is the property’s postcode?""
@check_answer_label = "Postcode""
@type = "text"
@width = 5
@inferred_answers = {
"la" => { "is_la_inferred" => true }
}
},
"inferred_check_answers_value": [{
"condition": {
"postcode_known": 0
},
"value": "Not known"
}]
}
@inferred_check_answers_value => [{
"condition" => { "postcode_known" => 0 },
"value": "Not known"
}]
end
end
```
In the above example the width is an optional attribute and can be provided for text type questions to determine the width of the text box on the page when when the question is displayed to a user (this allows you to match the width of the text box on the page to that of the design for a question).

30
docs/form/section.md

@ -10,24 +10,22 @@ Sections sit at the top level of a form definition.
An example section might look something like this:
```json
"sections": {
"tenancy_and_property": {
"label": "Property and tenancy information",
"subsections": {
"property_information": {
...
},
"tenancy_information": {
...
}
}
},
...
}
```
class Form::Sales::Sections::TenancyAndProperty < ::Form::Section
def initialize(id, hsh, form)
super
@id = "tenancy_and_property"
@label = "Property and tenancy information"
@description = ""
@subsections = [
Form::Sales::Subsections::PropertyInformation.new(nil, nil, self),
Form::Sales::Subsections::TenancyInformation.new(nil, nil, self)
]
end
end
```
In the above example the section id would be `tenancy_and_property` and its subsections would be `property_information` and `tenancy_information`.
In the above example the section id would be `tenancy_and_property` and its subsections would be `PropertyInformation` and `TenancyInformation`.
The label contains the text that users will see for that section in the task list page of a lettings log.

32
docs/form/subsection.md

@ -10,29 +10,25 @@ Subsections sit below the [`Section`](section) level of a form definition.
An example subsection might look something like this:
```json
"property_information": {
"label": "Property information",
"depends_on": [
{
"setup": "completed"
}
],
"pages": {
"property_postcode": {
...
},
"property_local_authority": {
...
}
}
}
```
class Form::Sales::Subsections::PropertyInformation < ::Form::Subsection
def initialize(id, hsh, section)
super
@id = property_information
@depends_on = [{ "setup": "completed" }]
@label = "Property information"
end
def pages
@pages ||= [Form::Sales::Pages::PropertyPostcode.new(nil, nil, self),Form::Sales::Pages::PropertyLocalAuthority.new(nil, nil, self)]
end
end
```
In the above example the the subsection has the id `property_information`. The `depends_on` contains the set of conditions that must be met for the section to be accessible to a data provider, in this example subsection depends on the completion of the setup section/subsection (note that this is a common condition as the answers provided to questions in the setup subsection often have an impact on what questions are asked of the data provider in later subsections of the form).
The label contains the text that users will see for that subsection in the task list page of a lettings log.
The pages of the subsection in the example would be `property_postcode` and `property_local_authority`.
The pages of the subsection in the example would be `PropertyPostcode` and `PropertyLocalAuthority`.
Subsections can contain one or more [pages](page).

14
docs/setup.md

@ -79,15 +79,23 @@ We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage NodeJS version
macOS (using nvm):
```bash
nvm install 16
nvm use 16
nvm install 20
nvm use 20
brew install yarn
```
or you could run it without specifying the version and it should use the version from .nvmrc
```bash
nvm install
nvm use
brew install yarn
```
Linux (Debian):
```bash
curl -sL https://deb.nodesource.com/setup_16.x | sudo bash -
curl -sL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt -y install nodejs
mkdir -p ~/.npm-packages
npm config set prefix ~/.npm-packages

2
spec/factories/collection_resource.rb

@ -1,7 +1,7 @@
FactoryBot.define do
factory :collection_resource, class: "CollectionResource" do
resource_type { "paper_form" }
display_name { "lettings log for tenants (2021 to 2022)" }
display_name { "lettings paper form (2021 to 2022)" }
short_display_name { "Paper Form" }
year { 2024 }
log_type { "lettings" }

9
spec/factories/csv_download.rb

@ -0,0 +1,9 @@
FactoryBot.define do
factory :csv_download do
download_type { "lettings" }
user { create(:user) }
organisation { user.organisation }
filename { "lettings.csv" }
expiration_time { 24.hours.to_i }
end
end

5
spec/features/accessibility_spec.rb

@ -138,15 +138,18 @@ RSpec.describe "Accessibility", js: true do
routes = find_routes("sales-log", sales_log, bulk_upload)
routes.reject { |path|
routes = routes.reject { |path|
path.include?("/edit") || path.include?("/new") || path.include?("*page") ||
path.include?("/sales-logs/bulk-upload-logs/#{bulk_upload.id}") ||
path.include?("bulk-upload-soft-validations-check") || path.include?("filters/update") ||
path == "/sales-logs/bulk-upload-resume/#{bulk_upload.id}" ||
path == "/sales-logs/bulk-upload-logs" ||
path.include?("/check-answers") ||
other_form_page_ids.any? { |page_id| path.include?(page_id.dasherize) } ||
sales_log_pages.any? { |page| path.include?(page.id.dasherize) && !page.routed_to?(sales_log, user) }
}.uniq
routes + sales_log.form.subsections.map(&:id).map { |id| "/sales-logs/#{sales_log.id}/#{id.dasherize}/check-answers" }
end
before do

2
spec/features/schemes_spec.rb

@ -677,7 +677,7 @@ RSpec.describe "Schemes scheme Features" do
end
it "adds scheme to the list of schemes" do
expect(page).to have_content "#{scheme.service_name} has been created. It does not require helpdesk approval."
expect(page).to have_content "#{scheme.service_name} has been created."
click_link "Schemes"
expect(page).to have_content "Supported housing schemes"
expect(page).to have_content scheme.id_to_display

14
spec/helpers/collection_resources_helper_spec.rb

@ -94,9 +94,9 @@ RSpec.describe CollectionResourcesHelper do
context "and next year resources were manually released" do
before do
create(:collection_resource, year: 2025, resource_type: "paper_form", display_name: "lettings log for tenants (2025 to 2026)", download_filename: "file.pdf", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "paper_form", display_name: "lettings paper form (2025 to 2026)", download_filename: "file.pdf", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "bulk_upload_template", display_name: "bulk upload template (2025 to 2026)", download_filename: "file.xlsx", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "bulk_upload_specification", display_name: "sales log for tenants (2025 to 2026)", download_filename: "file.xlsx", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "bulk_upload_specification", display_name: "sales paper form (2025 to 2026)", download_filename: "file.xlsx", mandatory: true, released_to_user: true)
end
it "reutrns current and next years" do
@ -121,7 +121,7 @@ RSpec.describe CollectionResourcesHelper do
describe "#document_list_component_items" do
let(:resources) do
[
build(:collection_resource, year: 2023, resource_type: "paper_form", display_name: "lettings log for tenants (2023 to 2024)", download_filename: "2023_24_lettings_paper_form.pdf"),
build(:collection_resource, year: 2023, resource_type: "paper_form", display_name: "lettings paper form (2023 to 2024)", download_filename: "2023_24_lettings_paper_form.pdf"),
build(:collection_resource, year: 2023, resource_type: "bulk_upload_template", display_name: "bulk upload template (2023 to 2024)", download_filename: "2023_24_lettings_bulk_upload_template.xlsx"),
]
end
@ -134,7 +134,7 @@ RSpec.describe CollectionResourcesHelper do
it "returns component items" do
expect(document_list_component_items(resources)).to eq([
{
name: "Download the lettings log for tenants (2023 to 2024)",
name: "Download the lettings paper form (2023 to 2024)",
href: "/collection-resources/lettings/2023/paper_form/download",
metadata: "PDF, 286 KB",
},
@ -150,7 +150,7 @@ RSpec.describe CollectionResourcesHelper do
describe "#document_list_edit_component_items" do
let(:resources) do
[
build(:collection_resource, year: 2023, resource_type: "paper_form", display_name: "lettings log for tenants (2023 to 2024)", download_filename: "2023_24_lettings_paper_form.pdf"),
build(:collection_resource, year: 2023, resource_type: "paper_form", display_name: "lettings paper form (2023 to 2024)", download_filename: "2023_24_lettings_paper_form.pdf"),
build(:collection_resource, year: 2023, resource_type: "bulk_upload_template", display_name: "bulk upload template (2023 to 2024)", download_filename: "2023_24_lettings_bulk_upload_template.xlsx"),
]
end
@ -199,9 +199,9 @@ RSpec.describe CollectionResourcesHelper do
context "and the resources have been manually released" do
before do
create(:collection_resource, year: 2025, resource_type: "paper_form", display_name: "lettings log for tenants (2025 to 2026)", download_filename: "file.pdf", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "paper_form", display_name: "lettings paper form (2025 to 2026)", download_filename: "file.pdf", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "bulk_upload_template", display_name: "bulk upload template (2025 to 2026)", download_filename: "file.xlsx", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "bulk_upload_specification", display_name: "sales log for tenants (2025 to 2026)", download_filename: "file.xlsx", mandatory: true, released_to_user: true)
create(:collection_resource, year: 2025, resource_type: "bulk_upload_specification", display_name: "sales paper form (2025 to 2026)", download_filename: "file.xlsx", mandatory: true, released_to_user: true)
end
it "returns false" do

25
spec/jobs/email_csv_job_spec.rb

@ -3,8 +3,6 @@ require "rails_helper"
describe EmailCsvJob do
include Helpers
test_url = :test_url
let(:job) { described_class.new }
let(:user) { FactoryBot.create(:user) }
let(:storage_service) { instance_double(Storage::S3Service) }
@ -22,7 +20,6 @@ describe EmailCsvJob do
before do
allow(Storage::S3Service).to receive(:new).and_return(storage_service)
allow(storage_service).to receive(:write_file)
allow(storage_service).to receive(:get_presigned_url).and_return(test_url)
allow(Csv::SalesLogCsvService).to receive(:new).and_return(sales_log_csv_service)
allow(sales_log_csv_service).to receive(:prepare_csv).and_return("")
@ -67,6 +64,16 @@ describe EmailCsvJob do
expect(lettings_log_csv_service).to receive(:prepare_csv).with(lettings_logs)
job.perform(user, nil, {}, nil, nil, codes_only_export)
end
it "creates a CsvDownload record" do
job.perform(user, nil, {}, nil, nil, codes_only_export, "lettings")
expect(CsvDownload.count).to eq(1)
expect(CsvDownload.first.user).to eq(user)
expect(CsvDownload.first.organisation).to eq(user.organisation)
expect(CsvDownload.first.filename).to match(/lettings-logs-.*\.csv/)
expect(CsvDownload.first.download_type).to eq("lettings")
expect(CsvDownload.first.expiration_time).to eq(172_800)
end
end
context "when exporting sales logs" do
@ -102,10 +109,20 @@ describe EmailCsvJob do
expect(sales_log_csv_service).to receive(:prepare_csv).with(sales_logs)
job.perform(user, nil, {}, nil, nil, codes_only_export, "sales")
end
it "creates a CsvDownload record" do
job.perform(user, nil, {}, nil, nil, codes_only_export, "sales")
expect(CsvDownload.count).to eq(1)
expect(CsvDownload.first.user).to eq(user)
expect(CsvDownload.first.organisation).to eq(user.organisation)
expect(CsvDownload.first.filename).to match(/sales-logs-.*\.csv/)
expect(CsvDownload.first.download_type).to eq("sales")
expect(CsvDownload.first.expiration_time).to eq(172_800)
end
end
it "sends an E-mail with the presigned URL and duration" do
expect(mailer).to receive(:send_csv_download_mail).with(user, test_url, instance_of(Integer))
expect(mailer).to receive(:send_csv_download_mail).with(user, /csv-downloads/, instance_of(Integer))
job.perform(user)
end
end

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save