Browse Source

Merge branch 'main' into CLDC-3382-Support-user-functionality-merge-organisations

pull/2598/head
Manny Dinssa 2 years ago committed by GitHub
parent
commit
38323e3fdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      .github/workflows/aws_deploy.yml
  2. 19
      .prettierignore
  3. 1
      .prettierrc
  4. 12
      .rubocop.yml
  5. 14
      README.md
  6. 8
      app/components/bulk_upload_error_row_component.html.erb
  7. 2
      app/components/bulk_upload_error_summary_table_component.html.erb
  8. 74
      app/controllers/form_controller.rb
  9. 15
      app/controllers/organisations_controller.rb
  10. 11
      app/controllers/users_controller.rb
  11. 3
      app/frontend/controllers/index.js
  12. 46
      app/frontend/controllers/search_controller.js
  13. 49
      app/frontend/modules/search.js
  14. 10
      app/frontend/styles/_button.scss
  15. 2
      app/frontend/styles/_feedback.scss
  16. 4
      app/frontend/styles/_filter-layout.scss
  17. 8
      app/frontend/styles/_filter.scss
  18. 4
      app/frontend/styles/_panel.scss
  19. 2
      app/frontend/styles/_primary-navigation.scss
  20. 2
      app/frontend/styles/_sub-navigation.scss
  21. 2
      app/frontend/styles/_table-group.scss
  22. 2
      app/frontend/styles/_task-list.scss
  23. 4
      app/frontend/styles/application.scss
  24. 82
      app/helpers/filters_helper.rb
  25. 11
      app/helpers/guidance_helper.rb
  26. 2
      app/models/form/lettings/pages/uprn_selection.rb
  27. 26
      app/models/form/sales/pages/about_deposit_without_discount.rb
  28. 22
      app/models/form/sales/pages/deposit.rb
  29. 5
      app/models/form/sales/pages/deposit_discount.rb
  30. 5
      app/models/form/sales/pages/discount.rb
  31. 5
      app/models/form/sales/pages/equity.rb
  32. 5
      app/models/form/sales/pages/grant.rb
  33. 1
      app/models/form/sales/pages/mortgageused.rb
  34. 18
      app/models/form/sales/pages/purchase_price.rb
  35. 2
      app/models/form/sales/pages/purchase_price_outright_ownership.rb
  36. 13
      app/models/form/sales/pages/value_shared_ownership.rb
  37. 7
      app/models/form/sales/questions/deposit_amount.rb
  38. 1
      app/models/form/sales/questions/deposit_discount.rb
  39. 1
      app/models/form/sales/questions/discount.rb
  40. 1
      app/models/form/sales/questions/equity.rb
  41. 1
      app/models/form/sales/questions/grant.rb
  42. 7
      app/models/form/sales/questions/mortgage_amount.rb
  43. 7
      app/models/form/sales/questions/mortgageused.rb
  44. 6
      app/models/form/sales/questions/purchase_price.rb
  45. 1
      app/models/form/sales/questions/staircase_bought.rb
  46. 1
      app/models/form/sales/questions/value.rb
  47. 7
      app/models/form/sales/subsections/discounted_ownership_scheme.rb
  48. 2
      app/models/form/sales/subsections/outright_sale.rb
  49. 11
      app/models/form/sales/subsections/shared_ownership_scheme.rb
  50. 4
      app/models/lettings_log.rb
  51. 3
      app/models/log.rb
  52. 1
      app/models/organisation.rb
  53. 5
      app/models/user.rb
  54. 139
      app/models/validations/sales/sale_information_validations.rb
  55. 2
      app/services/bulk_upload/lettings/year2023/row_parser.rb
  56. 43
      app/services/bulk_upload/lettings/year2024/row_parser.rb
  57. 4
      app/services/feature_toggle.rb
  58. 11
      app/services/filter_manager.rb
  59. 13
      app/views/content/privacy_notice.md
  60. 1
      app/views/filters/_radio_filter.html.erb
  61. 20
      app/views/filters/_text_select_filter.html.erb
  62. 3
      app/views/filters/assigned_to.html.erb
  63. 3
      app/views/filters/managed_by.html.erb
  64. 3
      app/views/filters/owned_by.html.erb
  65. 4
      app/views/form/_numeric_question.html.erb
  66. 2
      app/views/form/_radio_question.html.erb
  67. 29
      app/views/form/guidance/_financial_calculations_discounted_ownership.html.erb
  68. 13
      app/views/form/guidance/_financial_calculations_outright_sale.html.erb
  69. 30
      app/views/form/guidance/_financial_calculations_shared_ownership.html.erb
  70. 15
      app/views/logs/_log_filters.html.erb
  71. 2
      app/views/schemes/_scheme_filters.html.erb
  72. 8
      babel.config.js
  73. 376
      config/forms/2021_2022.json
  74. 1008
      config/forms/2022_2023.json
  75. 16
      config/forms/schema/2021_2022.json
  76. 47
      config/forms/schema/generic.json
  77. 20
      config/locales/en.yml
  78. 8
      config/routes.rb
  79. 1
      config/storage.yml
  80. 2
      docker-compose.yml
  81. 2
      docs/adr/adr-006-saving-values.md
  82. 7
      docs/adr/adr-015-asset-pipeline.md
  83. 2
      docs/adr/adr-016-hotwire.md
  84. 3
      docs/adr/adr-018-form-setup.md
  85. 1
      docs/adr/adr-019-form-end-dates.md
  86. 31
      docs/api/v1.json
  87. 10
      docs/app_api.md
  88. 8
      docs/documentation_website.md
  89. 2
      docs/form/builder.md
  90. 4
      docs/form/definition.md
  91. 4
      docs/form/question.md
  92. BIN
      docs/images/service.jpeg
  93. BIN
      docs/images/service.png
  94. 41
      docs/infrastructure.md
  95. 19
      docs/monitoring.md
  96. 238
      docs/setup.md
  97. 17
      docs/testing.md
  98. 7
      lib/tasks/lint.rake
  99. 5
      package.json
  100. 4
      spec/features/lettings_log_spec.rb
  101. Some files were not shown because too many files have changed in this diff Show More

4
.github/workflows/aws_deploy.yml

@ -53,7 +53,7 @@ jobs:
id: ecr-login
uses: aws-actions/amazon-ecr-login@v1
with:
mask-password: 'true'
mask-password: "true"
- name: Check if image with tag already exists
run: |
@ -90,7 +90,7 @@ jobs:
id: ecr-login
uses: aws-actions/amazon-ecr-login@v1
with:
mask-password: 'true'
mask-password: "true"
- name: Get timestamp
id: timestamp

19
.prettierignore

@ -0,0 +1,19 @@
# Ignore everything except as negated below
*
!*.scss
!*.md
!*.yml
!*.json
# Ensures subdirectories are checked for files matching the above
!*/
config/locales/
app/views/content/data_sharing_agreement.md
/node_modules
/vendor
/coverage
/public/assets
/app/assets/builds/*

1
.prettierrc

@ -0,0 +1 @@
{}

12
.rubocop.yml

@ -11,12 +11,12 @@ inherit_gem:
AllCops:
Exclude:
- 'bin/*'
- 'db/schema.rb'
- 'node_modules/**/*'
- 'config/application.rb'
- 'config/puma.rb'
- 'vendor/**/*'
- "bin/*"
- "db/schema.rb"
- "node_modules/**/*"
- "config/application.rb"
- "config/puma.rb"
- "vendor/**/*"
Style/Documentation:
Enabled: false

14
README.md

