diff --git a/Gemfile b/Gemfile
index baaddab2e..e9af29d55 100644
--- a/Gemfile
+++ b/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"
diff --git a/app/components/create_log_actions_component.html.erb b/app/components/create_log_actions_component.html.erb
index 53e2bb57b..a600f3290 100644
--- a/app/components/create_log_actions_component.html.erb
+++ b/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 %>
diff --git a/app/components/create_log_actions_component.rb b/app/components/create_log_actions_component.rb
index 4395c48a9..896bfe97e 100644
--- a/app/components/create_log_actions_component.rb
+++ b/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
diff --git a/app/controllers/csv_downloads_controller.rb b/app/controllers/csv_downloads_controller.rb
new file mode 100644
index 000000000..25f70026f
--- /dev/null
+++ b/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
diff --git a/app/controllers/lettings_logs_controller.rb b/app/controllers/lettings_logs_controller.rb
index cc3c731d5..af3a6c32f 100644
--- a/app/controllers/lettings_logs_controller.rb
+++ b/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
diff --git a/app/controllers/merge_requests_controller.rb b/app/controllers/merge_requests_controller.rb
index a21d42bbb..a6e2c08e5 100644
--- a/app/controllers/merge_requests_controller.rb
+++ b/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
diff --git a/app/controllers/sales_logs_controller.rb b/app/controllers/sales_logs_controller.rb
index af9879896..8799fe528 100644
--- a/app/controllers/sales_logs_controller.rb
+++ b/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
diff --git a/app/controllers/schemes_controller.rb b/app/controllers/schemes_controller.rb
index 3df38a237..7f246b1e2 100644
--- a/app/controllers/schemes_controller.rb
+++ b/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
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 6283ef42e..28c693935 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/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
diff --git a/app/jobs/email_csv_job.rb b/app/jobs/email_csv_job.rb
index 58f2d50b8..dd0f2917c 100644
--- a/app/jobs/email_csv_job.rb
+++ b/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
diff --git a/app/jobs/scheme_email_csv_job.rb b/app/jobs/scheme_email_csv_job.rb
index 44d016a90..803d3dce3 100644
--- a/app/jobs/scheme_email_csv_job.rb
+++ b/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
diff --git a/app/models/csv_download.rb b/app/models/csv_download.rb
new file mode 100644
index 000000000..4064c62f3
--- /dev/null
+++ b/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
diff --git a/app/models/derived_variables/lettings_log_variables.rb b/app/models/derived_variables/lettings_log_variables.rb
index 9219392f7..4692a0e6b 100644
--- a/app/models/derived_variables/lettings_log_variables.rb
+++ b/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
diff --git a/app/models/derived_variables/sales_log_variables.rb b/app/models/derived_variables/sales_log_variables.rb
index 1e092a26a..4f4c3105d 100644
--- a/app/models/derived_variables/sales_log_variables.rb
+++ b/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?
diff --git a/app/models/form/lettings/subsections/household_needs.rb b/app/models/form/lettings/subsections/household_needs.rb
index 3bfbbb336..2f6900f4f 100644
--- a/app/models/form/lettings/subsections/household_needs.rb
+++ b/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
diff --git a/app/models/form/page.rb b/app/models/form/page.rb
index 7a5c4bf87..c1c09c362 100644
--- a/app/models/form/page.rb
+++ b/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
diff --git a/app/models/form/question.rb b/app/models/form/question.rb
index 9409350f6..05eec89dd 100644
--- a/app/models/form/question.rb
+++ b/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
diff --git a/app/models/form/sales/pages/buyer_interview.rb b/app/models/form/sales/pages/buyer_interview.rb
index c6a43690b..4398e434a 100644
--- a/app/models/form/sales/pages/buyer_interview.rb
+++ b/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
diff --git a/app/models/form/sales/pages/deposit.rb b/app/models/form/sales/pages/deposit.rb
index 4870a3c35..b204227b2 100644
--- a/app/models/form/sales/pages/deposit.rb
+++ b/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
diff --git a/app/models/form/sales/pages/deposit_discount.rb b/app/models/form/sales/pages/deposit_discount.rb
index 84fcbb45f..3fae9c0f8 100644
--- a/app/models/form/sales/pages/deposit_discount.rb
+++ b/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
diff --git a/app/models/form/sales/pages/discount.rb b/app/models/form/sales/pages/discount.rb
index 2d632985e..38d675a77 100644
--- a/app/models/form/sales/pages/discount.rb
+++ b/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,
}]
diff --git a/app/models/form/sales/pages/equity.rb b/app/models/form/sales/pages/equity.rb
index 9bf3050a0..46eec40a3 100644
--- a/app/models/form/sales/pages/equity.rb
+++ b/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
diff --git a/app/models/form/sales/pages/estate_management_fee.rb b/app/models/form/sales/pages/estate_management_fee.rb
new file mode 100644
index 000000000..5be478f80
--- /dev/null
+++ b/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
diff --git a/app/models/form/sales/pages/extra_borrowing.rb b/app/models/form/sales/pages/extra_borrowing.rb
index c6ddb705a..d86db67b5 100644
--- a/app/models/form/sales/pages/extra_borrowing.rb
+++ b/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 = [{
diff --git a/app/models/form/sales/pages/grant.rb b/app/models/form/sales/pages/grant.rb
index 2f96701c5..1d11fba82 100644
--- a/app/models/form/sales/pages/grant.rb
+++ b/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,
diff --git a/app/models/form/sales/pages/living_before_purchase.rb b/app/models/form/sales/pages/living_before_purchase.rb
index 3bb5510ce..b8797537b 100644
--- a/app/models/form/sales/pages/living_before_purchase.rb
+++ b/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
diff --git a/app/models/form/sales/pages/monthly_rent.rb b/app/models/form/sales/pages/monthly_rent.rb
index 943e47cff..29f0d895f 100644
--- a/app/models/form/sales/pages/monthly_rent.rb
+++ b/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
diff --git a/app/models/form/sales/pages/mortgage_amount.rb b/app/models/form/sales/pages/mortgage_amount.rb
index 41fba167c..e6a722853 100644
--- a/app/models/form/sales/pages/mortgage_amount.rb
+++ b/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
diff --git a/app/models/form/sales/pages/mortgage_lender.rb b/app/models/form/sales/pages/mortgage_lender.rb
index 87646a514..6db3c01df 100644
--- a/app/models/form/sales/pages/mortgage_lender.rb
+++ b/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 = [{
diff --git a/app/models/form/sales/pages/mortgage_lender_other.rb b/app/models/form/sales/pages/mortgage_lender_other.rb
index 903d6518f..f71089377 100644
--- a/app/models/form/sales/pages/mortgage_lender_other.rb
+++ b/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 = [{
diff --git a/app/models/form/sales/pages/mortgage_length.rb b/app/models/form/sales/pages/mortgage_length.rb
index 76c46694a..dbc01a695 100644
--- a/app/models/form/sales/pages/mortgage_length.rb
+++ b/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,
}]
diff --git a/app/models/form/sales/pages/mortgageused.rb b/app/models/form/sales/pages/mortgageused.rb
index ab48b0c2d..f9d8eae2e 100644
--- a/app/models/form/sales/pages/mortgageused.rb
+++ b/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
diff --git a/app/models/form/sales/pages/previous_bedrooms.rb b/app/models/form/sales/pages/previous_bedrooms.rb
index 26b3ef050..214632d49 100644
--- a/app/models/form/sales/pages/previous_bedrooms.rb
+++ b/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,
diff --git a/app/models/form/sales/pages/previous_property_type.rb b/app/models/form/sales/pages/previous_property_type.rb
index c5dd4f66a..26669d774 100644
--- a/app/models/form/sales/pages/previous_property_type.rb
+++ b/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 = [
diff --git a/app/models/form/sales/pages/previous_tenure.rb b/app/models/form/sales/pages/previous_tenure.rb
index c35b6bd67..0f4a4b250 100644
--- a/app/models/form/sales/pages/previous_tenure.rb
+++ b/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
diff --git a/app/models/form/sales/pages/privacy_notice.rb b/app/models/form/sales/pages/privacy_notice.rb
index 40c441d3e..c99ee3397 100644
--- a/app/models/form/sales/pages/privacy_notice.rb
+++ b/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
diff --git a/app/models/form/sales/pages/resale.rb b/app/models/form/sales/pages/resale.rb
index 6f4cd24e2..ffdbbc046 100644
--- a/app/models/form/sales/pages/resale.rb
+++ b/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,
diff --git a/app/models/form/sales/pages/staircase.rb b/app/models/form/sales/pages/staircase.rb
index 1413abadc..c96da3f3f 100644
--- a/app/models/form/sales/pages/staircase.rb
+++ b/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
diff --git a/app/models/form/sales/questions/buyer_interview.rb b/app/models/form/sales/questions/buyer_interview.rb
index b49b57807..50c290904 100644
--- a/app/models/form/sales/questions/buyer_interview.rb
+++ b/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]
diff --git a/app/models/form/sales/questions/deposit_amount.rb b/app/models/form/sales/questions/deposit_amount.rb
index 41586cd94..6f2b98ce8 100644
--- a/app/models/form/sales/questions/deposit_amount.rb
+++ b/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
diff --git a/app/models/form/sales/questions/deposit_discount.rb b/app/models/form/sales/questions/deposit_discount.rb
index 289e3962c..bfc5f425d 100644
--- a/app/models/form/sales/questions/deposit_discount.rb
+++ b/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
diff --git a/app/models/form/sales/questions/discount.rb b/app/models/form/sales/questions/discount.rb
index 3807a8cfc..5dcf1f125 100644
--- a/app/models/form/sales/questions/discount.rb
+++ b/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
diff --git a/app/models/form/sales/questions/equity.rb b/app/models/form/sales/questions/equity.rb
index 4aae785b8..7a2a4ce5b 100644
--- a/app/models/form/sales/questions/equity.rb
+++ b/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
diff --git a/app/models/form/sales/questions/extra_borrowing.rb b/app/models/form/sales/questions/extra_borrowing.rb
index e3cd0ff7e..2ad13ef5d 100644
--- a/app/models/form/sales/questions/extra_borrowing.rb
+++ b/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
diff --git a/app/models/form/sales/questions/fromprop.rb b/app/models/form/sales/questions/fromprop.rb
index 1a3393b7a..dec591cd0 100644
--- a/app/models/form/sales/questions/fromprop.rb
+++ b/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
diff --git a/app/models/form/sales/questions/grant.rb b/app/models/form/sales/questions/grant.rb
index 17361fe9c..f069fedd2 100644
--- a/app/models/form/sales/questions/grant.rb
+++ b/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
diff --git a/app/models/form/sales/questions/has_management_fee.rb b/app/models/form/sales/questions/has_management_fee.rb
new file mode 100644
index 000000000..20a71ff5e
--- /dev/null
+++ b/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
diff --git a/app/models/form/sales/questions/management_fee.rb b/app/models/form/sales/questions/management_fee.rb
new file mode 100644
index 000000000..213b9e3df
--- /dev/null
+++ b/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
diff --git a/app/models/form/sales/questions/monthly_rent.rb b/app/models/form/sales/questions/monthly_rent.rb
index 7e64d8571..8e9ecfaef 100644
--- a/app/models/form/sales/questions/monthly_rent.rb
+++ b/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
diff --git a/app/models/form/sales/questions/mortgage_amount.rb b/app/models/form/sales/questions/mortgage_amount.rb
index a6ffcf26a..e0677ee18 100644
--- a/app/models/form/sales/questions/mortgage_amount.rb
+++ b/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
diff --git a/app/models/form/sales/questions/mortgage_lender.rb b/app/models/form/sales/questions/mortgage_lender.rb
index a4aa55f17..96bf9e5b3 100644
--- a/app/models/form/sales/questions/mortgage_lender.rb
+++ b/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"
diff --git a/app/models/form/sales/questions/mortgage_lender_other.rb b/app/models/form/sales/questions/mortgage_lender_other.rb
index 49876efb0..8cd5de8fb 100644
--- a/app/models/form/sales/questions/mortgage_lender_other.rb
+++ b/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
diff --git a/app/models/form/sales/questions/mortgage_length.rb b/app/models/form/sales/questions/mortgage_length.rb
index 877818b98..5d94fc832 100644
--- a/app/models/form/sales/questions/mortgage_length.rb
+++ b/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
diff --git a/app/models/form/sales/questions/mortgageused.rb b/app/models/form/sales/questions/mortgageused.rb
index 433fdef16..1c683384b 100644
--- a/app/models/form/sales/questions/mortgageused.rb
+++ b/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
diff --git a/app/models/form/sales/questions/previous_bedrooms.rb b/app/models/form/sales/questions/previous_bedrooms.rb
index d29da208a..dd243137d 100644
--- a/app/models/form/sales/questions/previous_bedrooms.rb
+++ b/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
diff --git a/app/models/form/sales/questions/previous_tenure.rb b/app/models/form/sales/questions/previous_tenure.rb
index 55b103f0d..794d449b5 100644
--- a/app/models/form/sales/questions/previous_tenure.rb
+++ b/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
diff --git a/app/models/form/sales/questions/privacy_notice.rb b/app/models/form/sales/questions/privacy_notice.rb
index 5e73e7a3a..ace2c9ec1 100644
--- a/app/models/form/sales/questions/privacy_notice.rb
+++ b/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
diff --git a/app/models/form/sales/questions/resale.rb b/app/models/form/sales/questions/resale.rb
index 0026adb48..2417960b4 100644
--- a/app/models/form/sales/questions/resale.rb
+++ b/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]
diff --git a/app/models/form/sales/sections/sale_information.rb b/app/models/form/sales/sections/sale_information.rb
index 43018927c..a4f14321b 100644
--- a/app/models/form/sales/sections/sale_information.rb
+++ b/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)
diff --git a/app/models/form/sales/subsections/discounted_ownership_scheme.rb b/app/models/form/sales/subsections/discounted_ownership_scheme.rb
index 63ce4af47..c74dcd262 100644
--- a/app/models/form/sales/subsections/discounted_ownership_scheme.rb
+++ b/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
diff --git a/app/models/form/sales/subsections/outright_sale.rb b/app/models/form/sales/subsections/outright_sale.rb
index af63c8179..afa0f4a69 100644
--- a/app/models/form/sales/subsections/outright_sale.rb
+++ b/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
diff --git a/app/models/form/sales/subsections/shared_ownership_initial_purchase.rb b/app/models/form/sales/subsections/shared_ownership_initial_purchase.rb
new file mode 100644
index 000000000..5dfb322a2
--- /dev/null
+++ b/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
diff --git a/app/models/form/sales/subsections/shared_ownership_scheme.rb b/app/models/form/sales/subsections/shared_ownership_scheme.rb
index c77b4e4bd..d71dc4eb0 100644
--- a/app/models/form/sales/subsections/shared_ownership_scheme.rb
+++ b/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),
diff --git a/app/models/merge_request_organisation.rb b/app/models/merge_request_organisation.rb
index 6dda8b35e..5bfbe14d7 100644
--- a/app/models/merge_request_organisation.rb
+++ b/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
diff --git a/app/policies/csv_download_policy.rb b/app/policies/csv_download_policy.rb
new file mode 100644
index 000000000..04471ccd0
--- /dev/null
+++ b/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
diff --git a/app/services/bulk_upload/lettings/year2024/csv_parser.rb b/app/services/bulk_upload/lettings/year2024/csv_parser.rb
index 22caeab02..08e12353b 100644
--- a/app/services/bulk_upload/lettings/year2024/csv_parser.rb
+++ b/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
diff --git a/app/services/bulk_upload/sales/year2024/csv_parser.rb b/app/services/bulk_upload/sales/year2024/csv_parser.rb
index 4a3cb7ac9..b20c5b3d3 100644
--- a/app/services/bulk_upload/sales/year2024/csv_parser.rb
+++ b/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
diff --git a/app/services/csv/downloader.rb b/app/services/csv/downloader.rb
new file mode 100644
index 000000000..24545bc41
--- /dev/null
+++ b/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
diff --git a/app/services/feature_toggle.rb b/app/services/feature_toggle.rb
index 1b67b8b37..065c3b54e 100644
--- a/app/services/feature_toggle.rb
+++ b/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
diff --git a/app/services/mandatory_collection_resources_service.rb b/app/services/mandatory_collection_resources_service.rb
index 397e4b5d0..197e521d4 100644
--- a/app/services/mandatory_collection_resources_service.rb
+++ b/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"
diff --git a/app/views/csv_downloads/show.html.erb b/app/views/csv_downloads/show.html.erb
new file mode 100644
index 000000000..18f8a67fe
--- /dev/null
+++ b/app/views/csv_downloads/show.html.erb
@@ -0,0 +1,10 @@
+<% title = "Downlaod CSV file" %>
+<% content_for :title, title %>
+
+
+
+
You are about to download a CSV file
+
Filename: <%= @csv_download.filename %>
+ <%= govuk_button_link_to "Download CSV", download_csv_download_path(@csv_download) %>
+
+
diff --git a/app/views/errors/download_link_expired.html.erb b/app/views/errors/download_link_expired.html.erb
new file mode 100644
index 000000000..7477ee6b3
--- /dev/null
+++ b/app/views/errors/download_link_expired.html.erb
@@ -0,0 +1,8 @@
+<% content_for :title, "This link has expired" %>
+
+
+
+
This link has expired.
+
Download the logs again to get a new link.
+
+
diff --git a/app/views/form/guidance/_financial_calculations_shared_ownership.html.erb b/app/views/form/guidance/_financial_calculations_shared_ownership.html.erb
index 0741e6afa..2dd2f343e 100644
--- a/app/views/form/guidance/_financial_calculations_shared_ownership.html.erb
+++ b/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 %>
<% end %>
diff --git a/app/views/merge_requests/_notification_banners.html.erb b/app/views/merge_requests/_notification_banners.html.erb
index 38c05dbcd..9e6a085ca 100644
--- a/app/views/merge_requests/_notification_banners.html.erb
+++ b/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 %>
+
+ This merge is happening in the future. Wait until the merge date to begin this merge.
+
+ <% end %>
+<% end %>
diff --git a/app/views/merge_requests/show.html.erb b/app/views/merge_requests/show.html.erb
index 0fbde7621..040cd7704 100644
--- a/app/views/merge_requests/show.html.erb
+++ b/app/views/merge_requests/show.html.erb
@@ -12,7 +12,7 @@
<% unless @merge_request.status == "request_merged" || @merge_request.status == "processing" %>
- <%= 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 %>
<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f8bb8255b..698618717 100644
--- a/config/locales/en.yml
+++ b/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:
diff --git a/config/locales/forms/2024/lettings/setup.en.yml b/config/locales/forms/2024/lettings/setup.en.yml
index 5a18fe719..68bc95364 100644
--- a/config/locales/forms/2024/lettings/setup.en.yml
+++ b/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.
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).
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:
diff --git a/config/locales/forms/2025/sales/sale_information.en.yml b/config/locales/forms/2025/sales/sale_information.en.yml
index bf4db868a..939919a42 100644
--- a/config/locales/forms/2025/sales/sale_information.en.yml
+++ b/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"
diff --git a/config/routes.rb b/config/routes.rb
index 6ac7b3f34..55d58b41b 100644
--- a/config/routes.rb
+++ b/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
diff --git a/db/migrate/20241114154215_add_management_fee_fields.rb b/db/migrate/20241114154215_add_management_fee_fields.rb
new file mode 100644
index 000000000..f8455d259
--- /dev/null
+++ b/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
diff --git a/db/migrate/20241118104046_add_csv_download_table.rb b/db/migrate/20241118104046_add_csv_download_table.rb
new file mode 100644
index 000000000..9b4f73f0b
--- /dev/null
+++ b/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
diff --git a/db/schema.rb b/db/schema.rb
index 619d5af27..9c118b0b2 100644
--- a/db/schema.rb
+++ b/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
diff --git a/db/seeds.rb b/db/seeds.rb
index b58f7e0a8..9654b2b78 100644
--- a/db/seeds.rb
+++ b/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
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index f8962318e..d102b14ef 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -255,6 +255,7 @@ GEM
PLATFORMS
arm64-darwin-21
+ arm64-darwin-23
x86_64-darwin-22
x86_64-linux
diff --git a/docs/app_api.md b/docs/app_api.md
index e1987a897..627be0ad7 100644
--- a/docs/app_api.md
+++ b/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.
diff --git a/docs/exports.md b/docs/exports.md
index f16efe893..4b971e29a 100644
--- a/docs/exports.md
+++ b/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
diff --git a/docs/form/builder.md b/docs/form/builder.md
index 155abaed6..5d21b1c43 100644
--- a/docs/form/builder.md
+++ b/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
diff --git a/docs/form/definition.md b/docs/form/definition.md
index 3d27bb30e..8eb646284 100644
--- a/docs/form/definition.md
+++ b/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
diff --git a/docs/form/index.md b/docs/form/index.md
index 664b136c7..ed21e3b10 100644
--- a/docs/form/index.md
+++ b/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.
diff --git a/docs/form/page.md b/docs/form/page.md
index 47de24d98..7e0607a70 100644
--- a/docs/form/page.md
+++ b/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.
diff --git a/docs/form/question.md b/docs/form/question.md
index dd3254e72..7112596cf 100644
--- a/docs/form/question.md
+++ b/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).
diff --git a/docs/form/section.md b/docs/form/section.md
index 514842355..e4443af3b 100644
--- a/docs/form/section.md
+++ b/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.
diff --git a/docs/form/subsection.md b/docs/form/subsection.md
index aa81c0259..5e659836a 100644
--- a/docs/form/subsection.md
+++ b/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).
diff --git a/docs/setup.md b/docs/setup.md
index d14fa58d9..4400a7ae2 100644
--- a/docs/setup.md
+++ b/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
diff --git a/spec/factories/collection_resource.rb b/spec/factories/collection_resource.rb
index 0282e33e3..fb1895806 100644
--- a/spec/factories/collection_resource.rb
+++ b/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" }
diff --git a/spec/factories/csv_download.rb b/spec/factories/csv_download.rb
new file mode 100644
index 000000000..415f69de6
--- /dev/null
+++ b/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
diff --git a/spec/features/accessibility_spec.rb b/spec/features/accessibility_spec.rb
index 15cd87ebb..889fcdf26 100644
--- a/spec/features/accessibility_spec.rb
+++ b/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
diff --git a/spec/features/schemes_spec.rb b/spec/features/schemes_spec.rb
index 33ab00b34..7c7d9f3fb 100644
--- a/spec/features/schemes_spec.rb
+++ b/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
diff --git a/spec/helpers/collection_resources_helper_spec.rb b/spec/helpers/collection_resources_helper_spec.rb
index 9fc77a4c9..05c164fc1 100644
--- a/spec/helpers/collection_resources_helper_spec.rb
+++ b/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
diff --git a/spec/jobs/email_csv_job_spec.rb b/spec/jobs/email_csv_job_spec.rb
index 8e8a027ea..59cf7bf7d 100644
--- a/spec/jobs/email_csv_job_spec.rb
+++ b/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
diff --git a/spec/jobs/scheme_email_csv_job_spec.rb b/spec/jobs/scheme_email_csv_job_spec.rb
index 5ddaa91a6..efb75b698 100644
--- a/spec/jobs/scheme_email_csv_job_spec.rb
+++ b/spec/jobs/scheme_email_csv_job_spec.rb
@@ -3,10 +3,8 @@ require "rails_helper"
describe SchemeEmailCsvJob do
include Helpers
- test_url = :test_url
-
let(:job) { described_class.new }
- let(:storage_service) { instance_double(Storage::S3Service, write_file: nil, get_presigned_url: test_url) }
+ let(:storage_service) { instance_double(Storage::S3Service, write_file: nil) }
let(:mailer) { instance_double(CsvDownloadMailer, send_csv_download_mail: nil) }
let(:user) { FactoryBot.create(:user) }
@@ -53,6 +51,16 @@ describe SchemeEmailCsvJob do
job.perform(user, nil, {}, nil, nil, download_type)
end
end
+
+ it "creates a CsvDownload record" do
+ job.perform(user, nil, {}, nil, nil, download_type)
+ 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(/schemes-.*\.csv/)
+ expect(CsvDownload.first.download_type).to eq("schemes")
+ expect(CsvDownload.first.expiration_time).to eq(172_800)
+ end
end
context "when download type locations" do
@@ -62,6 +70,16 @@ describe SchemeEmailCsvJob do
expect(storage_service).to receive(:write_file).with(/locations-.*\.csv/, anything)
job.perform(user, nil, {}, nil, nil, download_type)
end
+
+ it "creates a CsvDownload record" do
+ job.perform(user, nil, {}, nil, nil, download_type)
+ 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(/locations-.*\.csv/)
+ expect(CsvDownload.first.download_type).to eq("locations")
+ expect(CsvDownload.first.expiration_time).to eq(172_800)
+ end
end
context "when download type combined" do
@@ -71,6 +89,16 @@ describe SchemeEmailCsvJob do
expect(storage_service).to receive(:write_file).with(/schemes-and-locations.*\.csv/, anything)
job.perform(user, nil, {}, nil, nil, download_type)
end
+
+ it "creates a CsvDownload record" do
+ job.perform(user, nil, {}, nil, nil, download_type)
+ 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(/schemes-and-locations-.*\.csv/)
+ expect(CsvDownload.first.download_type).to eq("combined")
+ expect(CsvDownload.first.expiration_time).to eq(172_800)
+ end
end
it "includes the organisation name in the filename when one is provided" do
@@ -117,7 +145,7 @@ describe SchemeEmailCsvJob do
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
diff --git a/spec/models/form/lettings/questions/homeless_spec.rb b/spec/models/form/lettings/questions/homeless_spec.rb
index f18a8ece7..7f2c1b054 100644
--- a/spec/models/form/lettings/questions/homeless_spec.rb
+++ b/spec/models/form/lettings/questions/homeless_spec.rb
@@ -28,10 +28,6 @@ RSpec.describe Form::Lettings::Questions::Homeless, type: :model do
})
end
- it "has no hint text" do
- expect(question.hint_text).to be_empty
- end
-
it "has the correct check_answers_card_number" do
expect(question.check_answers_card_number).to eq(0)
end
diff --git a/spec/models/form/page_spec.rb b/spec/models/form/page_spec.rb
index 9315fb237..3ec929fc4 100644
--- a/spec/models/form/page_spec.rb
+++ b/spec/models/form/page_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Form::Page, type: :model do
let(:enabled) { true }
let(:depends_on_met) { true }
let(:form) { instance_double(Form, depends_on_met:, type: "form-type", start_date: Time.utc(2024, 12, 25)) }
- let(:subsection) { instance_double(Form::Subsection, depends_on:, enabled?: enabled, form:, id: "subsection-id") }
+ let(:subsection) { instance_double(Form::Subsection, depends_on:, enabled?: enabled, form:, id: "subsection-id", copy_key: "subsection-copy-key") }
let(:page_id) { "net_income" }
let(:questions) { [["earnings", { "conditional_for" => { "age1": nil }, "type" => "radio" }], %w[incfreq]] }
let(:page_definition) do
@@ -25,7 +25,7 @@ RSpec.describe Form::Page, type: :model do
end
it "sets copy_key in the default style" do
- expect(page.copy_key).to eq("#{form.type}.#{subsection.id}.#{questions[0][0]}")
+ expect(page.copy_key).to eq("#{form.type}.#{subsection.copy_key}.#{questions[0][0]}")
end
context "when header is not provided" do
diff --git a/spec/models/form/question_spec.rb b/spec/models/form/question_spec.rb
index 651d167bd..8b9ab7a85 100644
--- a/spec/models/form/question_spec.rb
+++ b/spec/models/form/question_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Form::Question, type: :model do
let(:inferred_check_answers_value) { [{ "condition" => { "postcode_known" => 0 }, "value" => "Weekly" }] }
let(:form) { instance_double(Form, depends_on_met:, conditional_question_conditions:, type: "form-type", start_date: Time.utc(2024, 12, 25)) }
- let(:subsection) { instance_double(Form::Subsection, form:, id: "subsection-id") }
+ let(:subsection) { instance_double(Form::Subsection, form:, id: "subsection-id", copy_key: "subsection-copy-key") }
let(:page) { instance_double(Form::Page, subsection:, routed_to?: true, questions: form_questions) }
let(:question_id) { "earnings" }
let(:question_definition) do
@@ -39,7 +39,7 @@ RSpec.describe Form::Question, type: :model do
end
it "sets copy_key in the default style" do
- expect(question.copy_key).to eq("#{form.type}.#{subsection.id}.#{question_id}")
+ expect(question.copy_key).to eq("#{form.type}.#{subsection.copy_key}.#{question_id}")
end
context "when copy is not provided" do
diff --git a/spec/models/form/sales/pages/buyer_interview_spec.rb b/spec/models/form/sales/pages/buyer_interview_spec.rb
index f7c6bbb70..ea707a0fe 100644
--- a/spec/models/form/sales/pages/buyer_interview_spec.rb
+++ b/spec/models/form/sales/pages/buyer_interview_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Form::Sales::Pages::BuyerInterview, type: :model do
let(:page_id) { "buyer_interview" }
let(:page_definition) { nil }
let(:form) { instance_double(Form, start_date: Time.zone.local(2023, 4, 1), start_year_2024_or_later?: false) }
- let(:subsection) { instance_double(Form::Subsection, form:, id: "setup") }
+ let(:subsection) { instance_double(Form::Subsection, form:, id: "setup", copy_key: "subsection_copy_key") }
it "has correct subsection" do
expect(page.subsection).to eq(subsection)
@@ -24,43 +24,23 @@ RSpec.describe Form::Sales::Pages::BuyerInterview, type: :model do
expect(page.description).to be_nil
end
- context "when form is before 2024" do
- let(:subsection) { instance_double(Form::Subsection, form:, id: "household_characteristics") }
+ context "when there are joint buyers" do
+ subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: true) }
- context "when there are joint buyers" do
- subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: true) }
+ let(:subsection) { instance_double(Form::Subsection, form:, copy_key: "subsection_copy_key") }
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.household_characteristics.noint.joint_purchase")
- end
- end
-
- context "when there is a single buyer" do
- subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: false) }
-
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.household_characteristics.noint.not_joint_purchase")
- end
+ it "has the expected copy_key" do
+ expect(page.copy_key).to eq("sales.subsection_copy_key.noint.joint_purchase")
end
end
- context "when form is after 2024" do
- let(:form) { instance_double(Form, start_date: Time.zone.local(2024, 4, 1), start_year_2024_or_later?: true) }
-
- context "when there are joint buyers" do
- subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: true) }
-
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.setup.noint.joint_purchase")
- end
- end
+ context "when there is a single buyer" do
+ subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: false) }
- context "when there is a single buyer" do
- subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: false) }
+ let(:subsection) { instance_double(Form::Subsection, form:, copy_key: "subsection_copy_key") }
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.setup.noint.not_joint_purchase")
- end
+ it "has the expected copy_key" do
+ expect(page.copy_key).to eq("sales.subsection_copy_key.noint.not_joint_purchase")
end
end
end
diff --git a/spec/models/form/sales/pages/living_before_purchase_spec.rb b/spec/models/form/sales/pages/living_before_purchase_spec.rb
index 26026471b..b597f90e9 100644
--- a/spec/models/form/sales/pages/living_before_purchase_spec.rb
+++ b/spec/models/form/sales/pages/living_before_purchase_spec.rb
@@ -5,17 +5,19 @@ RSpec.describe Form::Sales::Pages::LivingBeforePurchase, type: :model do
let(:page_id) { nil }
let(:page_definition) { nil }
- let(:subsection) { instance_double(Form::Subsection) }
+ let(:start_year) { 2022 }
+ let(:form) { Form.new(nil, start_year, [], "sales") }
+ let(:subsection) { instance_double(Form::Subsection, depends_on: nil, form:) }
it "has correct subsection" do
expect(page.subsection).to eq(subsection)
end
describe "questions" do
- let(:subsection) { instance_double(Form::Subsection, form: instance_double(Form, start_date:)) }
+ let(:subsection) { instance_double(Form::Subsection, form:, depends_on: nil) }
context "when 2022" do
- let(:start_date) { Time.utc(2022, 2, 8) }
+ let(:start_year) { 2022 }
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[proplen])
@@ -23,7 +25,7 @@ RSpec.describe Form::Sales::Pages::LivingBeforePurchase, type: :model do
end
context "when 2023" do
- let(:start_date) { Time.utc(2023, 2, 8) }
+ let(:start_year) { 2023 }
it "has correct questions" do
expect(page.questions.map(&:id)).to eq(%w[proplen_asked proplen])
@@ -39,15 +41,63 @@ RSpec.describe Form::Sales::Pages::LivingBeforePurchase, type: :model do
expect(page.description).to be_nil
end
- it "has correct depends_on" do
- expect(page.depends_on).to eq([{ "not_joint_purchase?" => true }, { "jointpur" => nil }])
- end
+ context "when routing" do
+ context "with form before 2025" do
+ let(:start_year) { 2024 }
+
+ context "with joint purchase" do
+ subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, joint_purchase: true) }
+
+ it "routes to the page when joint purchase is true" do
+ log = build(:sales_log, jointpur: 1)
+ expect(page.routed_to?(log, nil)).to eq(true)
+ end
+
+ it "does not route to the page when joint purchase is false" do
+ log = build(:sales_log, jointpur: 2)
+ expect(page.routed_to?(log, nil)).to eq(false)
+ end
+
+ it "does not route to the page when joint purchase is missing" do
+ log = build(:sales_log, jointpur: nil)
+ expect(page.routed_to?(log, nil)).to eq(false)
+ end
+ end
+
+ context "with non joint purchase" do
+ subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, joint_purchase: false) }
+
+ it "routes to the page when joint purchase is false" do
+ log = build(:sales_log, jointpur: 2)
+ expect(page.routed_to?(log, nil)).to eq(true)
+ end
- context "with joint purchase" do
- subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, joint_purchase: true) }
+ it "does not route to the page when joint purchase is true" do
+ log = build(:sales_log, jointpur: 1)
+ expect(page.routed_to?(log, nil)).to eq(false)
+ end
- it "has correct depends_on" do
- expect(page.depends_on).to eq([{ "joint_purchase?" => true }])
+ it "routes to the page when joint purchase is missing" do
+ log = build(:sales_log, jointpur: nil)
+ expect(page.routed_to?(log, nil)).to eq(true)
+ end
+ end
+ end
+
+ context "with form on or after 2025" do
+ subject(:page) { described_class.new(page_id, page_definition, subsection, ownershipsch: 1, joint_purchase: true) }
+
+ let(:start_year) { 2025 }
+
+ it "routes to the page when resale is 2" do
+ log = build(:sales_log, jointpur: 1, resale: 2)
+ expect(page.routed_to?(log, nil)).to eq(true)
+ end
+
+ it "does not route to the page when resale is not 2" do
+ log = build(:sales_log, jointpur: 1, resale: nil)
+ expect(page.routed_to?(log, nil)).to eq(false)
+ end
end
end
end
diff --git a/spec/models/form/sales/pages/privacy_notice_spec.rb b/spec/models/form/sales/pages/privacy_notice_spec.rb
index 80be7ae66..8b21e7b9e 100644
--- a/spec/models/form/sales/pages/privacy_notice_spec.rb
+++ b/spec/models/form/sales/pages/privacy_notice_spec.rb
@@ -5,7 +5,7 @@ RSpec.describe Form::Sales::Pages::PrivacyNotice, type: :model do
let(:page_id) { "privacy_notice" }
let(:page_definition) { nil }
- let(:subsection) { instance_double(Form::Subsection, id: "setup") }
+ let(:subsection) { instance_double(Form::Subsection, id: "setup", copy_key: "setup") }
let(:form) { instance_double(Form, start_date: Time.zone.local(2023, 4, 1), start_year_2024_or_later?: false) }
before do
@@ -31,20 +31,10 @@ RSpec.describe Form::Sales::Pages::PrivacyNotice, type: :model do
context "when there are joint buyers" do
subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: true) }
- context "when the form start year is before 2024" do
- let(:subsection) { instance_double(Form::Subsection, id: "household_characteristics") }
+ let(:subsection) { instance_double(Form::Subsection, id: "subsection_id", copy_key: "subsection_copy_key") }
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.household_characteristics.privacynotice.joint_purchase")
- end
- end
-
- context "when the form start year is after 2024" do
- let(:form) { instance_double(Form, start_date: Time.zone.local(2024, 4, 1), start_year_2024_or_later?: true) }
-
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.setup.privacynotice.joint_purchase")
- end
+ it "has the expected copy_key" do
+ expect(page.copy_key).to eq("sales.subsection_copy_key.privacynotice.joint_purchase")
end
it "has correct depends_on" do
@@ -55,20 +45,10 @@ RSpec.describe Form::Sales::Pages::PrivacyNotice, type: :model do
context "when there is a single buyer" do
subject(:page) { described_class.new(page_id, page_definition, subsection, joint_purchase: false) }
- context "when the form start year is before 2024" do
- let(:subsection) { instance_double(Form::Subsection, id: "household_characteristics") }
-
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.household_characteristics.privacynotice.not_joint_purchase")
- end
- end
-
- context "when the form start year is after 2024" do
- let(:form) { instance_double(Form, start_date: Time.zone.local(2024, 4, 1), start_year_2024_or_later?: true) }
+ let(:subsection) { instance_double(Form::Subsection, id: "subsection_id", copy_key: "subsection_copy_key") }
- it "has the expected copy_key" do
- expect(page.copy_key).to eq("sales.setup.privacynotice.not_joint_purchase")
- end
+ it "has the expected copy_key" do
+ expect(page.copy_key).to eq("sales.subsection_copy_key.privacynotice.not_joint_purchase")
end
it "has correct depends_on" do
diff --git a/spec/models/form/sales/questions/buyer_interview_spec.rb b/spec/models/form/sales/questions/buyer_interview_spec.rb
index 0db43407f..e812f6146 100644
--- a/spec/models/form/sales/questions/buyer_interview_spec.rb
+++ b/spec/models/form/sales/questions/buyer_interview_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe Form::Sales::Questions::BuyerInterview, type: :model do
let(:question_id) { nil }
let(:question_definition) { nil }
let(:form) { instance_double(Form, start_date: Time.zone.local(2023, 4, 1), start_year_2024_or_later?: true) }
- let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form:, id: "setup")) }
+ let(:subsection) { instance_double(Form::Subsection, form:, copy_key: "setup") }
+ let(:page) { instance_double(Form::Page, subsection:) }
it "has correct page" do
expect(question.page).to eq(page)
@@ -34,42 +35,20 @@ RSpec.describe Form::Sales::Questions::BuyerInterview, type: :model do
context "when there are joint buyers" do
subject(:question) { described_class.new(question_id, question_definition, page, joint_purchase: true) }
- context "when the form start year is before 2024" do
- let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form:, id: "household_characteristics")) }
- let(:form) { instance_double(Form, start_date: Time.zone.local(2024, 3, 1), start_year_2024_or_later?: false) }
+ let(:subsection) { instance_double(Form::Subsection, form:, copy_key: "subsection_copy_key") }
- it "has the expected copy_key" do
- expect(question.copy_key).to eq("sales.household_characteristics.noint.joint_purchase")
- end
- end
-
- context "when the form start year is after 2024" do
- let(:form) { instance_double(Form, start_date: Time.zone.local(2024, 4, 1), start_year_2024_or_later?: true) }
-
- it "has the expected copy_key" do
- expect(question.copy_key).to eq("sales.setup.noint.joint_purchase")
- end
+ it "has the expected copy_key" do
+ expect(question.copy_key).to eq("sales.subsection_copy_key.noint.joint_purchase")
end
end
context "when there is a single buyer" do
subject(:question) { described_class.new(question_id, question_definition, page, joint_purchase: false) }
- context "when the form start year is before 2024" do
- let(:page) { instance_double(Form::Page, subsection: instance_double(Form::Subsection, form:, id: "household_characteristics")) }
- let(:form) { instance_double(Form, start_date: Time.zone.local(2023, 4, 1), start_year_2024_or_later?: false) }
-
- it "has the expected copy_key" do
- expect(question.copy_key).to eq("sales.household_characteristics.noint.not_joint_purchase")
- end
- end
-
- context "when the form start year is after 2024" do
- let(:form) { instance_double(Form, start_date: Time.zone.local(2024, 4, 1), start_year_2024_or_later?: true) }
+ let(:subsection) { instance_double(Form::Subsection, form:, copy_key: "subsection_copy_key") }
- it "has the expected copy_key" do
- expect(question.copy_key).to eq("sales.setup.noint.not_joint_purchase")
- end
+ it "has the expected copy_key" do
+ expect(question.copy_key).to eq("sales.subsection_copy_key.noint.not_joint_purchase")
end
end
end
diff --git a/spec/models/form/sales/questions/privacy_notice_spec.rb b/spec/models/form/sales/questions/privacy_notice_spec.rb
index 5f764c7af..f279c6bf9 100644
--- a/spec/models/form/sales/questions/privacy_notice_spec.rb
+++ b/spec/models/form/sales/questions/privacy_notice_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Form::Sales::Questions::PrivacyNotice, type: :model do
let(:question_id) { nil }
let(:question_definition) { nil }
let(:page) { instance_double(Form::Page) }
- let(:subsection) { instance_double(Form::Subsection, id: "setup") }
+ let(:subsection) { instance_double(Form::Subsection, id: "setup", copy_key: "setup") }
let(:form) { instance_double(Form, start_date: Time.zone.local(2023, 4, 1)) }
before do
@@ -32,7 +32,7 @@ RSpec.describe Form::Sales::Questions::PrivacyNotice, type: :model do
end
context "when the form year is before 2024" do
- let(:subsection) { instance_double(Form::Subsection, id: "household_characteristics") }
+ let(:subsection) { instance_double(Form::Subsection, id: "household_characteristics", copy_key: "household_characteristics") }
before do
allow(form).to receive(:start_year_2024_or_later?).and_return(false)
diff --git a/spec/models/form/sales/questions/uprn_confirmation_spec.rb b/spec/models/form/sales/questions/uprn_confirmation_spec.rb
index 0e54155b6..c6f7fd723 100644
--- a/spec/models/form/sales/questions/uprn_confirmation_spec.rb
+++ b/spec/models/form/sales/questions/uprn_confirmation_spec.rb
@@ -25,10 +25,6 @@ RSpec.describe Form::Sales::Questions::UprnConfirmation, type: :model do
expect(question.derived?(nil)).to be false
end
- it "has the correct unanswered_error_message" do
- expect(question.unanswered_error_message).to eq("You must answer #{format_ending(I18n.t('forms.2023.sales.property_information.uprn_confirmed.check_answer_label'))}")
- end
-
describe "notification_banner" do
context "when address is not present" do
it "returns nil" do
diff --git a/spec/models/form/sales/sections/sale_information_spec.rb b/spec/models/form/sales/sections/sale_information_spec.rb
index a424d1e02..757676014 100644
--- a/spec/models/form/sales/sections/sale_information_spec.rb
+++ b/spec/models/form/sales/sections/sale_information_spec.rb
@@ -5,7 +5,7 @@ RSpec.describe Form::Sales::Sections::SaleInformation, type: :model do
let(:section_id) { nil }
let(:section_definition) { nil }
- let(:form) { instance_double(Form) }
+ let(:form) { instance_double(Form, start_year_2025_or_later?: false) }
before do
allow(form).to receive(:start_year_2025_or_later?).and_return(false)
@@ -15,12 +15,26 @@ RSpec.describe Form::Sales::Sections::SaleInformation, type: :model do
expect(sale_information.form).to eq(form)
end
- it "has correct subsections" do
- expect(sale_information.subsections.map(&:id)).to eq(%w[
- shared_ownership_scheme
- discounted_ownership_scheme
- outright_sale
- ])
+ context "when form is before 2025" do
+ it "has correct subsections" do
+ expect(sale_information.subsections.map(&:id)).to eq(%w[
+ shared_ownership_scheme
+ discounted_ownership_scheme
+ outright_sale
+ ])
+ end
+ end
+
+ context "when form is 2025 or later" do
+ let(:form) { instance_double(Form, start_year_2025_or_later?: true) }
+
+ it "has correct subsections" do
+ expect(sale_information.subsections.map(&:id)).to eq(%w[
+ shared_ownership_initial_purchase
+ discounted_ownership_scheme
+ outright_sale
+ ])
+ end
end
it "has the correct id" do
diff --git a/spec/models/form/sales/subsections/discounted_ownership_scheme_spec.rb b/spec/models/form/sales/subsections/discounted_ownership_scheme_spec.rb
index a573103a3..3fa57571b 100644
--- a/spec/models/form/sales/subsections/discounted_ownership_scheme_spec.rb
+++ b/spec/models/form/sales/subsections/discounted_ownership_scheme_spec.rb
@@ -56,6 +56,10 @@ RSpec.describe Form::Sales::Subsections::DiscountedOwnershipScheme, type: :model
expect(discounted_ownership_scheme.id).to eq("discounted_ownership_scheme")
end
+ it "has the correct copy key" do
+ expect(discounted_ownership_scheme.copy_key).to eq("sale_information")
+ end
+
it "has the correct label" do
expect(discounted_ownership_scheme.label).to eq("Discounted ownership scheme")
end
diff --git a/spec/models/form/sales/subsections/outright_sale_spec.rb b/spec/models/form/sales/subsections/outright_sale_spec.rb
index 52941d0a9..b91553939 100644
--- a/spec/models/form/sales/subsections/outright_sale_spec.rb
+++ b/spec/models/form/sales/subsections/outright_sale_spec.rb
@@ -109,6 +109,10 @@ RSpec.describe Form::Sales::Subsections::OutrightSale, type: :model do
expect(outright_sale.id).to eq("outright_sale")
end
+ it "has the correct copy key" do
+ expect(outright_sale.copy_key).to eq("sale_information")
+ end
+
it "has the correct label" do
expect(outright_sale.label).to eq("Outright sale")
end
diff --git a/spec/models/form/sales/subsections/shared_ownership_initial_purchase_spec.rb b/spec/models/form/sales/subsections/shared_ownership_initial_purchase_spec.rb
new file mode 100644
index 000000000..3b2d72b01
--- /dev/null
+++ b/spec/models/form/sales/subsections/shared_ownership_initial_purchase_spec.rb
@@ -0,0 +1,95 @@
+require "rails_helper"
+
+RSpec.describe Form::Sales::Subsections::SharedOwnershipInitialPurchase, type: :model do
+ subject(:shared_ownership_initial_purchase) { described_class.new(subsection_id, subsection_definition, section) }
+
+ let(:subsection_id) { nil }
+ let(:subsection_definition) { nil }
+ let(:section) { instance_double(Form::Sales::Sections::SaleInformation) }
+
+ before do
+ allow(section).to receive(:form).and_return(instance_double(Form, start_date: Time.zone.local(2025, 4, 1)))
+ end
+
+ it "has correct section" do
+ expect(shared_ownership_initial_purchase.section).to eq(section)
+ end
+
+ it "has correct pages" do
+ expect(shared_ownership_initial_purchase.pages.map(&:id)).to eq(
+ %w[
+ resale
+ living_before_purchase_shared_ownership_joint_purchase
+ living_before_purchase_shared_ownership
+ handover_date
+ handover_date_check
+ buyer_previous_joint_purchase
+ buyer_previous_not_joint_purchase
+ previous_bedrooms
+ previous_property_type
+ shared_ownership_previous_tenure
+ value_shared_ownership
+ about_price_shared_ownership_value_check
+ equity
+ shared_ownership_equity_value_check
+ mortgage_used_shared_ownership
+ mortgage_used_mortgage_value_check
+ mortgage_amount_shared_ownership
+ shared_ownership_mortgage_amount_value_check
+ mortgage_amount_mortgage_value_check
+ mortgage_length_shared_ownership
+ deposit_shared_ownership
+ deposit_shared_ownership_optional
+ deposit_joint_purchase_value_check
+ deposit_value_check
+ deposit_discount
+ deposit_discount_optional
+ shared_ownership_deposit_value_check
+ monthly_rent
+ leasehold_charges_shared_ownership
+ monthly_charges_shared_ownership_value_check
+ estate_management_fee
+ ],
+ )
+ end
+
+ it "has the correct id" do
+ expect(shared_ownership_initial_purchase.id).to eq("shared_ownership_initial_purchase")
+ end
+
+ it "has the correct label" do
+ expect(shared_ownership_initial_purchase.label).to eq("Shared ownership - initial purchase")
+ end
+
+ it "has the correct depends_on" do
+ expect(shared_ownership_initial_purchase.depends_on).to eq([
+ {
+ "ownershipsch" => 1, "setup_completed?" => true, "staircase" => 2
+ },
+ ])
+ end
+
+ context "when it is a shared ownership scheme and not staircase" do
+ let(:log) { FactoryBot.build(:sales_log, ownershipsch: 1, staircase: 2) }
+
+ it "is displayed in tasklist" do
+ expect(shared_ownership_initial_purchase.displayed_in_tasklist?(log)).to eq(true)
+ end
+ end
+
+ context "when it is not a shared ownership scheme" do
+ let(:log) { FactoryBot.build(:sales_log, ownershipsch: 2, staircase: 2) }
+
+ it "is displayed in tasklist" do
+ expect(shared_ownership_initial_purchase.displayed_in_tasklist?(log)).to eq(false)
+ end
+ end
+
+ context "when it is staircase" do
+ let(:log) { FactoryBot.build(:sales_log, ownershipsch: 1, staircase: 1) }
+
+ it "is displayed in tasklist" do
+ expect(shared_ownership_initial_purchase.displayed_in_tasklist?(log)).to eq(false)
+ end
+ end
+end
diff --git a/spec/models/form/sales/subsections/shared_ownership_scheme_spec.rb b/spec/models/form/sales/subsections/shared_ownership_scheme_spec.rb
index 4c546d58c..51e2ee60d 100644
--- a/spec/models/form/sales/subsections/shared_ownership_scheme_spec.rb
+++ b/spec/models/form/sales/subsections/shared_ownership_scheme_spec.rb
@@ -65,6 +65,10 @@ RSpec.describe Form::Sales::Subsections::SharedOwnershipScheme, type: :model do
expect(shared_ownership_scheme.id).to eq("shared_ownership_scheme")
end
+ it "has the correct copy key" do
+ expect(shared_ownership_scheme.copy_key).to eq("sale_information")
+ end
+
it "has the correct label" do
expect(shared_ownership_scheme.label).to eq("Shared ownership scheme")
end
diff --git a/spec/models/form/subsection_spec.rb b/spec/models/form/subsection_spec.rb
index 8a4b9d3d7..0215e51dc 100644
--- a/spec/models/form/subsection_spec.rb
+++ b/spec/models/form/subsection_spec.rb
@@ -19,6 +19,10 @@ RSpec.describe Form::Subsection, type: :model do
expect(subsection.id).to eq(subsection_id)
end
+ it "has a copy_key defaulting to the id" do
+ expect(subsection.copy_key).to eq(subsection_id)
+ end
+
it "has a label" do
expect(subsection.label).to eq("Household characteristics")
end
diff --git a/spec/models/lettings_log_spec.rb b/spec/models/lettings_log_spec.rb
index c47de8cf6..5702e65c8 100644
--- a/spec/models/lettings_log_spec.rb
+++ b/spec/models/lettings_log_spec.rb
@@ -809,6 +809,21 @@ RSpec.describe LettingsLog do
expect { lettings_log.update!(nationality_all_group: nil, declaration: 1) }.not_to change(lettings_log, :nationality_all)
end
end
+
+ context "when form year changes and LA is no longer active" do
+ before do
+ LocalAuthority.find_by(code: "E08000003").update!(end_date: Time.zone.today)
+ end
+
+ it "removes the LA" do
+ lettings_log.update!(startdate: Time.zone.yesterday, la: "E08000003")
+ expect(lettings_log.reload.la).to eq("E08000003")
+
+ lettings_log.update!(startdate: Time.zone.tomorrow)
+ expect(lettings_log.reload.la).to eq(nil)
+ expect(lettings_log.reload.is_la_inferred).to eq(false)
+ end
+ end
end
describe "optional fields" do
diff --git a/spec/models/sales_log_spec.rb b/spec/models/sales_log_spec.rb
index ae9b00d4c..f3dea90f9 100644
--- a/spec/models/sales_log_spec.rb
+++ b/spec/models/sales_log_spec.rb
@@ -978,5 +978,22 @@ RSpec.describe SalesLog, type: :model do
end
end
end
+
+ context "when form year changes and LA is no longer active" do
+ let!(:sales_log) { create(:sales_log) }
+
+ before do
+ LocalAuthority.find_by(code: "E08000003").update!(end_date: Time.zone.today)
+ end
+
+ it "removes the LA" do
+ sales_log.update!(saledate: Time.zone.yesterday, la: "E08000003")
+ expect(sales_log.reload.la).to eq("E08000003")
+
+ sales_log.update!(saledate: Time.zone.tomorrow)
+ expect(sales_log.reload.la).to eq(nil)
+ expect(sales_log.reload.is_la_inferred).to eq(false)
+ end
+ end
end
# rubocop:enable RSpec/MessageChain
diff --git a/spec/requests/csv_downloads_controller_spec.rb b/spec/requests/csv_downloads_controller_spec.rb
new file mode 100644
index 000000000..982077a12
--- /dev/null
+++ b/spec/requests/csv_downloads_controller_spec.rb
@@ -0,0 +1,136 @@
+require "rails_helper"
+
+RSpec.describe CsvDownloadsController, type: :request do
+ describe "GET #show" do
+ let(:page) { Capybara::Node::Simple.new(response.body) }
+ let(:csv_user) { create(:user) }
+ let(:csv_download) { create(:csv_download, user: csv_user, organisation: csv_user.organisation) }
+ let(:get_file_io) do
+ io = StringIO.new
+ io.write("hello")
+ io.rewind
+ io
+ end
+ let(:mock_storage_service) { instance_double(Storage::S3Service, get_file_io:, get_presigned_url: "https://example.com") }
+
+ before do
+ allow(Storage::S3Service).to receive(:new).and_return(mock_storage_service)
+ end
+
+ context "when user is not signed in" do
+ it "redirects to sign in page" do
+ get "/csv-downloads/#{csv_download.id}"
+ expect(response).to redirect_to("/account/sign-in")
+ end
+ end
+
+ context "when user is signed in" do
+ before do
+ sign_in user
+ end
+
+ context "and the user is from a different organisation" do
+ let(:user) { create(:user) }
+
+ before do
+ get "/csv-downloads/#{csv_download.id}"
+ end
+
+ it "returns page not found" do
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context "and is the user who generated the csv" do
+ let(:user) { csv_user }
+
+ before do
+ get "/csv-downloads/#{csv_download.id}"
+ end
+
+ it "allows downloading the csv" do
+ expect(response).to have_http_status(:ok)
+ expect(page).to have_link("Download CSV", href: "/csv-downloads/#{csv_download.id}/download")
+ end
+ end
+
+ context "and is the user is from the same organisation" do
+ let(:user) { create(:user, organisation: csv_user.organisation) }
+
+ before do
+ get "/csv-downloads/#{csv_download.id}"
+ end
+
+ it "allows downloading the csv" do
+ expect(response).to have_http_status(:ok)
+ expect(page).to have_link("Download CSV", href: "/csv-downloads/#{csv_download.id}/download")
+ end
+ end
+ end
+ end
+
+ describe "GET #download" do
+ let(:csv_user) { create(:user) }
+ let(:csv_download) { create(:csv_download, user: csv_user, organisation: csv_user.organisation) }
+ let(:get_file_io) do
+ io = StringIO.new
+ io.write("hello")
+ io.rewind
+ io
+ end
+ let(:mock_storage_service) { instance_double(Storage::S3Service, get_file_io:, get_presigned_url: "https://example.com") }
+
+ before do
+ allow(Storage::S3Service).to receive(:new).and_return(mock_storage_service)
+ end
+
+ context "when user is not signed in" do
+ it "redirects to sign in page" do
+ get "/csv-downloads/#{csv_download.id}/download"
+ expect(response).to redirect_to("/account/sign-in")
+ end
+ end
+
+ context "when user is signed in" do
+ before do
+ sign_in user
+ end
+
+ context "and the user is from a different organisation" do
+ let(:user) { create(:user) }
+
+ before do
+ get "/csv-downloads/#{csv_download.id}/download"
+ end
+
+ it "returns page not found" do
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context "and is the user who generated the csv" do
+ let(:user) { csv_user }
+
+ before do
+ get "/csv-downloads/#{csv_download.id}/download"
+ end
+
+ it "allows downloading the csv" do
+ expect(response).to have_http_status(:found)
+ end
+ end
+
+ context "and is the user is from the same organisation" do
+ let(:user) { create(:user, organisation: csv_user.organisation) }
+
+ before do
+ get "/csv-downloads/#{csv_download.id}/download"
+ end
+
+ it "allows downloading the csv" do
+ expect(response).to have_http_status(:found)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/merge_requests_controller_spec.rb b/spec/requests/merge_requests_controller_spec.rb
index dc1dd817d..a73db8067 100644
--- a/spec/requests/merge_requests_controller_spec.rb
+++ b/spec/requests/merge_requests_controller_spec.rb
@@ -84,6 +84,22 @@ RSpec.describe MergeRequestsController, type: :request do
end
end
+ context "when the user updates merge request with organisation that is already part of another merge" do
+ let(:another_organisation) { create(:organisation) }
+ let(:other_merge_request) { create(:merge_request, merge_date: Time.zone.local(2022, 5, 4)) }
+ let(:params) { { merge_request: { merging_organisation: another_organisation.id, new_merging_org_ids: [] } } }
+
+ before do
+ MergeRequestOrganisation.create!(merge_request_id: other_merge_request.id, merging_organisation_id: another_organisation.id)
+ patch "/merge-request/#{merge_request.id}/merging-organisations", headers:, params:
+ end
+
+ it "displays the page with an error message" do
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(page).to have_content("Another merge request records #{another_organisation.name} as merging into #{other_merge_request.absorbing_organisation&.name} on 4 May 2022. Select another organisation or remove this organisation from the other merge request.")
+ end
+ end
+
context "when the user selects an organisation that is a part of another merge" do
let(:another_organisation) { create(:organisation) }
let(:params) { { merge_request: { merging_organisation: another_organisation.id, new_merging_org_ids: [] } } }
@@ -396,6 +412,24 @@ RSpec.describe MergeRequestsController, type: :request do
}.from(nil).to(Time.zone.local(2022, 4, 10))
end
end
+
+ context "when merge date set to a date more than 1 year in the future" do
+ let(:merge_request) { MergeRequest.create!(requesting_organisation: organisation) }
+ let(:params) do
+ { merge_request: { page: "merge_date", "merge_date(3i)": (Time.zone.now.day + 1).to_s, "merge_date(2i)": Time.zone.now.month.to_s, "merge_date(1i)": (Time.zone.now.year + 1).to_s } }
+ end
+
+ let(:request) do
+ patch "/merge-request/#{merge_request.id}", headers:, params:
+ end
+
+ it "displays the page with an error message" do
+ request
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(page).to have_content("The merge date must not be later than a year from today’s date.")
+ end
+ end
end
describe "from merging_organisations page" do
diff --git a/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb b/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb
index d0e5b3692..b0fcaf8b6 100644
--- a/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb
+++ b/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb
@@ -30,6 +30,29 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do
end
end
+ context "when some csv headers are empty (and we don't care about them)" do
+ before do
+ file.write("Question\n")
+ file.write("Additional info\n")
+ file.write("Values\n")
+ file.write("\n")
+ file.write("Type of letting the question applies to\n")
+ file.write("Duplicate check field?\n")
+ file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row)
+ file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row)
+ file.rewind
+ end
+
+ it "returns correct offsets" do
+ expect(service.row_offset).to eq(7)
+ expect(service.col_offset).to eq(1)
+ end
+
+ it "parses csv correctly" do
+ expect(service.row_parsers[0].field_13).to eql(log.tenancycode)
+ end
+ end
+
context "when parsing csv with headers with extra rows" do
before do
file.write("Section\n")
diff --git a/spec/services/bulk_upload/sales/year2024/csv_parser_spec.rb b/spec/services/bulk_upload/sales/year2024/csv_parser_spec.rb
index 9440b7e8c..5f9f003d0 100644
--- a/spec/services/bulk_upload/sales/year2024/csv_parser_spec.rb
+++ b/spec/services/bulk_upload/sales/year2024/csv_parser_spec.rb
@@ -39,6 +39,38 @@ RSpec.describe BulkUpload::Sales::Year2024::CsvParser do
end
end
+ context "when some csv headers are empty (and we don't care about them)" do
+ before do
+ file.write("Question\n")
+ file.write("Additional info\n")
+ file.write("Values\n")
+ file.write("\n")
+ file.write("Type of letting the question applies to\n")
+ file.write("Duplicate check field?\n")
+ file.write(BulkUpload::SalesLogToCsv.new(log:).default_2024_field_numbers_row)
+ file.write(BulkUpload::SalesLogToCsv.new(log:).to_2024_csv_row)
+ file.write("\n")
+ file.rewind
+ end
+
+ it "returns correct offsets" do
+ expect(service.row_offset).to eq(7)
+ expect(service.col_offset).to eq(1)
+ end
+
+ it "parses csv correctly" do
+ expect(service.row_parsers[0].field_22).to eql(log.uprn)
+ end
+
+ it "counts the number of valid field numbers correctly" do
+ expect(service).to be_correct_field_count
+ end
+
+ it "does not parse the last empty row" do
+ expect(service.row_parsers.count).to eq(1)
+ end
+ end
+
context "when parsing csv with headers in arbitrary order" do
let(:seed) { rand }
diff --git a/spec/services/mandatory_collection_resources_service_spec.rb b/spec/services/mandatory_collection_resources_service_spec.rb
index 423370e96..6b67e1d13 100644
--- a/spec/services/mandatory_collection_resources_service_spec.rb
+++ b/spec/services/mandatory_collection_resources_service_spec.rb
@@ -17,7 +17,7 @@ describe MandatoryCollectionResourcesService do
it "returns a CollectionResource object with the correct attributes" do
resource = service.generate_resource("lettings", 2024, "paper_form")
expect(resource.resource_type).to eq("paper_form")
- expect(resource.display_name).to eq("lettings log for tenants (2024 to 2025)")
+ expect(resource.display_name).to eq("lettings paper form (2024 to 2025)")
expect(resource.short_display_name).to eq("Paper form")
expect(resource.year).to eq(2024)
expect(resource.log_type).to eq("lettings")