@ -3,16 +3,16 @@
[![Production CI/CD Pipeline](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/production_pipeline.yml/badge.svg)](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/production_pipeline.yml)
[![Staging CI/CD Pipeline](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/staging_pipeline.yml/badge.svg)](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/actions/workflows/staging_pipeline.yml)
Ruby on Rails app that handles the submission of lettings and sales of social housing data in England. Currently in private beta.
Ruby on Rails app that handles the submission of lettings and sales of social housing data in England. Currently in public beta.
## Domain documentation
* [Domain and technical documentation](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data)
* [Local development setup](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/setup)
* [Architecture decision records](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/adr)
* [API browser](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/api) (using this [OpenAPI specification](docs/api/v1.json))
* [Design history](https://core-design-history.herokuapp.com)
- [Domain and technical documentation](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data)
- [Local development setup](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/setup)
- [Architecture decision records](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/adr)
- [API browser](https://communitiesuk.github.io/submit-social-housing-lettings-and-sales-data/api) (using this [OpenAPI specification](docs/api/v1.json))
- [Design history](https://core-design-history.herokuapp.com)
## User interface
![View of the logs list](docs/images/service.png)
![View of the logs list](docs/images/service.jpeg)

8
app/components/bulk_upload_error_row_component.html.erb

@ -24,8 +24,8 @@
<% critical_errors.each do |error| %>
<% body.with_row do |row| %>
<% row.with_cell(text: error.cell) %>
<% row.with_cell(text: question_for_field(error.field)) %>
<% row.with_cell(text: error.error, html_attributes: { class: "govuk-!-font-weight-bold" }) %>
<% row.with_cell(text: question_for_field(error.field), html_attributes: { class: "govuk-!-width-one-half" }) %>
<% row.with_cell(text: error.error.html_safe, html_attributes: { class: "govuk-!-font-weight-bold govuk-!-width-one-half" }) %>
<% row.with_cell(text: error.field.humanize) %>
<% end %>
<% end %>
@ -54,9 +54,9 @@
<% row_class += " last-row" if index == errors.size - 1 %>
<% body.with_row(html_attributes: { class: row_class }) do |row| %>
<% row.with_cell(text: error.cell) %>
<% row.with_cell(text: question_for_field(error.field)) %>
<% row.with_cell(text: question_for_field(error.field), html_attributes: { class: "govuk-!-width-one-half" }) %>
<% if index == 0 %>
<% row.with_cell(text: error_message, rowspan: errors.size, html_attributes: { class: "govuk-!-font-weight-bold grouped-multirow-cell" }) %>
<% row.with_cell(text: error_message.html_safe, rowspan: errors.size, html_attributes: { class: "govuk-!-font-weight-bold govuk-!-width-one-half grouped-multirow-cell" }) %>
<% end %>
<% row.with_cell(text: error.field.humanize) %>
<% end %>

2
app/components/bulk_upload_error_summary_table_component.html.erb

@ -12,7 +12,7 @@
<%= table.with_body do |body| %>
<% body.with_row do |row| %>
<% row.with_cell(text: error[0][2]) %>
<% row.with_cell(text: error[0][2].html_safe) %>
<% row.with_cell(text: pluralize(error[1], "error"), numeric: true) %>
<% end %>
<% end %>

74
app/controllers/form_controller.rb

@ -26,8 +26,6 @@ class FormController < ApplicationController
flash[:notice] = "You have successfully updated #{updated_question_string}"
end
update_duplication_tracking
pages_requiring_update = pages_requiring_update(shown_page_ids_with_unanswered_questions_before_update)
redirect_to(successful_redirect_path(pages_requiring_update))
else
@ -194,43 +192,24 @@ private
params[@log.model_name.param_key]["interruption_page_referrer_type"].presence
end
def update_duplication_tracking
class_name = @log.class.name.underscore
dynamic_duplicates = current_user.send(class_name.pluralize).duplicate_logs(@log)
if dynamic_duplicates.any?
saved_duplicates = @log.duplicates
if saved_duplicates.none? || duplicates_changed?(dynamic_duplicates, saved_duplicates)
duplicate_set_id = dynamic_duplicates.first.duplicate_set_id || new_duplicate_set_id(@log)
update_logs_with_duplicate_set_id(@log, dynamic_duplicates, duplicate_set_id)
saved_duplicates.first.update!(duplicate_set_id: nil) if saved_duplicates.count == 1
end
else
remove_fixed_duplicate_set_ids(@log)
end
end
def successful_redirect_path(pages_to_check)
class_name = @log.class.name.underscore
if is_referrer_type?("duplicate_logs") || is_referrer_type?("duplicate_logs_banner")
original_log = current_user.send(class_name.pluralize).find_by(id: from_referrer_query("original_log_id"))
if FeatureToggle.deduplication_flow_enabled?
if is_referrer_type?("duplicate_logs") || is_referrer_type?("duplicate_logs_banner")
return correcting_duplicate_logs_redirect_path
end
if original_log.present? && current_user.send(class_name.pluralize).duplicate_logs(original_log).any?
if @log.duplicates.none?
flash[:notice] = deduplication_success_banner
dynamic_duplicates = @log.lettings? ? current_user.lettings_logs.duplicate_logs(@log) : current_user.sales_logs.duplicate_logs(@log)
if dynamic_duplicates.any?
saved_duplicates = @log.duplicates
if saved_duplicates.none? || duplicates_changed?(dynamic_duplicates, saved_duplicates)
duplicate_set_id = dynamic_duplicates.first.duplicate_set_id || new_duplicate_set_id(@log)
update_logs_with_duplicate_set_id(@log, dynamic_duplicates, duplicate_set_id)
saved_duplicates.first.update!(duplicate_set_id: nil) if saved_duplicates.count == 1
end
return send("#{class_name}_duplicate_logs_path", original_log, original_log_id: original_log.id, referrer: params[:referrer], organisation_id: params[:organisation_id])
else
flash[:notice] = deduplication_success_banner
return send("#{class_name}_duplicate_logs_path", "#{class_name}_id".to_sym => from_referrer_query("first_remaining_duplicate_id"), original_log_id: from_referrer_query("original_log_id"), referrer: params[:referrer], organisation_id: params[:organisation_id])
return send("#{@log.class.name.underscore}_duplicate_logs_path", @log, original_log_id: @log.id)
end
end
if @log.duplicates.any?
return send("#{@log.class.name.underscore}_duplicate_logs_path", @log, original_log_id: @log.id)
end
if is_referrer_type?("check_answers")
next_page_id = form.next_page_id(@page, @log, current_user)
next_page = form.get_page(next_page_id)
@ -336,6 +315,35 @@ private
CONFIRMATION_PAGE_IDS = %w[uprn_confirmation uprn_selection].freeze
def correcting_duplicate_logs_redirect_path
class_name = @log.class.name.underscore
original_log = current_user.send(class_name.pluralize).find_by(id: from_referrer_query("original_log_id"))
dynamic_duplicates = current_user.send(class_name.pluralize).duplicate_logs(@log)
if dynamic_duplicates.any?
saved_duplicates = @log.duplicates
if duplicates_changed?(dynamic_duplicates, saved_duplicates)
duplicate_set_id = dynamic_duplicates.first.duplicate_set_id || new_duplicate_set_id(@log)
update_logs_with_duplicate_set_id(@log, dynamic_duplicates, duplicate_set_id)
saved_duplicates.first.update!(duplicate_set_id: nil) if saved_duplicates.count == 1
end
else
remove_fixed_duplicate_set_ids(@log)
end
if original_log.present? && current_user.send(class_name.pluralize).duplicate_logs(original_log).any?
if dynamic_duplicates.none?
flash[:notice] = deduplication_success_banner
end
send("#{class_name}_duplicate_logs_path", original_log, original_log_id: original_log.id, referrer: params[:referrer], organisation_id: params[:organisation_id])
else
remove_fixed_duplicate_set_ids(original_log)
flash[:notice] = deduplication_success_banner
send("#{class_name}_duplicate_logs_path", "#{class_name}_id".to_sym => from_referrer_query("first_remaining_duplicate_id"), original_log_id: from_referrer_query("original_log_id"), referrer: params[:referrer], organisation_id: params[:organisation_id])
end
end
def deduplication_success_banner
deduplicated_log_link = "<a class=\"govuk-notification-banner__link govuk-!-font-weight-bold\" href=\"#{send("#{@log.class.name.underscore}_path", @log)}\">Log #{@log.id}</a>"
changed_labels = {

15
app/controllers/organisations_controller.rb

@ -4,8 +4,8 @@ class OrganisationsController < ApplicationController
include DuplicateLogsHelper
before_action :authenticate_user!
before_action :find_resource, except: %i[index new create]
before_action :authenticate_scope!, except: [:index]
before_action :find_resource, except: %i[index new create search]
before_action :authenticate_scope!, except: %i[index search]
before_action :session_filters, if: -> { current_user.support? || current_user.organisation.has_managing_agents? }, only: %i[lettings_logs sales_logs email_lettings_csv download_lettings_csv email_sales_csv download_sales_csv]
before_action :session_filters, only: %i[users schemes email_schemes_csv download_schemes_csv]
before_action -> { filter_manager.serialize_filters_to_session }, if: -> { current_user.support? || current_user.organisation.has_managing_agents? }, only: %i[lettings_logs sales_logs email_lettings_csv download_lettings_csv email_sales_csv download_sales_csv]
@ -284,6 +284,17 @@ class OrganisationsController < ApplicationController
render "schemes/changes"
end
def search
org_options = current_user.support? ? Organisation.all : Organisation.affiliated_organisations(current_user.organisation)
organisations = org_options.search_by(params["query"]).limit(20)
org_data = organisations.each_with_object({}) do |org, hash|
hash[org.id] = { value: org.name }
end
render json: org_data.to_json
end
private
def filter_type

11
app/controllers/users_controller.rb

@ -32,6 +32,17 @@ class UsersController < ApplicationController
end
end
def search
user_options = current_user.support? ? User.all : User.own_and_managing_org_users(current_user.organisation)
users = user_options.search_by(params["query"]).limit(20)
user_data = users.each_with_object({}) do |user, hash|
hash[user.id] = { value: user.name, hint: user.email }
end
render json: user_data.to_json
end
def resend_invite
@user.send_confirmation_instructions
flash[:notice] = "Invitation sent to #{@user.email}"

3
app/frontend/controllers/index.js

@ -13,6 +13,8 @@ import GovukfrontendController from './govukfrontend_controller.js'
import NumericQuestionController from './numeric_question_controller.js'
import SearchController from './search_controller.js'
import FilterLayoutController from './filter_layout_controller.js'
import TabsController from './tabs_controller.js'
@ -23,4 +25,5 @@ application.register('conditional-question', ConditionalQuestionController)
application.register('govukfrontend', GovukfrontendController)
application.register('numeric-question', NumericQuestionController)
application.register('filter-layout', FilterLayoutController)
application.register('search', SearchController)
application.register('tabs', TabsController)

46
app/frontend/controllers/search_controller.js

@ -0,0 +1,46 @@
import { Controller } from '@hotwired/stimulus'
import accessibleAutocomplete from 'accessible-autocomplete'
import 'accessible-autocomplete/dist/accessible-autocomplete.min.css'
import { searchSuggestion, fetchAndPopulateSearchResults, confirmSelectedOption, searchableName } from '../modules/search'
const options = []
const populateOptions = (results, selectEl) => {
selectEl.innerHTML = ''
Object.keys(results).forEach((key) => {
const option = document.createElement('option')
option.value = key
option.innerHTML = results[key].value
if (results[key].hint) { option.setAttribute('data-hint', results[key].hint) }
option.setAttribute('text', searchableName(results[key]))
selectEl.appendChild(option)
options.push(option)
})
}
export default class extends Controller {
connect () {
const selectEl = this.element
const matches = /^(\w+)\[(\w+)\]$/.exec(selectEl.name)
const rawFieldName = matches ? `${matches[1]}[${matches[2]}_raw]` : ''
const searchUrl = JSON.parse(this.element.dataset.info).search_url
document.querySelectorAll('.non-js-text-search-input-field').forEach((el) => {
el.style.display = 'none'
})
accessibleAutocomplete.enhanceSelectElement({
defaultValue: '',
selectElement: selectEl,
minLength: 1,
source: (query, populateResults) => {
fetchAndPopulateSearchResults(query, populateResults, searchUrl, populateOptions, selectEl)
},
autoselect: true,
placeholder: 'Start typing to search',
templates: { suggestion: (value) => searchSuggestion(value, options) },
name: rawFieldName,
onConfirm: (val) => confirmSelectedOption(selectEl, val)
})
}
}

49
app/frontend/modules/search.js

@ -117,6 +117,22 @@ export const suggestion = (value, options) => {
}
}
export const searchSuggestion = (value, options) => {
try {
const option = options.find((o) => o.getAttribute('text') === value)
if (option) {
const result = enhanceOption(option)
const html = result.append ? `<span class="autocomplete__option__append">${result.text}</span> <span>${result.append}</span>` : `<span>${result.text}</span>`
return result.hint ? `${html}<div class="autocomplete__option__hint">${result.hint}</div>` : html
} else {
return '<span>No results found</span>'
}
} catch (error) {
console.error('Error fetching user option:', error)
return value
}
}
export const enhanceOption = (option) => {
return {
text: option.text,
@ -128,6 +144,39 @@ export const enhanceOption = (option) => {
}
}
export const fetchAndPopulateSearchResults = async (query, populateResults, relativeUrlRoute, populateOptions, selectEl) => {
if (/\S/.test(query)) {
const results = await fetchUserOptions(query, relativeUrlRoute)
populateOptions(results, selectEl)
populateResults(Object.values(results).map((o) => searchableName(o)))
}
}
export const fetchUserOptions = async (query, searchUrl) => {
try {
const response = await fetch(`${searchUrl}?query=${encodeURIComponent(query)}`)
const results = await response.json()
return results
} catch (error) {
console.error('Error fetching user options:', error)
return []
}
}
export const getSearchableName = (option) => {
return option.getAttribute('data-hint') ? option.text + ' ' + option.getAttribute('data-hint') : option.text
}
export const searchableName = (option) => {
return option.hint ? option.value + ' ' + option.hint : option.value
}
export const confirmSelectedOption = (selectEl, val) => {
const arrayOfOptions = Array.from(selectEl.options).filter(function (option, index, arr) { return option.value !== '' })
const selectedOption = [].filter.call(
arrayOfOptions,
(option) => option.getAttribute('text') === val
)[0]
if (selectedOption) selectedOption.selected = true
}

10
app/frontend/styles/_button.scss

@ -1,8 +1,14 @@
$app-button-shadow-size: $govuk-border-width-form-element;
$app-button-inverse-background-colour: govuk-colour("white");
$app-button-inverse-foreground-colour: $govuk-brand-colour;
$app-button-inverse-shadow-colour: govuk-shade($app-button-inverse-foreground-colour, 30%);
$app-button-inverse-hover-background-colour: govuk-tint($app-button-inverse-foreground-colour, 90%);
$app-button-inverse-shadow-colour: govuk-shade(
$app-button-inverse-foreground-colour,
30%
);
$app-button-inverse-hover-background-colour: govuk-tint(
$app-button-inverse-foreground-colour,
90%
);
.app-button--inverse,
.app-button--inverse:link,

2
app/frontend/styles/_feedback.scss

@ -88,7 +88,7 @@
&:hover {
// backup style for browsers that don't support rgba
background: govuk-colour("mid-grey");
background: rgba(govuk-colour("black"), .2);
background: rgba(govuk-colour("black"), 0.2);
color: govuk-colour("black");
}

4
app/frontend/styles/_filter-layout.scss

@ -34,7 +34,9 @@
.app-filter-layout__content {
@include govuk-media-query(wide) {
float: right;
max-width: calc(#{govuk-grid-width("three-quarters")} - #{govuk-spacing(6)});
max-width: calc(
#{govuk-grid-width("three-quarters")} - #{govuk-spacing(6)}
);
width: 100%;
}
}

8
app/frontend/styles/_filter.scss

@ -5,7 +5,7 @@
.govuk-checkboxes__label,
.govuk-radios__label {
&:before {
&::before {
background-color: govuk-colour("white");
}
}
@ -47,7 +47,9 @@
&:focus {
background-color: $govuk-focus-colour;
color: $govuk-focus-text-colour;
box-shadow: 0 -2px $govuk-focus-colour, 0 4px $govuk-focus-text-colour;
box-shadow:
0 -2px $govuk-focus-colour,
0 4px $govuk-focus-text-colour;
outline: none;
}
@ -57,7 +59,7 @@
border: 0;
}
&:before {
&::before {
background-image: url("../assets/images/icon-cross.svg");
content: "";
display: inline-block;

4
app/frontend/styles/_panel.scss

@ -19,8 +19,8 @@
margin-bottom: 0;
}
.govuk-radios__label:before,
& :after {
.govuk-radios__label::before,
& ::after {
color: govuk-colour("black");
border-color: govuk-colour("black");
background-color: govuk-colour("white");

2
app/frontend/styles/_primary-navigation.scss

@ -54,7 +54,7 @@
@include govuk-typography-weight-bold;
// Extend the touch area of the link to the list
&:after {
&::after {
bottom: 0;
content: "";
left: 0;

2
app/frontend/styles/_sub-navigation.scss

@ -64,7 +64,7 @@
position: relative;
// Extend the touch area of the link to the list
&:after {
&::after {
bottom: 0;
content: "";
left: 0;

2
app/frontend/styles/_table-group.scss

@ -2,7 +2,7 @@
overflow-x: auto;
overflow-y: hidden;
margin: govuk-spacing(-3) govuk-spacing(-3) govuk-spacing(3);
padding: govuk-spacing(3) govuk-spacing(3);
padding: govuk-spacing(3);
scrollbar-color: $govuk-text-colour govuk-colour("light-grey");
.govuk-table {

2
app/frontend/styles/_task-list.scss

@ -10,7 +10,7 @@
}
.app-task-list__section-heading {
@include govuk-font($size:24, $weight: bold);
@include govuk-font($size: 24, $weight: bold);
display: table;
margin-top: govuk-spacing(0);
margin-bottom: govuk-spacing(4);

4
app/frontend/styles/application.scss

@ -13,8 +13,8 @@ $govuk-new-link-styles: true;
// Add additional breakpoint named `wide`
$govuk-breakpoints: (
mobile: 320px,
tablet: 641px,
mobile: 320px,
tablet: 641px,
desktop: 769px,
wide: 921px,
);

82
app/helpers/filters_helper.rb

@ -11,8 +11,8 @@ module FiltersHelper
return true if !selected_filters.key?("owning_organisation") && filter == "owning_organisation_select" && value == :all
return true if !selected_filters.key?("managing_organisation") && filter == "managing_organisation_select" && value == :all
return true if selected_filters["owning_organisation"].present? && filter == "owning_organisation_select" && value == :specific_org
return true if selected_filters["managing_organisation"].present? && filter == "managing_organisation_select" && value == :specific_org
return true if (selected_filters["owning_organisation"].present? || selected_filters["owning_organisation_text_search"].present?) && filter == "owning_organisation_select" && value == :specific_org
return true if (selected_filters["managing_organisation"].present? || selected_filters["managing_organisation_text_search"].present?) && filter == "managing_organisation_select" && value == :specific_org
return false if selected_filters[filter].blank?
@ -84,16 +84,54 @@ module FiltersHelper
JSON.parse(session[session_name_for(filter_type)])[filter] || ""
end
def owning_organisation_filter_options(user)
def all_owning_organisation_filter_options(user)
organisation_options = user.support? ? Organisation.all : ([user.organisation] + user.organisation.stock_owners + user.organisation.absorbed_organisations).uniq
[OpenStruct.new(id: "", name: "Select an option")] + organisation_options.map { |org| OpenStruct.new(id: org.id, name: org.name) }
end
def assigned_to_filter_options(user)
def owning_organisation_filter_options(user, filter_type)
if applied_filters(filter_type)["owning_organisation"].present?
organisation_id = applied_filters(filter_type)["owning_organisation"]
org = if user.support?
Organisation.where(id: organisation_id)&.first
else
Organisation.affiliated_organisations(user.organisation).where(id: organisation_id)&.first
end
return [OpenStruct.new(id: org.id, name: org.name)] if org.present?
end
[OpenStruct.new(id: "", name: "Select an option")]
end
def assigned_to_csv_filter_options(user)
user_options = user.support? ? User.all : (user.organisation.users + user.organisation.managing_agents.flat_map(&:users) + user.organisation.stock_owners.flat_map(&:users)).uniq
[OpenStruct.new(id: "", name: "Select an option", hint: "")] + user_options.map { |user_option| OpenStruct.new(id: user_option.id, name: user_option.name, hint: user_option.email) }
end
def assigned_to_filter_options(filter_type)
if applied_filters(filter_type)["assigned_to"] == "specific_user" && applied_filters(filter_type)["user"].present?
user_id = applied_filters(filter_type)["user"]
selected_user = if current_user.support?
User.where(id: user_id)&.first
else
User.own_and_managing_org_users(current_user.organisation).where(id: user_id)&.first
end
return [OpenStruct.new(id: selected_user.id, name: selected_user.name, hint: selected_user.email)] if selected_user.present?
end
[OpenStruct.new(id: "", name: "Select an option", hint: "")]
end
def filter_search_url(category)
case category
when :user
search_users_path
when :owning_organisation, :managing_organisation
search_organisations_path
end
end
def collection_year_options
years = {
current_collection_start_year.to_s => year_combo(current_collection_start_year),
@ -125,11 +163,26 @@ module FiltersHelper
end
end
def managing_organisation_filter_options(user)
def managing_organisation_csv_filter_options(user)
organisation_options = user.support? ? Organisation.all : ([user.organisation] + user.organisation.managing_agents + user.organisation.absorbed_organisations).uniq
[OpenStruct.new(id: "", name: "Select an option")] + organisation_options.map { |org| OpenStruct.new(id: org.id, name: org.name) }
end
def managing_organisation_filter_options(user, filter_type)
if applied_filters(filter_type)["managing_organisation"].present?
organisation_id = applied_filters(filter_type)["managing_organisation"]
org = if user.support?
Organisation.where(id: organisation_id)&.first
else
Organisation.affiliated_organisations(user.organisation).where(id: organisation_id)&.first
end
return [OpenStruct.new(id: org.id, name: org.name)] if org.present?
end
[OpenStruct.new(id: "", name: "Select an option")]
end
def show_scheme_managing_org_filter?(user)
org = user.organisation
@ -176,8 +229,8 @@ module FiltersHelper
{ id: "status", label: "Status", value: formatted_status_filter(session_filters) },
filter_type == "lettings_logs" ? { id: "needstype", label: "Needs type", value: formatted_needstype_filter(session_filters) } : nil,
{ id: "assigned_to", label: "Assigned to", value: formatted_assigned_to_filter(session_filters) },
{ id: "owned_by", label: "Owned by", value: formatted_owned_by_filter(session_filters) },
{ id: "managed_by", label: "Managed by", value: formatted_managed_by_filter(session_filters) },
{ id: "owned_by", label: "Owned by", value: formatted_owned_by_filter(session_filters, filter_type) },
{ id: "managed_by", label: "Managed by", value: formatted_managed_by_filter(session_filters, filter_type) },
].compact
end
@ -221,7 +274,7 @@ private
filters.each.sum do |category, category_filters|
if %w[years status needstypes bulk_upload_id].include?(category)
category_filters.count(&:present?)
elsif %w[user owning_organisation managing_organisation].include?(category)
elsif %w[user owning_organisation managing_organisation user_text_search owning_organisation_text_search managing_organisation_text_search].include?(category)
1
else
0
@ -256,26 +309,27 @@ private
return "All" if session_filters["assigned_to"].include?("all")
return "You" if session_filters["assigned_to"].include?("you")
selected_user_option = assigned_to_filter_options(current_user).find { |x| x.id == session_filters["user"].to_i }
User.own_and_managing_org_users(current_user.organisation).find(session_filters["user"].to_i).name
selected_user_option = User.own_and_managing_org_users(current_user.organisation).find(session_filters["user"].to_i)
return unless selected_user_option
"#{selected_user_option.name} (#{selected_user_option.hint})"
"#{selected_user_option.name} (#{selected_user_option.email})"
end
def formatted_owned_by_filter(session_filters)
def formatted_owned_by_filter(session_filters, filter_type)
return "All" if params["id"].blank? && (session_filters["owning_organisation"].blank? || session_filters["owning_organisation"]&.include?("all"))
session_org_id = session_filters["owning_organisation"] || params["id"]
selected_owning_organisation_option = owning_organisation_filter_options(current_user).find { |org| org.id == session_org_id.to_i }
selected_owning_organisation_option = owning_organisation_filter_options(current_user, filter_type).find { |org| org.id == session_org_id.to_i }
return unless selected_owning_organisation_option
selected_owning_organisation_option&.name
end
def formatted_managed_by_filter(session_filters)
def formatted_managed_by_filter(session_filters, filter_type)
return "All" if session_filters["managing_organisation"].blank? || session_filters["managing_organisation"].include?("all")
selected_managing_organisation_option = managing_organisation_filter_options(current_user).find { |org| org.id == session_filters["managing_organisation"].to_i }
selected_managing_organisation_option = managing_organisation_filter_options(current_user, filter_type).find { |org| org.id == session_filters["managing_organisation"].to_i }
return unless selected_managing_organisation_option
selected_managing_organisation_option&.name

11
app/helpers/guidance_helper.rb

@ -0,0 +1,11 @@
module GuidanceHelper
include GovukLinkHelper
include GovukVisuallyHiddenHelper
def question_link(question_id, log, user)
question = log.form.get_question(question_id, log)
return "" unless question.page.routed_to?(log, user)
"(#{govuk_link_to "Q#{question.question_number}", send("#{log.class.name.underscore}_#{question.page.id}_path", log)})".html_safe
end
end

2
app/models/form/lettings/pages/uprn_selection.rb

@ -17,7 +17,7 @@ class Form::Lettings::Pages::UprnSelection < ::Form::Page
end
def routed_to?(log, _current_user = nil)
(log.uprn_known.nil? || log.uprn_known.zero?) && log.address_line1_input.present? && log.postcode_full_input.present? && (1..10).cover?(log.address_options&.count)
!log.is_supported_housing? && (log.uprn_known.nil? || log.uprn_known.zero?) && log.address_line1_input.present? && log.postcode_full_input.present? && (1..10).cover?(log.address_options&.count)
end
def skip_text

26
app/models/form/sales/pages/about_deposit_without_discount.rb

@ -1,26 +0,0 @@
class Form::Sales::Pages::AboutDepositWithoutDiscount < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:, optional:)
super(id, hsh, subsection)
@header = "About the deposit"
@ownershipsch = ownershipsch
@optional = optional
end
def questions
@questions ||= [
Form::Sales::Questions::DepositAmount.new(nil, nil, self, ownershipsch: @ownershipsch, optional: @optional),
]
end
def depends_on
if form.start_year_after_2024?
[{ "social_homebuy?" => false, "ownershipsch" => 1, "stairowned_100?" => @optional },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }]
else
[{ "social_homebuy?" => false, "ownershipsch" => 1 },
{ "ownershipsch" => 2 },
{ "ownershipsch" => 3, "mortgageused" => 1 }]
end
end
end

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

@ -0,0 +1,22 @@
class Form::Sales::Pages::Deposit < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:, optional:)
super(id, hsh, subsection)
@ownershipsch = ownershipsch
@optional = optional
@header = "About the deposit"
end
def questions
@questions ||= [
Form::Sales::Questions::DepositAmount.new(nil, nil, self, ownershipsch: @ownershipsch, optional: @optional),
]
end
def routed_to?(log, _user)
return false unless super
return true if log.ownershipsch == 2 || (log.ownershipsch == 3 && log.mortgageused == 1)
return false if log.stairowned_100? != @optional && form.start_year_after_2024?
log.ownershipsch == 1
end
end

5
app/models/form/sales/pages/about_deposit_with_discount.rb → app/models/form/sales/pages/deposit_discount.rb

@ -1,13 +1,12 @@
class Form::Sales::Pages::AboutDepositWithDiscount < ::Form::Page
class Form::Sales::Pages::DepositDiscount < ::Form::Page
def initialize(id, hsh, subsection, optional:)
super(id, hsh, subsection)
@header = "About the deposit"
@optional = optional
@header = "About the deposit"
end
def questions
@questions ||= [
Form::Sales::Questions::DepositAmount.new(nil, nil, self, ownershipsch: 1, optional: @optional),
Form::Sales::Questions::DepositDiscount.new(nil, nil, self),
]
end

5
app/models/form/sales/pages/about_price_rtb.rb → app/models/form/sales/pages/discount.rb

@ -1,7 +1,7 @@
class Form::Sales::Pages::AboutPriceRtb < ::Form::Page
class Form::Sales::Pages::Discount < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "about_price_rtb"
@id = "discount"
@header = "About the price of the property"
@depends_on = [{
"right_to_buy?" => true,
@ -10,7 +10,6 @@ class Form::Sales::Pages::AboutPriceRtb < ::Form::Page
def questions
@questions ||= [
Form::Sales::Questions::PurchasePrice.new(nil, nil, self, ownershipsch: 2),
Form::Sales::Questions::Discount.new(nil, nil, self),
]
end

5
app/models/form/sales/pages/about_price_shared_ownership.rb → app/models/form/sales/pages/equity.rb

@ -1,13 +1,12 @@
class Form::Sales::Pages::AboutPriceSharedOwnership < ::Form::Page
class Form::Sales::Pages::Equity < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "about_price_shared_ownership"
@id = "equity"
@header = "About the price of the property"
end
def questions
@questions ||= [
Form::Sales::Questions::Value.new(nil, nil, self),
Form::Sales::Questions::Equity.new(nil, nil, self),
]
end

5
app/models/form/sales/pages/about_price_not_rtb.rb → app/models/form/sales/pages/grant.rb

@ -1,7 +1,7 @@
class Form::Sales::Pages::AboutPriceNotRtb < ::Form::Page
class Form::Sales::Pages::Grant < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "about_price_not_rtb"
@id = "grant"
@header = "About the price of the property"
@depends_on = [{
"right_to_buy?" => false,
@ -11,7 +11,6 @@ class Form::Sales::Pages::AboutPriceNotRtb < ::Form::Page
def questions
@questions ||= [
Form::Sales::Questions::PurchasePrice.new(nil, nil, self, ownershipsch: 2),
Form::Sales::Questions::Grant.new(nil, nil, self),
]
end

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

@ -1,6 +1,7 @@
class Form::Sales::Pages::Mortgageused < ::Form::Page
def initialize(id, hsh, subsection, ownershipsch:)
super(id, hsh, subsection)
@header = "Mortgage Amount"
@ownershipsch = ownershipsch
end

18
app/models/form/sales/pages/purchase_price.rb

@ -0,0 +1,18 @@
class Form::Sales::Pages::PurchasePrice < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "purchase_price"
@header = "About the price of the property"
@depends_on = [{ "right_to_buy?" => true },
{
"right_to_buy?" => false,
"rent_to_buy_full_ownership?" => false,
}]
end
def questions
@questions ||= [
Form::Sales::Questions::PurchasePrice.new(nil, nil, self, ownershipsch: 2),
]
end
end

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

@ -4,6 +4,8 @@ class Form::Sales::Pages::PurchasePriceOutrightOwnership < ::Form::Page
@depends_on = [
{ "outright_sale_or_discounted_with_full_ownership?" => true },
]
@header = "About the price of the property"
@top_guidance_partial = "financial_calculations_outright_sale"
@ownershipsch = ownershipsch
end

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

@ -0,0 +1,13 @@
class Form::Sales::Pages::ValueSharedOwnership < ::Form::Page
def initialize(id, hsh, subsection)
super
@id = "value_shared_ownership"
@header = "About the price of the property"
end
def questions
@questions ||= [
Form::Sales::Questions::Value.new(nil, nil, self),
]
end
end

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

@ -13,6 +13,7 @@ class Form::Sales::Questions::DepositAmount < ::Form::Question
@ownershipsch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@optional = optional
@top_guidance_partial = top_guidance_partial
end
def derived?(log)
@ -31,4 +32,10 @@ class Form::Sales::Questions::DepositAmount < ::Form::Question
"Enter the total cash sum paid by the buyer towards the property that was not funded by the mortgage. This excludes any grant or loan"
end
end
def top_guidance_partial
return "financial_calculations_shared_ownership" if @ownershipsch == 1
return "financial_calculations_discounted_ownership" if @ownershipsch == 2
return "financial_calculations_outright_sale" if @ownershipsch == 3
end
end

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

@ -12,6 +12,7 @@ class Form::Sales::Questions::DepositDiscount < ::Form::Question
@prefix = "£"
@hint_text = "Enter the total cash discount given on the property being purchased through the Social HomeBuy scheme"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 96, 2024 => 97 }.freeze

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

@ -14,6 +14,7 @@ class Form::Sales::Questions::Discount < ::Form::Question
If discount capped, enter capped %</br></br>
If the property is being sold to an existing tenant under the RTB, PRTB, or VRTB schemes, enter the % discount from the full market value that is being given."
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_discounted_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 102, 2024 => 103 }.freeze

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

@ -12,6 +12,7 @@ class Form::Sales::Questions::Equity < ::Form::Question
@suffix = "%"
@hint_text = "Enter the amount of initial equity held by the purchaser (for example, 25% or 50%)"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 89, 2024 => 90 }.freeze

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

@ -12,6 +12,7 @@ class Form::Sales::Questions::Grant < ::Form::Question
@prefix = "£"
@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_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_discounted_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 101, 2024 => 102 }.freeze

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

@ -12,6 +12,7 @@ class Form::Sales::Questions::MortgageAmount < ::Form::Question
@hint_text = "Enter the amount of mortgage agreed with the mortgage lender. Exclude any deposits or cash payments. Numeric in pounds. Rounded to the nearest pound."
@ownershipsch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@top_guidance_partial = top_guidance_partial
end
QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP = {
@ -22,4 +23,10 @@ class Form::Sales::Questions::MortgageAmount < ::Form::Question
def derived?(log)
log&.mortgage_not_used?
end
def top_guidance_partial
return "financial_calculations_shared_ownership" if @ownershipsch == 1
return "financial_calculations_discounted_ownership" if @ownershipsch == 2
return "financial_calculations_outright_sale" if @ownershipsch == 3
end
end

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

@ -8,6 +8,7 @@ class Form::Sales::Questions::Mortgageused < ::Form::Question
@answer_options = ANSWER_OPTIONS
@ownershipsch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@top_guidance_partial = top_guidance_partial
end
def displayed_answer_options(log, _user = nil)
@ -34,4 +35,10 @@ class Form::Sales::Questions::Mortgageused < ::Form::Question
2023 => { 1 => 90, 2 => 103, 3 => 111 },
2024 => { 1 => 91, 2 => 104, 3 => 112 },
}.freeze
def top_guidance_partial
return "financial_calculations_shared_ownership" if @ownershipsch == 1
return "financial_calculations_discounted_ownership" if @ownershipsch == 2
return "financial_calculations_outright_sale" if @ownershipsch == 3
end
end

6
app/models/form/sales/questions/purchase_price.rb

@ -12,6 +12,7 @@ class Form::Sales::Questions::PurchasePrice < ::Form::Question
@hint_text = hint_text
@ownership_sch = ownershipsch
@question_number = QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.fetch(form.start_date.year, QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP.max_by { |k, _v| k }.last)[ownershipsch]
@top_guidance_partial = top_guidance_partial
end
QUESTION_NUMBER_FROM_YEAR_AND_OWNERSHIP = {
@ -24,4 +25,9 @@ class Form::Sales::Questions::PurchasePrice < ::Form::Question
"For all schemes, including Right to Acquire (RTA), Right to Buy (RTB), Voluntary Right to Buy (VRTB) or Preserved Right to Buy (PRTB) sales, enter the full price of the property without any discount"
end
def top_guidance_partial
return "financial_calculations_discounted_ownership" if @ownership_sch == 2
return "financial_calculations_outright_sale" if @ownership_sch == 3
end
end

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

@ -11,6 +11,7 @@ class Form::Sales::Questions::StaircaseBought < ::Form::Question
@step = 1
@suffix = "%"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 77, 2024 => 79 }.freeze

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

@ -11,6 +11,7 @@ class Form::Sales::Questions::Value < ::Form::Question
@prefix = "£"
@hint_text = "Enter the full purchase price of the property before any discounts are applied. For shared ownership, enter the full purchase price paid for 100% equity (this is equal to the value of the share owned by the PRP plus the value bought by the purchaser)"
@question_number = QUESTION_NUMBER_FROM_YEAR[form.start_date.year] || QUESTION_NUMBER_FROM_YEAR[QUESTION_NUMBER_FROM_YEAR.keys.max]
@top_guidance_partial = "financial_calculations_shared_ownership"
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 88, 2024 => 89 }.freeze

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

@ -10,10 +10,11 @@ class Form::Sales::Subsections::DiscountedOwnershipScheme < ::Form::Subsection
@pages ||= [
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_discounted_ownership_joint_purchase", nil, self, ownershipsch: 2, joint_purchase: true),
Form::Sales::Pages::LivingBeforePurchase.new("living_before_purchase_discounted_ownership", nil, self, ownershipsch: 2, joint_purchase: false),
Form::Sales::Pages::AboutPriceRtb.new(nil, nil, self),
Form::Sales::Pages::PurchasePrice.new(nil, nil, self),
Form::Sales::Pages::Discount.new(nil, nil, self),
Form::Sales::Pages::ExtraBorrowingValueCheck.new("extra_borrowing_price_value_check", nil, self),
Form::Sales::Pages::PercentageDiscountValueCheck.new("percentage_discount_value_check", nil, self),
Form::Sales::Pages::AboutPriceNotRtb.new(nil, nil, self),
Form::Sales::Pages::Grant.new(nil, nil, self),
Form::Sales::Pages::GrantValueCheck.new(nil, nil, self),
Form::Sales::Pages::PurchasePriceOutrightOwnership.new("purchase_price_discounted_ownership", nil, self, ownershipsch: 2),
Form::Sales::Pages::DiscountedSaleValueCheck.new("discounted_sale_grant_value_check", nil, self),
@ -32,7 +33,7 @@ class Form::Sales::Subsections::DiscountedOwnershipScheme < ::Form::Subsection
Form::Sales::Pages::MortgageLength.new("mortgage_length_discounted_ownership", nil, self, ownershipsch: 2),
Form::Sales::Pages::ExtraBorrowing.new("extra_borrowing_discounted_ownership", nil, self, ownershipsch: 2),
Form::Sales::Pages::ExtraBorrowingValueCheck.new("extra_borrowing_value_check", nil, self),
Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_discounted_ownership", nil, self, ownershipsch: 2, optional: false),
Form::Sales::Pages::Deposit.new("deposit_discounted_ownership", nil, self, ownershipsch: 2, optional: false),
Form::Sales::Pages::ExtraBorrowingValueCheck.new("extra_borrowing_deposit_value_check", nil, self),
Form::Sales::Pages::DepositValueCheck.new("discounted_ownership_deposit_joint_purchase_value_check", nil, self, joint_purchase: true),
Form::Sales::Pages::DepositValueCheck.new("discounted_ownership_deposit_value_check", nil, self, joint_purchase: false),

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

@ -18,7 +18,7 @@ class Form::Sales::Subsections::OutrightSale < ::Form::Subsection
(Form::Sales::Pages::MortgageLenderOther.new("mortgage_lender_other_outright_sale", nil, self, ownershipsch: 3) unless form.start_year_after_2024?),
Form::Sales::Pages::MortgageLength.new("mortgage_length_outright_sale", nil, self, ownershipsch: 3),
Form::Sales::Pages::ExtraBorrowing.new("extra_borrowing_outright_sale", nil, self, ownershipsch: 3),
Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_outright_sale", nil, self, ownershipsch: 3, optional: false),
Form::Sales::Pages::Deposit.new("deposit_outright_sale", nil, self, ownershipsch: 3, optional: false),
Form::Sales::Pages::DepositValueCheck.new("outright_sale_deposit_joint_purchase_value_check", nil, self, joint_purchase: true),
Form::Sales::Pages::DepositValueCheck.new("outright_sale_deposit_value_check", nil, self, joint_purchase: false),
leasehold_charge_pages,

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

@ -26,8 +26,9 @@ class Form::Sales::Subsections::SharedOwnershipScheme < ::Form::Subsection
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::AboutPriceSharedOwnership.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),
@ -38,12 +39,12 @@ class Form::Sales::Subsections::SharedOwnershipScheme < ::Form::Subsection
Form::Sales::Pages::MortgageLenderOther.new("mortgage_lender_other_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::MortgageLength.new("mortgage_length_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::ExtraBorrowing.new("extra_borrowing_shared_ownership", nil, self, ownershipsch: 1),
Form::Sales::Pages::AboutDepositWithDiscount.new("about_deposit_with_discount", nil, self, optional: false),
(Form::Sales::Pages::AboutDepositWithDiscount.new("about_deposit_with_discount_optional", nil, self, optional: true) if form.start_year_after_2024?),
Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_shared_ownership", nil, self, ownershipsch: 1, optional: false),
(Form::Sales::Pages::AboutDepositWithoutDiscount.new("about_deposit_shared_ownership_optional", nil, self, ownershipsch: 1, optional: true) if form.start_year_after_2024?),
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) if form.start_year_after_2024?),
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) if form.start_year_after_2024?),
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),

4
app/models/lettings_log.rb

@ -132,6 +132,10 @@ class LettingsLog < Log
illness_type_10: false)
}
scope :filter_by_user_text_search, ->(param, user) { where(assigned_to: user.support? ? User.search_by(param) : User.own_and_managing_org_users(user.organisation).search_by(param)) }
scope :filter_by_owning_organisation_text_search, ->(param, _user) { where(owning_organisation: Organisation.search_by(param)) }
scope :filter_by_managing_organisation_text_search, ->(param, _user) { where(managing_organisation: Organisation.search_by(param)) }
AUTOGENERATED_FIELDS = %w[id status created_at updated_at discarded_at].freeze
OPTIONAL_FIELDS = %w[tenancycode propcode chcharge].freeze
RENT_TYPE_MAPPING_LABELS = { 1 => "Social Rent", 2 => "Affordable Rent", 3 => "Intermediate Rent" }.freeze

3
app/models/log.rb

@ -53,6 +53,9 @@ class Log < ApplicationRecord
scope :filter_by_organisation, ->(org, _user = nil) { where(owning_organisation: org).or(where(managing_organisation: org)) }
scope :filter_by_owning_organisation, ->(owning_organisation, _user = nil) { where(owning_organisation:) }
scope :filter_by_managing_organisation, ->(managing_organisation, _user = nil) { where(managing_organisation:) }
scope :filter_by_user_text_search, ->(param, user) { where(assigned_to: user.support? ? User.search_by(param) : User.own_and_managing_org_users(user.organisation).search_by(param)) }
scope :filter_by_owning_organisation_text_search, ->(param, _user) { where(owning_organisation: Organisation.search_by(param)) }
scope :filter_by_managing_organisation_text_search, ->(param, _user) { where(managing_organisation: Organisation.search_by(param)) }
attr_accessor :skip_update_status, :skip_update_uprn_confirmed, :select_best_address_match, :skip_dpo_validation

1
app/models/organisation.rb

@ -18,6 +18,7 @@ class Organisation < ApplicationRecord
belongs_to :absorbing_organisation, class_name: "Organisation", optional: true
has_many :absorbed_organisations, class_name: "Organisation", foreign_key: "absorbing_organisation_id"
scope :visible, -> { where(discarded_at: nil) }
scope :affiliated_organisations, ->(organisation) { where(id: (organisation.child_organisations + [organisation] + organisation.parent_organisations + organisation.absorbed_organisations).map(&:id)) }
def affiliated_stock_owners
ids = []

5
app/models/user.rb

@ -77,6 +77,7 @@ class User < ApplicationRecord
scope :deactivated, -> { where(active: false) }
scope :active_status, -> { where(active: true).where.not(last_sign_in_at: nil) }
scope :visible, -> { where(discarded_at: nil) }
scope :own_and_managing_org_users, ->(organisation) { where(organisation: organisation.child_organisations + [organisation]) }
def lettings_logs
if support?
@ -209,9 +210,9 @@ class User < ApplicationRecord
def logs_filters(specific_org: false)
if (support? && !specific_org) || organisation.has_managing_agents? || organisation.has_stock_owners?
%w[years status needstypes assigned_to user managing_organisation owning_organisation bulk_upload_id]
%w[years status needstypes assigned_to user managing_organisation owning_organisation bulk_upload_id user_text_search owning_organisation_text_search managing_organisation_text_search]
else
%w[years status needstypes assigned_to user bulk_upload_id]
%w[years status needstypes assigned_to user bulk_upload_id user_text_search]
end
end

139
app/models/validations/sales/sale_information_validations.rb

@ -51,8 +51,15 @@ module Validations::Sales::SaleInformationValidations
tolerance = record.discount ? record.value * 0.05 / 100 : 1
if over_tolerance?(record.mortgage_deposit_and_grant_total, record.value_with_discount, tolerance, strict: !record.discount.nil?) && record.discounted_ownership_sale?
deposit_and_grant_sentence = record.grant.present? ? ", cash deposit (#{record.field_formatted_as_currency('deposit')}), and grant (#{record.field_formatted_as_currency('grant')})" : " and cash deposit (#{record.field_formatted_as_currency('deposit')})"
discount_sentence = record.discount.present? ? " (#{record.field_formatted_as_currency('value')}) subtracted by the sum of the full purchase price (#{record.field_formatted_as_currency('value')}) multiplied by the percentage discount (#{record.discount}%)" : ""
%i[mortgageused mortgage value deposit ownershipsch discount grant].each do |field|
record.errors.add field, I18n.t("validations.sale_information.discounted_ownership_value", mortgage_deposit_and_grant_total: record.field_formatted_as_currency("mortgage_deposit_and_grant_total"), value_with_discount: record.field_formatted_as_currency("value_with_discount"))
record.errors.add field, I18n.t("validations.sale_information.discounted_ownership_value",
mortgage: record.mortgage&.positive? ? " (#{record.field_formatted_as_currency('mortgage')})" : "",
deposit_and_grant_sentence:,
mortgage_deposit_and_grant_total: record.field_formatted_as_currency("mortgage_deposit_and_grant_total"),
discount_sentence:,
value_with_discount: record.field_formatted_as_currency("value_with_discount")).html_safe
end
end
end
@ -65,9 +72,17 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_and_deposit_total, record.value, 1)
%i[mortgageused mortgage value deposit].each do |field|
record.errors.add field, I18n.t("validations.sale_information.outright_sale_value", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), value: record.field_formatted_as_currency("value"))
record.errors.add field, I18n.t("validations.sale_information.outright_sale_value",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
mortgage: record.mortgage&.positive? ? " (#{record.field_formatted_as_currency('mortgage')})" : "",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value")).html_safe
end
record.errors.add :ownershipsch, :skip_bu_error, message: I18n.t("validations.sale_information.outright_sale_value", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), value: record.field_formatted_as_currency("value"))
record.errors.add :ownershipsch, :skip_bu_error, message: I18n.t("validations.sale_information.outright_sale_value",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
mortgage: record.mortgage&.positive? ? " (#{record.field_formatted_as_currency('mortgage')})" : "",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value")).html_safe
end
end
@ -155,16 +170,42 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_deposit_and_discount_total, record.expected_shared_ownership_deposit_value, 1)
%i[mortgage value deposit cashdis equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
elsif record.mortgage_not_used?
if over_tolerance?(record.deposit_and_discount_total, record.expected_shared_ownership_deposit_value, 1)
%i[mortgageused value deposit cashdis equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%").html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
equity: "#{record.equity}%").html_safe
end
end
end
@ -175,16 +216,34 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_and_deposit_total, record.expected_shared_ownership_deposit_value, 1)
%i[mortgage value deposit equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
equity: "#{record.equity}%",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
equity: "#{record.equity}%",
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
end
elsif record.mortgage_not_used?
if over_tolerance?(record.deposit, record.expected_shared_ownership_deposit_value, 1)
%i[mortgageused value deposit equity].each do |field|
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add field, I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.non_staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
expected_shared_ownership_deposit_value: record.field_formatted_as_currency("expected_shared_ownership_deposit_value")).html_safe
end
end
end
@ -197,15 +256,41 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_deposit_and_discount_total, record.stairbought_part_of_value, 1)
%i[mortgage value deposit cashdis stairbought].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy", mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used_socialhomebuy",
mortgage_deposit_and_discount_total: record.field_formatted_as_currency("mortgage_deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
mortgage: record.field_formatted_as_currency("mortgage"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
elsif over_tolerance?(record.deposit_and_discount_total, record.stairbought_part_of_value, 1)
%i[mortgageused value deposit cashdis stairbought].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy", deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used_socialhomebuy",
deposit_and_discount_total: record.field_formatted_as_currency("deposit_and_discount_total"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"),
value: record.field_formatted_as_currency("value"),
deposit: record.field_formatted_as_currency("deposit"),
cashdis: record.field_formatted_as_currency("cashdis"),
stairbought: "#{record.stairbought}%").html_safe
end
end
@ -215,15 +300,31 @@ module Validations::Sales::SaleInformationValidations
if over_tolerance?(record.mortgage_and_deposit_total, record.stairbought_part_of_value, 1)
%i[mortgage value deposit stairbought type].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used", mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_used",
mortgage: record.field_formatted_as_currency("mortgage"),
deposit: record.field_formatted_as_currency("deposit"),
mortgage_and_deposit_total: record.field_formatted_as_currency("mortgage_and_deposit_total"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
elsif over_tolerance?(record.deposit, record.stairbought_part_of_value, 1)
%i[mortgageused value deposit stairbought type].each do |field|
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add field, I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used", deposit: record.field_formatted_as_currency("deposit"), stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value"))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sale_information.staircasing_mortgage.mortgage_not_used",
deposit: record.field_formatted_as_currency("deposit"),
value: record.field_formatted_as_currency("value"),
stairbought_part_of_value: record.field_formatted_as_currency("stairbought_part_of_value")).html_safe
end
end

2
app/services/bulk_upload/lettings/year2023/row_parser.rb

@ -48,7 +48,7 @@ class BulkUpload::Lettings::Year2023::RowParser
field_42: "If 'Other', what is the type of tenancy?",
field_43: "What is the length of the fixed-term tenancy to the nearest year?",
field_44: "Is this letting sheltered accommodation?",
field_45: "Has tenant seen the DLUHC privacy notice?",
field_45: "Has tenant seen the MHCLG privacy notice?",
field_46: "What is the lead tenant's age?",
field_47: "Which of these best describes the lead tenant's gender identity?",
field_48: "Which of these best describes the lead tenant's ethnic background?",

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

@ -1130,10 +1130,6 @@ private
attributes["lettype"] = nil # should get this from rent_type
attributes["tenancycode"] = field_13
attributes["la"] = field_23
attributes["la_as_entered"] = field_23
attributes["postcode_known"] = postcode_known
attributes["postcode_full"] = postcode_full
attributes["owning_organisation"] = owning_organisation
attributes["managing_organisation"] = managing_organisation
attributes["renewal"] = renewal
@ -1304,22 +1300,29 @@ private
attributes["first_time_property_let_as_social_housing"] = first_time_property_let_as_social_housing
attributes["uprn_known"] = field_16.present? ? 1 : 0
attributes["uprn_confirmed"] = 1 if field_16.present?
attributes["skip_update_uprn_confirmed"] = true
attributes["uprn"] = field_16
attributes["address_line1"] = field_17
attributes["address_line1_as_entered"] = field_17
attributes["address_line2"] = field_18
attributes["address_line2_as_entered"] = field_18
attributes["town_or_city"] = field_19
attributes["town_or_city_as_entered"] = field_19
attributes["county"] = field_20
attributes["county_as_entered"] = field_20
attributes["address_line1_input"] = address_line1_input
attributes["postcode_full_input"] = postcode_full
attributes["postcode_full_as_entered"] = postcode_full
attributes["select_best_address_match"] = true if field_16.blank? && !supported_housing?
if general_needs?
attributes["uprn_known"] = field_16.present? ? 1 : 0
attributes["uprn_confirmed"] = 1 if field_16.present?
attributes["skip_update_uprn_confirmed"] = true
attributes["uprn"] = field_16
attributes["address_line1"] = field_17
attributes["address_line1_as_entered"] = field_17
attributes["address_line2"] = field_18
attributes["address_line2_as_entered"] = field_18
attributes["town_or_city"] = field_19
attributes["town_or_city_as_entered"] = field_19
attributes["county"] = field_20
attributes["county_as_entered"] = field_20
attributes["postcode_full"] = postcode_full
attributes["postcode_full_as_entered"] = postcode_full
attributes["postcode_known"] = postcode_known
attributes["la"] = field_23
attributes["la_as_entered"] = field_23
attributes["address_line1_input"] = address_line1_input
attributes["postcode_full_input"] = postcode_full
attributes["select_best_address_match"] = true if field_16.blank?
end
attributes
end

4
app/services/feature_toggle.rb

@ -11,6 +11,10 @@ class FeatureToggle
!Rails.env.development?
end
def self.deduplication_flow_enabled?
true
end
def self.duplicate_summary_enabled?
true
end

11
app/services/filter_manager.rb

@ -24,6 +24,9 @@ class FilterManager
next if category == "owning_organisation" && all_orgs
next if category == "managing_organisation" && all_orgs
next if category == "assigned_to"
next if category == "user_text_search" && filters["assigned_to"] != "specific_user"
next if category == "owning_organisation_text_search" && all_orgs
next if category == "managing_organisation_text_search" && all_orgs
logs = logs.public_send("filter_by_#{category}", values, user)
end
@ -94,11 +97,19 @@ class FilterManager
new_filters[filter] = params[filter] if params[filter].present?
end
if params["action"] == "download_csv"
new_filters["assigned_to"] = "all" if new_filters["assigned_to"] == "specific_user" && new_filters["user_text_search"].present?
new_filters["owning_organisation_select"] = "all" if new_filters["owning_organisation_select"] == "specific_organisation" && new_filters["owning_organisation_text_search"].present?
new_filters["managing_organisation_select"] = "all" if new_filters["managing_organisation_select"] == "specific_organisation" && new_filters["managing_organisation_text_search"].present?
end
new_filters = new_filters.except("owning_organisation") if params["owning_organisation_select"] == "all"
new_filters = new_filters.except("managing_organisation") if params["managing_organisation_select"] == "all"
new_filters = new_filters.except("user") if params["assigned_to"] == "all"
new_filters["user"] = current_user.id.to_s if params["assigned_to"] == "you"
new_filters = new_filters.except("user_text_search") if params["assigned_to"] == "all" || params["assigned_to"] == "you"
new_filters = new_filters.except("owning_organisation_text_search") if params["owning_organisation_select"] == "all"
new_filters = new_filters.except("managing_organisation_text_search") if params["managing_organisation_select"] == "all"
end
if (filter_type.include?("schemes") || filter_type.include?("users") || filter_type.include?("scheme_locations")) && params["status"].present?

13
app/views/content/privacy_notice.md

@ -6,7 +6,6 @@ If your household enters a new social housing tenancy or purchases a social hous
The information is provided via ‘<%= t('service_name') %>’, a service funded and managed by MHCLG. It collects information on the tenants or residents, tenancy or sale, and the dwelling itself. Some of this data is personal and sensitive, so MHCLG is responsible for ensuring it’s processed in line with data protection legislation.
## Why do we share this information?
Information collected via CORE is shared with other government departments and agencies. It’s shared with the Greater London Authority and the Regulator of Social Housing. Data providers can also access data for their organisations via CORE. Data is only shared for research and statistical purposes.
@ -27,16 +26,16 @@ Information collected via CORE relates to your tenancy, the dwelling you are liv
Collected data will be held for as long as necessary for research and statistical purposes. When no longer needed, data will be deleted in a safe manner. We’re aware some collected data is particularly sensitive. For example:
* ethnic group
* if previous tenure is a hospital, prison or approved probation hostel support
* if household left last settled home because discharged from prison, a long stay hospital or other institution
* if referral source is probation or prison, youth offending or community mental health team, or health service
- ethnic group
- if previous tenure is a hospital, prison or approved probation hostel support
- if household left last settled home because discharged from prison, a long stay hospital or other institution
- if referral source is probation or prison, youth offending or community mental health team, or health service
MHCLG publishes data annually, in aggregate form, as part of a report and complementary tables.
* For annual lettings data, visit: [https://www.gov.uk/government/collections/rents-lettings-and-tenancies](https://www.gov.uk/government/collections/rents-lettings-and-tenancies)
- For annual lettings data, visit: [https://www.gov.uk/government/collections/rents-lettings-and-tenancies](https://www.gov.uk/government/collections/rents-lettings-and-tenancies)
* For annual sales data, visit: [https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers](https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers)
- For annual sales data, visit: [https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers](https://www.gov.uk/government/collections/social-housing-sales-including-right-to-buy-and-transfers)
Detail-level data is anonymised and protected, minimising identification risk. It's held with the UK Data Archive.

1
app/views/filters/_radio_filter.html.erb

@ -10,6 +10,7 @@
collection: option[:conditional_filter][:options],
category: option[:conditional_filter][:category],
label: option[:conditional_filter][:label],
caption_text: option[:conditional_filter][:caption_text],
secondary: true,
hint_text: option[:conditional_filter][:hint_text],
} %>

20
app/views/filters/_text_select_filter.html.erb

@ -0,0 +1,20 @@
<span class="non-js-text-search-input-field">
<%= f.govuk_text_field "#{category}_text_search".to_sym,
label: { text: label, hidden: secondary },
"data-controller": "search conditional-filter",
caption: { text: caption_text },
"data-info": { search_url: filter_search_url(category.to_sym) }.to_json,
value: selected_option("#{category}_text_search", @filter_type) %>
</span>
<%= f.govuk_select(category.to_sym,
label: { text: label, hidden: secondary },
"data-controller": "search conditional-filter",
"hidden": true,
"data-info": { search_url: filter_search_url(category.to_sym) }.to_json) do %>
<% collection.each do |answer| %>
<option value="<%= answer.id %>"
data-hint="<%= answer.hint %>"
<%= answer.id.to_s == selected_option(category, @filter_type).to_s ? "selected" : "" %>
<%= answer.id == "" ? "disabled" : "" %>><%= answer.name %></option>
<% end %>
<% end %>

3
app/views/filters/assigned_to.html.erb

@ -11,7 +11,8 @@
type: "select",
label: "User",
category: "user",
options: assigned_to_filter_options(current_user),
caption_text: "User's name or email",
options: assigned_to_csv_filter_options(current_user),
},
},
},

3
app/views/filters/managed_by.html.erb

@ -9,7 +9,8 @@
type: "select",
label: "Managed by",
category: "managing_organisation",
options: managing_organisation_filter_options(current_user),
options: managing_organisation_csv_filter_options(current_user),
caption_text: "Organisation name",
},
},
},

3
app/views/filters/owned_by.html.erb

@ -9,7 +9,8 @@
type: "select",
label: "Owning Organisation",
category: "owning_organisation",
options: owning_organisation_filter_options(current_user),
options: all_owning_organisation_filter_options(current_user),
caption_text: "Organisation name",
},
},
},

4
app/views/form/_numeric_question.html.erb

@ -1,4 +1,4 @@
<%= render partial: "form/guidance/#{question.top_guidance_partial}" if question.top_guidance? %>
<%= render partial: "form/guidance/#{question.top_guidance_partial}", locals: { log: @log } if question.top_guidance? %>
<%= f.govuk_text_field(
question.id.to_sym,
@ -14,7 +14,7 @@
suffix_text: question.suffix_label(@log),
value: format_money_input(log: @log, question:),
inputmode: "numeric",
pattern: "\d*\.?\d*",
pattern: "\\d*\\.?\\d*",
**stimulus_html_attributes(question),
) %>

2
app/views/form/_radio_question.html.erb

@ -1,4 +1,4 @@
<%= render partial: "form/guidance/#{question.top_guidance_partial}" if question.top_guidance? %>
<%= render partial: "form/guidance/#{question.top_guidance_partial}", locals: { log: @log } if question.top_guidance? %>
<% banner = question.notification_banner(@log) %>
<% if banner %>
<%= govuk_notification_banner(

29
app/views/form/guidance/_financial_calculations_discounted_ownership.html.erb

@ -0,0 +1,29 @@
<% discount_question_link = question_link("discount", log, current_user) %>
<% grant_question_link = question_link("grant", log, current_user) %>
<% value_question_link = question_link("value", log, current_user) %>
<%= govuk_details(summary_text: "How the financial values are calculated") do %>
<p class="govuk-body">
<% if log.mortgage_used? || log.mortgageused.blank? %>
<% mortgage_question_link = log.mortgageused.blank? ? question_link("mortgageused", log, current_user) : question_link("mortgage", log, current_user) %>
The mortgage amount <%= mortgage_question_link %><% if grant_question_link.blank? %>
and cash deposit <%= question_link("deposit", log, current_user) %>
<% else %>, cash deposit <%= question_link("deposit", log, current_user) %>
and grant <%= grant_question_link %>
<% end %>
added together must equal
<% else %>
<% if grant_question_link.blank? %>
Cash deposit <%= question_link("deposit", log, current_user) %> must equal
<% else %>
Cash deposit <%= question_link("deposit", log, current_user) %>
and grant <%= grant_question_link %>
added together must equal
<% end %>
<% end %>
the purchase price <%= value_question_link %>
<% if discount_question_link.present? %>
subtracted by the sum of the purchase price <%= value_question_link %>
multiplied by the discount <%= discount_question_link %>
<% end %>
</p>
<% end %>

13
app/views/form/guidance/_financial_calculations_outright_sale.html.erb

@ -0,0 +1,13 @@
<%= govuk_details(summary_text: "How the financial values are calculated") do %>
<p class="govuk-body">
<% if log.mortgage_used? || log.mortgageused.blank? %>
<% mortgage_question_link = log.mortgageused.blank? ? question_link("mortgageused", log, current_user) : question_link("mortgage", log, current_user) %>
The mortgage amount <%= mortgage_question_link %>
and cash deposit <%= question_link("deposit", log, current_user) %>
added together must equal
<% else %>
Cash deposit <%= question_link("deposit", log, current_user) %> must equal
<% end %>
the purchase price <%= question_link("value", log, current_user) %>
</p>
<% end %>

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

@ -0,0 +1,30 @@
<%= govuk_details(summary_text: "How the financial values are calculated") do %>
<p class="govuk-body">
<% if log.mortgage_used? || log.mortgageused.blank? %>
<% mortgage_question_link = log.mortgageused.blank? ? question_link("mortgageused", log, current_user) : question_link("mortgage", log, current_user) %>
The mortgage amount <%= mortgage_question_link %><% if log.type == 18 %>, cash deposit <%= question_link("deposit", log, current_user) %>,
and cash discount <%= question_link("cashdis", log, current_user) %>
added together
<% else %>
and cash deposit <%= question_link("deposit", log, current_user) %>
added together
<% end %>
<% elsif log.mortgage_not_used? || log.mortgage_use_unknown? %>
<% if log.type == 18 %>
The cash deposit <%= question_link("deposit", log, current_user) %>,
and cash discount <%= question_link("cashdis", log, current_user) %>
added together
<% else %>
Cash deposit <%= question_link("deposit", log, current_user) %>
<% end %>
<% 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) %>
multiplied by the percentage bought <%= question_link("stairbought", log, current_user) %>
<% else %>
multiplied by the percentage equity stake <%= question_link("equity", log, current_user) %>
<% end %>
</p>
<% end %>

15
app/views/logs/_log_filters.html.erb

@ -66,10 +66,11 @@
"specific_user": {
label: "Specific user",
conditional_filter: {
type: "select",
type: "text_select",
label: "User",
category: "user",
options: assigned_to_filter_options(current_user),
options: assigned_to_filter_options(@filter_type),
caption_text: "User's name or email",
},
},
},
@ -86,10 +87,11 @@
"specific_org": {
label: "Specific owning organisation",
conditional_filter: {
type: "select",
type: "text_select",
label: "Owning Organisation",
category: "owning_organisation",
options: owning_organisation_filter_options(current_user),
options: owning_organisation_filter_options(current_user, @filter_type),
caption_text: "Organisation name",
},
},
},
@ -107,10 +109,11 @@
"specific_org": {
label: "Specific managing organisation",
conditional_filter: {
type: "select",
type: "text_select",
label: user_or_org_lettings_path? ? "Managed by" : "Reported by",
category: "managing_organisation",
options: managing_organisation_filter_options(current_user),
options: managing_organisation_filter_options(current_user, @filter_type),
caption_text: "Organisation name",
},
},
},

2
app/views/schemes/_scheme_filters.html.erb

@ -35,7 +35,7 @@
type: "select",
label: "Owning Organisation",
category: "owning_organisation",
options: owning_organisation_filter_options(current_user),
options: all_owning_organisation_filter_options(current_user),
},
},
},

8
babel.config.js

@ -35,10 +35,10 @@ module.exports = function (api) {
'babel-plugin-macros',
'@babel/plugin-syntax-dynamic-import',
isTestEnv && 'babel-plugin-dynamic-import-node',
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-private-methods',
'@babel/plugin-proposal-private-property-in-object',
'@babel/plugin-transform-class-properties',
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-transform-private-methods',
'@babel/plugin-transform-private-property-in-object',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
[

376
config/forms/2021_2022.json

@ -34,9 +34,7 @@
}
},
"conditional_for": {
"postcode_full": [
1
]
"postcode_full": [1]
},
"hidden_in_check_answers": {
"depends_on": [
@ -61,12 +59,14 @@
"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"
}
]
}
},
"depends_on": [
@ -851,9 +851,7 @@
}
},
"conditional_for": {
"mrcdate": [
1
]
"mrcdate": [1]
}
},
"mrcdate": {
@ -999,9 +997,7 @@
}
},
"conditional_for": {
"tenancyother": [
3
]
"tenancyother": [3]
}
},
"tenancyother": {
@ -1043,9 +1039,7 @@
}
},
"conditional_for": {
"tenancyother": [
3
]
"tenancyother": [3]
}
},
"tenancyother": {
@ -1277,9 +1271,7 @@
}
},
"conditional_for": {
"age1": [
0
]
"age1": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -1301,12 +1293,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age1_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age1_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -2056,9 +2050,7 @@
}
},
"conditional_for": {
"age2": [
0
]
"age2": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -2080,12 +2072,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age2_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age2_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -2591,9 +2585,7 @@
}
},
"conditional_for": {
"age3": [
0
]
"age3": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -2615,12 +2607,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age3_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age3_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -3123,9 +3117,7 @@
}
},
"conditional_for": {
"age4": [
0
]
"age4": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -3147,12 +3139,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age4_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age4_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -3652,9 +3646,7 @@
}
},
"conditional_for": {
"age5": [
0
]
"age5": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -3676,12 +3668,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age5_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age5_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -4178,9 +4172,7 @@
}
},
"conditional_for": {
"age6": [
0
]
"age6": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -4202,12 +4194,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age6_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age6_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -4701,9 +4695,7 @@
}
},
"conditional_for": {
"age7": [
0
]
"age7": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -4725,12 +4717,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age7_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age7_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -5221,9 +5215,7 @@
}
},
"conditional_for": {
"age8": [
0
]
"age8": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -5245,12 +5237,14 @@
"max": 120,
"step": 1,
"width": 2,
"inferred_check_answers_value": [{
"condition": {
"age8_known": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"age8_known": 1
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -6251,7 +6245,7 @@
"value": "Other"
},
"47": {
"value":"Tenant prefers not to say"
"value": "Tenant prefers not to say"
},
"divider": {
"value": true
@ -6261,9 +6255,7 @@
}
},
"conditional_for": {
"reasonother": [
20
]
"reasonother": [20]
}
},
"reasonother": {
@ -6495,9 +6487,7 @@
}
},
"conditional_for": {
"ppostcode_full": [
0
]
"ppostcode_full": [0]
},
"hidden_in_check_answers": {
"depends_on": [
@ -6522,12 +6512,14 @@
"is_previous_la_inferred": true
}
},
"inferred_check_answers_value": [{
"condition": {
"ppcodenk": 1
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"ppcodenk": 1
},
"value": "Not known"
}
]
}
}
},
@ -6560,9 +6552,7 @@
}
},
"conditional_for": {
"prevloc": [
1
]
"prevloc": [1]
}
},
"prevloc": {
@ -6957,12 +6947,14 @@
"W92000004": "Wales",
"9300000XX": "Outside UK"
},
"inferred_check_answers_value": [{
"condition": {
"previous_la_known": 0
},
"value": "Not known"
}]
"inferred_check_answers_value": [
{
"condition": {
"previous_la_known": 0
},
"value": "Not known"
}
]
}
},
"depends_on": [
@ -7618,9 +7610,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7715,9 +7705,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7762,9 +7750,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7809,9 +7795,7 @@
}
},
"conditional_for": {
"chcharge": [
1
]
"chcharge": [1]
}
},
"chcharge": {
@ -7888,12 +7872,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7907,12 +7886,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7926,12 +7900,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7945,12 +7914,7 @@
"width": 5,
"prefix": "£",
"suffix": " every week",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -7966,12 +7930,7 @@
"suffix": " every week",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8111,12 +8070,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8130,12 +8084,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8149,12 +8098,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8168,12 +8112,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 2 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8189,12 +8128,7 @@
"suffix": " every 2 weeks",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8234,12 +8168,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8253,12 +8182,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8272,12 +8196,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8291,12 +8210,7 @@
"width": 5,
"prefix": "£",
"suffix": " every 4 weeks",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8312,12 +8226,7 @@
"suffix": " every 4 weeks",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8357,12 +8266,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8376,12 +8280,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8395,12 +8294,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8414,12 +8308,7 @@
"width": 5,
"prefix": "£",
"suffix": " every month",
"fields-to-add": [
"brent",
"scharge",
"pscharge",
"supcharg"
],
"fields-to-add": ["brent", "scharge", "pscharge", "supcharg"],
"result-field": "tcharge",
"hidden_in_check_answers": true
},
@ -8435,12 +8324,7 @@
"suffix": " every month",
"readonly": true,
"requires_js": true,
"fields_added": [
"brent",
"scharge",
"pscharge",
"supcharg"
]
"fields_added": ["brent", "scharge", "pscharge", "supcharg"]
}
},
"depends_on": [
@ -8637,9 +8521,7 @@
}
},
"conditional_for": {
"tshortfall": [
0
]
"tshortfall": [0]
}
},
"tshortfall": {

1008
config/forms/2022_2023.json

File diff suppressed because it is too large Load Diff

16
config/forms/schema/2021_2022.json

@ -4,12 +4,7 @@
"title": "Form",
"description": "A form",
"type": "object",
"required": [
"form_type",
"start_year",
"end_year",
"sections"
],
"required": ["form_type", "start_year", "end_year", "sections"],
"properties": {
"form_type": {
"description": "",
@ -40,9 +35,7 @@
"[a-z_]+": {
"description": "",
"type": "object",
"required": [
"label"
],
"required": ["label"],
"properties": {
"label": {
"description": "",
@ -69,10 +62,7 @@
"[a-z_]+": {
"description": "",
"type": "object",
"required": [
"header",
"check_answer_label"
],
"required": ["header", "check_answer_label"],
"properties": {
"check_answer_label": {
"description": "",

47
config/forms/schema/generic.json

@ -4,12 +4,7 @@
"title": "Form",
"description": "A form",
"type": "object",
"required": [
"form_type",
"start_year",
"end_year",
"sections"
],
"required": ["form_type", "start_year", "end_year", "sections"],
"properties": {
"form_type": {
"description": "",
@ -40,9 +35,7 @@
"[a-z_]+": {
"description": "SubSection Name",
"type": "object",
"required": [
"label"
],
"required": ["label"],
"properties": {
"label": {
"description": "",
@ -54,10 +47,7 @@
"^(?!(depends_on))[a-z_]+$": {
"description": "Page Name",
"type": "object",
"required": [
"header",
"questions"
],
"required": ["header", "questions"],
"properties": {
"header": {
"description": "",
@ -73,10 +63,7 @@
"[a-z_]+": {
"description": "Question Name",
"type": "object",
"required": [
"header",
"type"
],
"required": ["header", "type"],
"properties": {
"header": {
"description": "",
@ -115,20 +102,22 @@
"description": "fields that get inferred based on the value of the current field",
"type": "object"
},
"inferred_check_answers_value": [{
"description": "value that gets displayed in the check answers for this field if the given condition is met",
"type": "object",
"properties": {
"condition": {
"description": "",
"type": "object"
},
"value": {
"description": "",
"type": "object"
"inferred_check_answers_value": [
{
"description": "value that gets displayed in the check answers for this field if the given condition is met",
"type": "object",
"properties": {
"condition": {
"description": "",
"type": "object"
},
"value": {
"description": "",
"type": "object"
}
}
}
}]
]
},
"minProperties": 1
}

20
config/locales/en.yml

@ -637,8 +637,8 @@ en:
must_be_after_hodate: "Sale completion date must be after practical completion or handover date"
previous_property_type:
property_type_bedsit: "A bedsit cannot have more than 1 bedroom"
discounted_ownership_value: "The mortgage, deposit, and grant when added together is %{mortgage_deposit_and_grant_total}, and the purchase price times by the discount is %{value_with_discount}. These figures should be the same"
outright_sale_value: "The mortgage and deposit when added together is %{mortgage_and_deposit_total}, and the purchase price is %{value}. These figures should be the same."
discounted_ownership_value: "The mortgage%{mortgage}%{deposit_and_grant_sentence} added together is %{mortgage_deposit_and_grant_total}.</br></br>The full purchase price%{discount_sentence} is %{value_with_discount}.</br></br>These two amounts should be the same."
outright_sale_value: "The mortgage%{mortgage} and cash deposit (%{deposit}) when added together is %{mortgage_and_deposit_total}.</br></br>The full purchase price is %{value}.</br></br>These two amounts should be the same."
monthly_rent:
higher_than_expected: "Basic monthly rent must be between £0.00 and £9,999.00"
grant:
@ -649,15 +649,15 @@ en:
over_discounted_london_max: "The percentage discount multiplied by the purchase price is %{discount_value}. This figure should not be more than £136,400 for properties in London."
over_discounted_max: "The percentage discount multiplied by the purchase price is %{discount_value}. This figure should not be more than £102,400 for properties outside of London."
non_staircasing_mortgage:
mortgage_used: "The mortgage and deposit added together is %{mortgage_and_deposit_total}. The value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_not_used: "The deposit is %{deposit} and the value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_used_socialhomebuy: "The mortgage, deposit, and cash discount added together is %{mortgage_deposit_and_discount_total}. The value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_not_used_socialhomebuy: "The deposit and cash discount added together is %{deposit_and_discount_total}. The value multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}. These figures should be the same."
mortgage_used: "The mortgage (%{mortgage}) and cash deposit (%{deposit}) added together is %{mortgage_and_deposit_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage equity stake purchased (%{equity}) is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
mortgage_not_used: "The cash deposit is %{deposit}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
mortgage_used_socialhomebuy: "The mortgage amount (%{mortgage}), cash deposit (%{deposit}), and cash discount (%{cashdis}) added together is %{mortgage_deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage equity stake purchased (%{equity}) is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
mortgage_not_used_socialhomebuy: "The cash deposit (%{deposit}) and cash discount (%{cashdis}) added together is %{deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought (%{equity}) is %{expected_shared_ownership_deposit_value}.</br></br>These two amounts should be the same."
staircasing_mortgage:
mortgage_used: "The mortgage and deposit added together is %{mortgage_and_deposit_total}. The value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_not_used: "The deposit is %{deposit} and the value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_used_socialhomebuy: "The mortgage, deposit, and cash discount added together is %{mortgage_deposit_and_discount_total}. The value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_not_used_socialhomebuy: "The deposit and cash discount added together is %{deposit_and_discount_total}. The value multiplied by the percentage bought is %{stairbought_part_of_value}. These figures should be the same."
mortgage_used: "The mortgage (%{mortgage}) and cash deposit (%{deposit}) added together is %{mortgage_and_deposit_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
mortgage_not_used: "The cash deposit is %{deposit}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
mortgage_used_socialhomebuy: "The mortgage amount (%{mortgage}), cash deposit (%{deposit}), and cash discount (%{cashdis}) added together is %{mortgage_deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought (%{stairbought}) is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
mortgage_not_used_socialhomebuy: "The cash deposit (%{deposit}) and cash discount (%{cashdis}) added together is %{deposit_and_discount_total}.</br></br>The full purchase price (%{value}) multiplied by the percentage bought (%{stairbought}) is %{stairbought_part_of_value}.</br></br>These two amounts should be the same."
stairowned:
mortgageused_dont_know: "The percentage owned has to be 100% if the mortgage used is 'Don’t know'"
merge_request:

8
config/routes.rb

@ -125,6 +125,10 @@ Rails.application.routes.draw do
get "edit-dpo", to: "users#dpo"
get "edit-key-contact", to: "users#key_contact"
collection do
get :search
end
member do
get "deactivate", to: "users#deactivate"
get "reactivate", to: "users#reactivate"
@ -191,6 +195,10 @@ Rails.application.routes.draw do
get "delete-confirmation", to: "organisations#delete_confirmation"
delete "delete", to: "organisations#delete"
end
collection do
get :search
end
end
resources :merge_requests, path: "/merge-request" do

1
config/storage.yml

@ -5,7 +5,6 @@ test:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3

2
docker-compose.yml

@ -1,4 +1,4 @@
version: '3.6'
version: "3.6"
volumes:
dbdata:

2
docs/adr/adr-006-saving-values.md

@ -18,6 +18,6 @@ There are a few reasons we have opted to save the values directly, they are as f
- Changing the wording/casing of the answers could result in discrepancies in the database.
- There is a small risk that if the database is accessed by someone unauthorised they would have access to personally identifiable information if we were to collect Any. We will be mitigating this risk by encrypting the production database.
- There is a small risk that if the database is accessed by someone unauthorised they would have access to personally identifiable information if we were to collect Any. We will be mitigating this risk by encrypting the production database.
This decision is not too difficult to change and can be revisited in the future if there is sufficient reason to switch to storing keys/numbers and using enums and active record to convert those to the appropriate values.

7
docs/adr/adr-015-asset-pipeline.md

@ -12,12 +12,11 @@ However, since Rails 7, it's been deprecated by the Rails CORE team and it's jav
The primary options considered were:
1. [Import maps](https://github.com/rails/importmap-rails) - Rails 7 default. Serve JS directly but do no transpiling so not suitable
2. [JSBundling](https://github.com/rails/jsbundling-rails) - Rails recommended
- With [ESBuild](https://esbuild.github.io/) - fast and does some transpiling but [doesn't support ES5/IE11](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/203)
- With [Rollup](https://www.rollupjs.org/guide/en/) - similar to ESBuild, node rather than Go based, doesn't have the big speed benefits
- With [Webpack](https://webpack.js.org/) - rather than the old approach of using Webpacker as a opinionated wrapper around webpack, this approach uses webpack directly.
- With [ESBuild](https://esbuild.github.io/) - fast and does some transpiling but [doesn't support ES5/IE11](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/203)
- With [Rollup](https://www.rollupjs.org/guide/en/) - similar to ESBuild, node rather than Go based, doesn't have the big speed benefits
- With [Webpack](https://webpack.js.org/) - rather than the old approach of using Webpacker as a opinionated wrapper around webpack, this approach uses webpack directly.
3. [Shakapacker](https://github.com/shakacode/shakapacker) - the "official" community maintained fork of Webpacker 6 RC. Requires upgrading current install since breaking changes happened between Webpacker 5 & 6
4. [Vite](https://vite-ruby.netlify.app/) - Webpack alternative

2
docs/adr/adr-016-hotwire.md

@ -14,4 +14,4 @@ For **Stimulus** we were able to do this and are continuing to use it. This is a
- Adding the StimulusJS NPM package path to our [webpack config](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/blob/main/webpack.config.js#L23) rules to be transpiled
- Adding the required Babel plugins to our [Babel config](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/blob/main/babel.config.js#L34)
For **Turbo** the same approach was attempted but proved [unsuccessful](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/430). As a result we decided to [remove Turbo](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/406) until we can drop support for Internet Explorer. This does have a perceptible impact on UX/speed but provides the most browser compatibility.
For **Turbo** the same approach was attempted but proved [unsuccessful](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/430). As a result we decided to [remove Turbo](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data/pull/406) until we can drop support for Internet Explorer. This does have a perceptible impact on UX/speed but provides the most browser compatibility.

3
docs/adr/adr-018-form-setup.md

@ -18,7 +18,8 @@ The amount of application context needed to make it work is what ultimately drov
Instead the setup section is now composed of coded Ruby class in `app/models/form/setup`.
It still has all the same components as before:
- Section
- Section
- Subsection
- Pages
- Questions

1
docs/adr/adr-019-form-end-dates.md

@ -12,6 +12,7 @@ There might be short extensions to the deadline, so shortly after the last day t
Also, if incorrect data is found during QA process, data providers might be asked to correct it. Once the data has been through its first QA processes and is as present and correct as possible, the ability to edit and delete logs is closed. This is typically in late summer/autumn, but it depends on the statistical analysis.
To accommodate the different end dates, we will now store 3 different dates on the form definition:
- Submission deadline (submission_deadline) - this is the date displayed at the top of a completed log in lettings and sales - "You can review and make changes to this log until 9 June 2024.". Nothing happens on this date
- New logs end date (new_logs_end_date) - no new logs for that collection year can be submitted, but logs can be edited
- Edit and delete logs end date (edit_end_date) - logs can no longer be edited or deleted. Completed logs can still be viewed. Materials / references to the collection year are removed.

31
docs/api/v1.json

@ -54,9 +54,7 @@
{
"schema": {
"type": "string",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -109,9 +107,7 @@
"Invalid Age": {
"value": {
"errors": {
"age1": [
"Tenant age must be between 16 and 120"
]
"age1": ["Tenant age must be between 16 and 120"]
}
}
}
@ -136,9 +132,7 @@
{
"schema": {
"type": "string",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -177,9 +171,7 @@
{
"schema": {
"type": "string",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -219,9 +211,7 @@
"reasonable_preference_reason": [
"If reasonable preference is Yes, a reason must be given"
],
"age1": [
"Tenant age must be between 16 and 120"
]
"age1": ["Tenant age must be between 16 and 120"]
}
}
}
@ -247,9 +237,7 @@
"schema": {
"type": "string",
"pattern": "application/json",
"enum": [
"application/json"
]
"enum": ["application/json"]
},
"in": "header",
"name": "Accept",
@ -430,12 +418,7 @@
"sex1": {
"type": "string",
"minLength": 1,
"enum": [
"F: Female",
"M:Male",
"X:Non-binary",
"R:Refused"
],
"enum": ["F: Female", "M:Male", "X:Non-binary", "R:Refused"],
"maxLength": 1
},
"ethnic": {

10
docs/app_api.md

@ -6,10 +6,10 @@ nav_order: 8
In order to use the app as an API, you will need to configure requests to the API as so:
* Configure your request with Basic Auth. Set the username to be the same as `API_USER` and password as the `API_KEY` (`API_USER` and `API_KEY` are environment variables that should be set for the application)
* Check the endpoint you are calling is an action that is `create`, `show` or `update`
* Check you are setting the following request headers:
* `Content-Type = application/json`
* `Action = application/json` N.B. If you use `*/*` instead, the request won't be recognised as an API request`
- Configure your request with Basic Auth. Set the username to be the same as `API_USER` and password as the `API_KEY` (`API_USER` and `API_KEY` are environment variables that should be set for the application)
- Check the endpoint you are calling is an action that is `create`, `show` or `update`
- Check you are setting the following request headers:
- `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.

8
docs/documentation_website.md

@ -7,13 +7,13 @@ nav_order: 11
The documentation website can be generated and run locally using Jekyll.
1. Change into the `/docs/` directory:\
`cd docs`
`cd docs`
2. Install Jekyll and its dependencies:\
`bundle install`
`bundle install`
3. Start the Jekyll server:\
`bundle exec jekyll serve`
`bundle exec jekyll serve`
4. View the website:\
<http://localhost:4000>
<http://localhost:4000>

2
docs/form/builder.md

@ -115,7 +115,7 @@ Assumptions made by the format:
- For conditionally shown questions, conditions that have been implemented and can be used are:
- Radio question answer option selected matches one of conditional e.g.\
`["answer-options-1-string", "answer-option-3-string"]`
`["answer-options-1-string", "answer-option-3-string"]`
- Numeric question value matches condition e.g. [">2"], ["<7"] or ["== 6"]

4
docs/form/definition.md

@ -16,12 +16,12 @@ The current system is built around a form definition written in JSON. At the top
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": {
...
...
}
}
```

4
docs/form/question.md

@ -31,7 +31,7 @@ An example question might look something like this:
}
```
In the above example the the question has the id `postcode_known`.
In the above example the the question has the id `postcode_known`.
The `check_answer_label` contains the text that will be displayed in the label of the table on the check answers page.
@ -85,6 +85,6 @@ The answer the data inputter provides to some questions allows us to infer the v
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).
The above example links to the first example as both of these questions would be on the same page. The `inferred_check_answers_value` is what should be displayed on the check answers page for this question if we infer it. If the value of `postcode_known` was given as `0` (which is a no), as seen in the condition part of `inferred_check_answers_value` then we can infer that the data inputter does not know the postcode and so we would display the value of `Not known` on the check answers page for the postcode.
The above example links to the first example as both of these questions would be on the same page. The `inferred_check_answers_value` is what should be displayed on the check answers page for this question if we infer it. If the value of `postcode_known` was given as `0` (which is a no), as seen in the condition part of `inferred_check_answers_value` then we can infer that the data inputter does not know the postcode and so we would display the value of `Not known` on the check answers page for the postcode.
In the above example the `inferred_answers` refers to a question where we can infer the answer based on the answer of this question. In this case the `la` question can be inferred from the postcode value given by the data inputter as we are able to lookup the local authority based on the postcode given. We then set a property on the lettings log `is_la_inferred` to true to indicate that this is an answer we've inferred.

BIN
docs/images/service.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
docs/images/service.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

41
docs/infrastructure.md

@ -7,32 +7,38 @@ nav_order: 5
## Current infrastructure
Currently, there are four environments with infrastructure:
- Meta
- Development (Review Apps)
- Staging
- Production
### Meta
This holds the Terraform “backend” and the ECR(s).
The Terraform “backend” consists of:
- S3 buckets - for storing Terraform state files. One for all non-production environments (including the meta environment itself), and another just for production.
- DynamoDB - for managing access and locking of all state files.
The ECR(s) are:
- core - holds the application Docker images.
- db-migration - holds the Docker images curated to help migrate a DB from PaaS to AWS.
- s3-migration - holds the Docker images curated to help migrate S3 files from PaaS to AWS.
N.B. the migration ECRs may or may not be present, depending on if the Terraform has been configured to create migration infrastructure. The migration infrastructure is only used to help migrate the DB and S3 from PaaS to AWS, so is usually therefore only temporarily present.
N.B. the migration ECRs may or may not be present, depending on if the Terraform has been configured to create migration infrastructure. The migration infrastructure is only used to help migrate the DB and S3 from PaaS to AWS, so is usually therefore only temporarily present.
### Development / Staging / Production
These are the main environments holding the “application” infrastructure.
These are the main environments holding the “application” infrastructure.
Though not exhaustive, each of them will generally contain the following key components:
- ECS Fargate cluster
- RDS (PostgreSQL database)
- ElastiCache (Redis data store)
- S3 buckets
- One for Bulk upload (sometimes also to referred to as the CSV bucket)
- One for CDS Export
- One for Bulk upload (sometimes also to referred to as the CSV bucket)
- One for CDS Export
- VPC
- Private subnets
- Public subnets
@ -43,11 +49,12 @@ Though not exhaustive, each of them will generally contain the following key com
- WAF (Firewall)
### Development / Review Apps
The development environment is used for Review Apps, and has some infrastructure that is created per-review-app and some that is shared by all apps.
The development environment is used for Review Apps, and has some infrastructure that is created per-review-app and some that is shared by all apps.
In general, each review app has its own ECS Fargate cluster and Redis instances (plus any infrastructure to enable this), while the rest is shared.
Where to find the Infrastructure?
The infrastructure is managed as code.
The infrastructure is managed as code.
In the terraform folder of the codebase, there will be dedicated sub-folders for each of the aforementioned environments, where all the infrastructure for them is defined.
## Deployment (Pipeline — Recommended)
@ -64,7 +71,6 @@ To deploy you need to:
6. Post success message on Slack.
7. Tag tickets as ‘Released’ and move tickets to done on JIRA.
## CI/CD
When a commit is made to `main` the following GitHub action jobs are triggered:
@ -88,26 +94,27 @@ After a sucessful deployment a comment will be added to the pull request with th
Once a pull request has been closed the review app infrastructure will be tore down to save on any costs. Should you wish to re-open a closed pull request the review app will be spun up again.
### Review app deployment failures
### Review app deployment failures
One reason a review app deployment might fail is that it is attempting to run migrations which conflict with data in the database. For example you might have introduced a unique constraint, but the database associated with the review app has duplicate data in it that would violate this constraint, and so the migration cannot be run.
## Destroying/recreating infrastructure
Things to watch out for when destroying/creating infra:
- All resources
- The lifecycle meta-argument prevent_destroy will stop you destroying things. Best to set this to false before trying to destroy!
- The lifecycle meta-argument prevent_destroy will stop you destroying things. Best to set this to false before trying to destroy!
- Database
- skip_final_snapshot being false will prevent you from destroying the db without creating a final snapshot.
- skip_final_snapshot being false will prevent you from destroying the db without creating a final snapshot.
- Load Balancer
- Sometimes when creating infra, you may see the error message: failure configuring LB attributes: InvalidConfigurationRequest: Access Denied for bucket: <load-balancer-access-log-bucket-name>. Please check S3bucket permission during a terraform apply. To get around this you may have wait a few minutes and try applying again to ensure everything is fully updated (the error shouldn’t appear on the second attempt). It’s unclear what the exact cause is, but as this is related to infra that enables load balancer access logging, it is suspected there might be a delay with the S3 bucket permissions being realised or the load balancer recognising it can access the bucket.
- Sometimes when creating infra, you may see the error message: failure configuring LB attributes: InvalidConfigurationRequest: Access Denied for bucket: <load-balancer-access-log-bucket-name>. Please check S3bucket permission during a terraform apply. To get around this you may have wait a few minutes and try applying again to ensure everything is fully updated (the error shouldn’t appear on the second attempt). It’s unclear what the exact cause is, but as this is related to infra that enables load balancer access logging, it is suspected there might be a delay with the S3 bucket permissions being realised or the load balancer recognising it can access the bucket.
- S3
- Terraform won’t let you delete buckets that have objects in them.
- Terraform won’t let you delete buckets that have objects in them.
- Secrets
- If you destroy secrets, they will actually be marked as ‘scheduled to delete’ which will take effect after a minimum of 7 days. You can’t recreate secrets with the same name during this period. If you want to destroy immediately, you need to do it from the command line (using your staging developer role, rather than your MHCLG-wide role used to apply Terraform) with this command: aws secretsmanager delete-secret --force-delete-without-recovery --secret-id <secret-arn>. (Note that if a secret is marked as scheduled to delete, you can undo this in the console to make it an ‘active’ secret again.)
- You may need to manually re-enter secret values into Secrets Manager at some point. When you do, just paste the secret value as plain text (don’t enter a key name, or format it as JSON).
- If you destroy secrets, they will actually be marked as ‘scheduled to delete’ which will take effect after a minimum of 7 days. You can’t recreate secrets with the same name during this period. If you want to destroy immediately, you need to do it from the command line (using your staging developer role, rather than your MHCLG-wide role used to apply Terraform) with this command: aws secretsmanager delete-secret --force-delete-without-recovery --secret-id <secret-arn>. (Note that if a secret is marked as scheduled to delete, you can undo this in the console to make it an ‘active’ secret again.)
- You may need to manually re-enter secret values into Secrets Manager at some point. When you do, just paste the secret value as plain text (don’t enter a key name, or format it as JSON).
- ECS
- Sometimes task definitions don’t get deleted. You may need to manually delete them.
- After destroying the db, you’ll need to make sure the ad hoc ECS task which seeds the database gets run in order to set up the database correctly.
- Sometimes task definitions don’t get deleted. You may need to manually delete them.
- After destroying the db, you’ll need to make sure the ad hoc ECS task which seeds the database gets run in order to set up the database correctly.
- SNS
- When creating an email subscription in an environment, Terraform will look up the email to use as the subscription endpoint from Secrets Manager. If you haven’t already created this (e.g. by running terraform apply -target="module.monitoring" -var="create_secrets_first=true") then this will lead to the subscription creation erroring, because it can’t retrieve the value of the secret (because it doesn’t exist yet). If this happens, remember you’ll need to go to Secrets Manager in the console and enter the desired email (as plaintext, no quotation marks or anything else required) as the value of the secret (which is most likely called MONITORING_EMAIL). Then run another apply with Terraform and this time it should succeed.
- When creating an email subscription in an environment, Terraform will look up the email to use as the subscription endpoint from Secrets Manager. If you haven’t already created this (e.g. by running terraform apply -target="module.monitoring" -var="create_secrets_first=true") then this will lead to the subscription creation erroring, because it can’t retrieve the value of the secret (because it doesn’t exist yet). If this happens, remember you’ll need to go to Secrets Manager in the console and enter the desired email (as plaintext, no quotation marks or anything else required) as the value of the secret (which is most likely called MONITORING_EMAIL). Then run another apply with Terraform and this time it should succeed.

19
docs/monitoring.md

@ -3,36 +3,46 @@ nav_order: 6
---
# Logs and Debugging
## Logs
Logs can be found in two locations:
- AWS CloudWatch (for general application / infrastructure logging)
- Sentry (for application error logging)
### CloudWatch
The CloudWatch service can be accessed from the AWS Console. You should authenticate onto the infrastructure environment whose logs you want to check.
From CloudWatch, navigate to the desired log group (e.g. for the app task running on ECS) and open the desired log stream, in order to read its log “events”.
Alternatively, you can also navigate to a specific AWS service / resource in question (e.g. ECS tasks), selecting the instance of interest (e.g. a specific ECS task), and finding the “logs” tab (or similar) to view the log “events”.
### Sentry
To access Sentry, ensure you have been added to the MHCLG account.
Generally error logs in Sentry will also be present somewhere in the CloudWatch logs, but they will be easier to assess here (e.g. number of occurrences over a time period). The logs in Sentry are created by the application when it makes Rails.logger.error calls.
## Debugging
### Application infrastructure
For debugging / investigating infrastructure issues you can use the AWS CloudWatch automatic dashboards. (e.g. is there a lack of physical space on the database, how long has the ECS had very high compute usage for etc.)
They can be found in the CloudWatch service on AWS console, by going to dashboards → automatic dashboards, and selecting the desired dashboard (e.g. Elastic Container Service).
They can be found in the CloudWatch service on AWS console, by going to dashboards → automatic dashboards, and selecting the desired dashboard (e.g. Elastic Container Service).
Alternatively, you can also navigate to the AWS resource in question (e.g. RDS database), selecting the instance of interest, and selecting the “monitoring” / ”metrics” tab (or similar), as this can provide alternate useful information also.
### Exec into a container
You can open a terminal directly on a running container / app, in order to run some commands that may help with debugging an issue.
You can open a terminal directly on a running container / app, in order to run some commands that may help with debugging an issue.
To do this, you will need to “exec” into the container.
#### Prerequisites
- AWS CLI
- AWS Session manager plugin Install the Session Manager plugin for the AWS CLI - AWS Systems Manager
- AWS Session manager plugin Install the Session Manager plugin for the AWS CLI - AWS Systems Manager
- AWS access
#### Accessing the rails console
Prerequisite:
Configure AWS auth following the [documentation in the infra repo](https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data-infrastructure/blob/main/docs/development_setup.md). This also details how to enter a subshell with suitable AWS credentials.
@ -51,8 +61,9 @@ env=staging
taskArns=$(aws ecs list-tasks --cluster "core-$env-app" --query "taskArns[*]")
aws ecs describe-tasks --cluster "core-$env-app" --tasks "${taskArns[@]}" --query "tasks[*].{arn:taskArn, status:lastStatus, startedAt:startedAt, group:group, image:containers[0].image}" --output text
```
You can then use `aws ecs execute-command --cluster "core-$env-app" --task <taskid> --interactive --command <command>` to run the relevant command on a specific task.
You can then use `aws ecs execute-command --cluster "core-$env-app" --task <taskid> --interactive --command <command>` to run the relevant command on a specific task.
### Database
In order to investigate or look more closely at the database, you can exec into a container as above, and use the rails console to query the database.

238
docs/setup.md

@ -18,87 +18,104 @@ We recommend using [RBenv](https://github.com/rbenv/rbenv) to manage Ruby versio
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage NodeJS versions.
## Pre-setup installation
1. Install PostgreSQL
macOS:
macOS:
```bash
brew install postgresql
brew services start postgresql
```
```bash
brew install postgresql
brew services start postgresql
```
Linux (Debian):
Linux (Debian):
```bash
sudo apt install -y postgresql postgresql-contrib libpq-dev
sudo systemctl start postgresql
```
```bash
sudo apt install -y postgresql postgresql-contrib libpq-dev
sudo systemctl start postgresql
```
2. Create a Postgres user
```bash
sudo su - postgres -c "createuser <username> -s -P"
```
```bash
sudo su - postgres -c "createuser <username> -s -P"
```
3. Install RBenv and Ruby-build
macOS:
macOS:
```bash
brew install rbenv
rbenv init
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
```bash
brew install rbenv
rbenv init
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
Linux (Debian):
Linux (Debian):
```bash
sudo apt install -y rbenv git
rbenv init
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
```bash
sudo apt install -y rbenv git
rbenv init
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
mkdir -p ~/.rbenv/plugins
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
```
4. Install Ruby and Bundler
```bash
rbenv install 3.1.4
rbenv global 3.1.4
source ~/.bashrc
gem install bundler
```
```bash
rbenv install 3.1.4
rbenv global 3.1.4
source ~/.bashrc
gem install bundler
```
5. Install JavaScript dependencies
Note that we currently use node v16, which is no longer the latest LTS version so you will need to specify the version number when installing
macOS (using nvm):
macOS (using nvm):
```bash
nvm install 16
nvm use 16
brew install yarn
```
Linux (Debian):
Linux (Debian):
```bash
curl -sL https://deb.nodesource.com/setup_16.x | sudo bash -
sudo apt -y install nodejs
mkdir -p ~/.npm-packages
npm config set prefix ~/.npm-packages
echo 'NPM_PACKAGES="~/.npm-packages"' >> ~/.bashrc
echo 'export PATH="$PATH:$NPM_PACKAGES/bin"' >> ~/.bashrc
source ~/.bashrc
npm install --location=global yarn
```
6. (For running tests) Install Gecko Driver
Linux (Debian):
```bash
wget https://github.com/mozilla/geckodriver/releases/download/v0.31.0/geckodriver-v0.31.0-linux64.tar.gz
tar -xvzf geckodriver-v0.31.0-linux64.tar.gz
rm geckodriver-v0.31.0-linux64.tar.gz
chmod +x geckodriver
sudo mv geckodriver /usr/local/bin/
```
```bash
curl -sL https://deb.nodesource.com/setup_16.x | sudo bash -
sudo apt -y install nodejs
mkdir -p ~/.npm-packages
npm config set prefix ~/.npm-packages
echo 'NPM_PACKAGES="~/.npm-packages"' >> ~/.bashrc
echo 'export PATH="$PATH:$NPM_PACKAGES/bin"' >> ~/.bashrc
source ~/.bashrc
npm install --location=global yarn
```
Also ensure you have firefox installed
6. Clone the repo
7. Clone the repo
```bash
git clone https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data.git
```
```bash
git clone https://github.com/communitiesuk/submit-social-housing-lettings-and-sales-data.git
```
## Application setup
@ -106,99 +123,102 @@ We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage NodeJS version
2. Install the dependencies:
```bash
bundle install && yarn install
```
```bash
bundle install && yarn install
```
3. Create the database & run migrations:
```bash
bundle exec rake db:create db:migrate
```
```bash
bundle exec rake db:create db:migrate
```
4. Seed the database if required:
```bash
bundle exec rake db:seed
```
```bash
bundle exec rake db:seed
```
## Running Locally
### Application
Start the dev servers
5. Start the dev servers
a. Using Foreman:
a. Using Foreman:
```bash
./bin/dev
```
```bash
./bin/dev
```
b. Individually:
b. Individually:
Rails:
Rails:
```bash
bundle exec rails s
```
```bash
bundle exec rails s
```
JavaScript (for hot reloading):
JavaScript (for hot reloading):
```bash
yarn build --mode=development --watch
```
```bash
yarn build --mode=development --watch
```
If you’re not modifying front end assets you can bundle them as a one off task:
If you’re not modifying front end assets you can bundle them as a one off task:
```bash
yarn build --mode=development
```
```bash
yarn build --mode=development
```
Development mode will target the latest versions of Chrome, Firefox and Safari for transpilation while production mode will target older browsers.
Development mode will target the latest versions of Chrome, Firefox and Safari for transpilation while production mode will target older browsers.
The Rails server will start on <http://localhost:3000>.
The Rails server will start on <http://localhost:3000>.
### Tests
6. Install Gecko Driver
```bash
bundle exec rspec
```
Linux (Debian):
Or to run individual tests / files use your IDE
```bash
wget https://github.com/mozilla/geckodriver/releases/download/v0.31.0/geckodriver-v0.31.0-linux64.tar.gz
tar -xvzf geckodriver-v0.31.0-linux64.tar.gz
rm geckodriver-v0.31.0-linux64.tar.gz
chmod +x geckodriver
sudo mv geckodriver /usr/local/bin/
```
### Formatting
Running the test suite (front end assets need to be built or server needs to be running):
- `yarn prettier . --write` for scss, yml, md, and json files
- `yarn standard --fix` for js files
```bash
bundle exec rspec
```
Note that these tests assume you have firefox installed.
### Linting
```bash
bundle exec rake lint
```
## Using Docker
1. Build the image:
```bash
docker-compose build
```
```bash
docker-compose build
```
2. Run the database migrations:
```bash
docker-compose run --rm app /bin/bash -c 'rake db:migrate'
```
```bash
docker-compose run --rm app /bin/bash -c 'rake db:migrate'
```
3. Seed the database if required:
```bash
docker-compose run --rm app /bin/bash -c 'rake db:seed'
```
```bash
docker-compose run --rm app /bin/bash -c 'rake db:seed'
```
4. To be able to debug with Pry run the app using:
```bash
docker-compose run --service-ports app
```
```bash
docker-compose run --service-ports app
```
If this is not needed you can run `docker-compose up` as normal
@ -206,6 +226,6 @@ The Rails server will start on <http://localhost:8080>.
5. To run the test suite in docker:
```bash
docker-compose run --rm app /bin/bash -c ' RAILS_ENV=test rspec'
```
```bash
docker-compose run --rm app /bin/bash -c ' RAILS_ENV=test rspec'
```

17
docs/testing.md

@ -32,51 +32,64 @@ bundle exec rake parallel:setup
RAILS_ENV=test bundle exec rake parallel:spec
```
## Factories for Lettings Log, Sales Log, Organisation, and User
## Factories for Lettings Log, Sales Log, Organisation, and User
Each of these factories has nested relationships and callbacks that ensure associated objects are created and linked properly. For instance, creating a `lettings_log` involves creating or associating with a `user`, which in turn is linked to an `organisation`, potentially leading to creating `organisation_rent_periods` and a `data_protection_confirmation`.
This documentation outlines the objects that are created and/or persisted to the database when using FactoryBot to create or build models for LettingsLog, SalesLog, Organisation, and User. There are other factories, but they are simpler, less frequently used and don't have as much resource hierarchy.
### Lettings Log
Objects Created/Persisted:
- **User**: The `assigned_to` user is created.
- **Organisation**: The `assigned_to` user’s organisation created by `User` factory.
- **DataProtectionConfirmation**: If `organisation` does not have DSA signed, `DataProtectionConfirmation` gets created with `assigned_to` user as a `data_protection_officer`
- **OrganisationRentPeriod**: If `log.period` is present and the `managing_organisation` does not have an `OrganisationRentPeriod` for that period, a new `OrganisationRentPeriod` is created and associated with `managing_organisation`.
Example Usage:
```
let(:lettings_log) { create(:lettings_log) }
```
### Sales Log
Objects Created/Persisted:
- **User**: The `assigned_to` user is created.
- **Organisation**: The `assigned_to` user’s organisation created by `User` factory.
- **DataProtectionConfirmation**: If `organisation` does not have DSA signed, `DataProtectionConfirmation` gets created with `assigned_to` user as a `data_protection_officer`
Example Usage:
```
let(:sales_log) { create(:sales_log) }
```
### Organisation
Objects Created/Persisted:
- **OrganisationRentPeriod**: For each rent period in transient attribute `rent_periods`, an `OrganisationRentPeriod` is created.
- **DataProtectionConfirmation**: If `with_dsa` is `true` (default), a `DataProtectionConfirmation` is created with a `data_protection_officer`
- **User**: Data protection officer that signs the data protection confirmation
Example Usage:
```
let(:organisation) { create(:organisation, rent_periods: [1, 2])}
```
### User
Objects Created/Persisted:
- **Organisation**: User’s organisation.
- **DataProtectionConfirmation**: If `organisation` does not have DSA signed, `DataProtectionConfirmation` gets created with this user as a `data_protection_officer`
Example Usage:
```
let(:user) { create(:user) }
```
```

7
lib/tasks/lint.rake

@ -18,5 +18,10 @@ task stylelint: :environment do
sh "yarn stylelint app/frontend/styles"
end
desc "Run Prettier"
task prettier: :environment do
sh "yarn prettier . --check"
end
desc "Run all the linters"
task lint: %i[rubocop erblint standard stylelint]
task lint: %i[rubocop erblint standard stylelint prettier]

5
package.json

@ -36,9 +36,10 @@
"version": "0.1.0",
"devDependencies": {
"are-you-es5": "^2.1.2",
"prettier": "3.3.3",
"standard": "^17.0.0",
"stylelint": "^15.10.1",
"stylelint-config-gds": "^0.2.0"
"stylelint": "^16.8.2",
"stylelint-config-gds": "^2.0.0"
},
"browserslist": {
"production": [

4
spec/features/lettings_log_spec.rb

@ -89,9 +89,9 @@ RSpec.describe "Lettings Log Features" do
check("In progress")
choose("You")
choose("Specific owning organisation")
select(stock_owner_1.name, from: "owning_organisation")
fill_in("owning-organisation-text-search-field", with: "stock")
choose("Specific managing organisation")
select(managing_agent_1.name, from: "managing_organisation")
fill_in("managing-organisation-text-search-field", with: "managing")
click_button("Apply filters")
end

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

Loading…
Cancel
Save