diff --git a/Dockerfile b/Dockerfile index b26aad246..a26c80ee9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN apk add --update --no-cache tzdata && \ # build-base: compilation tools for bundle # yarn: node package manager # postgresql-dev: postgres driver and libraries -RUN apk add --no-cache build-base=0.5-r3 busybox=1.36.1-r7 nodejs-current=20.8.1-r0 yarn=1.22.19-r0 postgresql13-dev=13.15-r0 git=2.40.1-r0 bash=5.2.15-r5 +RUN apk add --no-cache build-base=0.5-r3 busybox=1.36.1-r7 nodejs-current=20.8.1-r0 yarn=1.22.19-r0 postgresql13-dev=13.15-r0 git=2.40.3-r0 bash=5.2.15-r5 # Bundler version should be the same version as what the Gemfile.lock was bundled with RUN gem install bundler:2.3.14 --no-document diff --git a/app/controllers/schemes_controller.rb b/app/controllers/schemes_controller.rb index c0a36b920..1468bc013 100644 --- a/app/controllers/schemes_controller.rb +++ b/app/controllers/schemes_controller.rb @@ -51,17 +51,24 @@ class SchemesController < ApplicationController end def deactivate_confirm - @affected_logs = @scheme.lettings_logs.visible.after_date(params[:deactivation_date]) - if @affected_logs.count.zero? + @deactivation_date = Time.zone.parse(params[:deactivation_date]) + @affected_logs = @scheme.lettings_logs.visible.after_date(@deactivation_date) + @deactivation_date_type = params[:deactivation_date_type] + + scheme_locations = @scheme.locations.confirmed + + @affected_locations = scheme_locations.select do |location| + %i[active deactivating_soon reactivating_soon activating_soon].include?(location.status_at(@deactivation_date)) + end + + if @affected_logs.count.zero? && @affected_locations.count.zero? deactivate - else - @deactivation_date = params[:deactivation_date] - @deactivation_date_type = params[:deactivation_date_type] end end def deactivate - if @scheme.open_deactivation&.update!(deactivation_date: params[:deactivation_date]) || @scheme.scheme_deactivation_periods.create!(deactivation_date: params[:deactivation_date]) + deactivation_date = params[:deactivation_date] + if @scheme.open_deactivation&.update!(deactivation_date:) || @scheme.scheme_deactivation_periods.create!(deactivation_date:) logs = reset_location_and_scheme_for_logs! flash[:notice] = deactivate_success_notice diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c5c5241f9..91b0ccd40 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -56,20 +56,24 @@ class UsersController < ApplicationController def key_contact; end def edit - redirect_to user_path(@user) unless @user.active? + redirect_to user_path(@user) unless @user.active? || current_user.support? end def update validate_attributes - if @user.errors.empty? && @user.update(user_params) + if @user.errors.empty? && @user.update(user_params_without_org) if @user == current_user bypass_sign_in @user flash[:notice] = I18n.t("devise.passwords.updated") if user_params.key?("password") - if user_params.key?("email") + if user_params.key?("email") && user_params[:email] != @user.email flash[:notice] = I18n.t("devise.email.updated", email: @user.unconfirmed_email) end - redirect_to account_path + if updating_organisation? + redirect_to user_log_reassignment_path(@user, organisation_id: user_params[:organisation_id]) + else + redirect_to account_path + end else user_name = @user.name&.possessive || @user.email.possessive if user_params[:active] == "false" @@ -79,10 +83,15 @@ class UsersController < ApplicationController @user.reactivate! @user.send_confirmation_instructions flash[:notice] = I18n.t("devise.activation.reactivated", user_name:) - elsif user_params.key?("email") + elsif user_params.key?("email") && user_params[:email] != @user.email flash[:notice] = I18n.t("devise.email.updated", email: @user.unconfirmed_email) end - redirect_to user_path(@user) + + if updating_organisation? + redirect_to user_log_reassignment_path(@user, organisation_id: user_params[:organisation_id]) + else + redirect_to user_path(@user) + end end elsif user_params.key?("password") format_error_messages @@ -144,6 +153,58 @@ class UsersController < ApplicationController redirect_to users_organisation_path(@user.organisation), notice: I18n.t("notification.user_deleted", name: @user.name) end + def log_reassignment + authorize @user + assigned_to_logs_count = @user.assigned_to_lettings_logs.visible.count + @user.assigned_to_sales_logs.visible.count + return redirect_to user_organisation_change_confirmation_path(@user, organisation_id: params[:organisation_id]) if assigned_to_logs_count.zero? + + if params[:organisation_id].present? && Organisation.where(id: params[:organisation_id]).exists? + @new_organisation = Organisation.find(params[:organisation_id]) + else + redirect_to user_path(@user) + end + end + + def update_log_reassignment + authorize @user + return redirect_to user_path(@user) unless log_reassignment_params[:organisation_id].present? && Organisation.where(id: log_reassignment_params[:organisation_id]).exists? + + @new_organisation = Organisation.find(log_reassignment_params[:organisation_id]) + + validate_log_reassignment + + if @user.errors.empty? + redirect_to user_organisation_change_confirmation_path(@user, log_reassignment_params) + else + render :log_reassignment, status: :unprocessable_entity + end + end + + def organisation_change_confirmation + authorize @user + assigned_to_logs_count = @user.assigned_to_lettings_logs.visible.count + @user.assigned_to_sales_logs.visible.count + + return redirect_to user_path(@user) if params[:organisation_id].blank? || !Organisation.where(id: params[:organisation_id]).exists? + return redirect_to user_path(@user) if params[:log_reassignment].blank? && assigned_to_logs_count.positive? + + @new_organisation = Organisation.find(params[:organisation_id]) + @log_reassignment = params[:log_reassignment] + end + + def confirm_organisation_change + authorize @user + assigned_to_logs_count = @user.assigned_to_lettings_logs.visible.count + @user.assigned_to_sales_logs.visible.count + + return redirect_to user_path(@user) if log_reassignment_params[:organisation_id].blank? || !Organisation.where(id: log_reassignment_params[:organisation_id]).exists? + return redirect_to user_path(@user) if log_reassignment_params[:log_reassignment].blank? && assigned_to_logs_count.positive? + + @new_organisation = Organisation.find(log_reassignment_params[:organisation_id]) + @log_reassignment = log_reassignment_params[:log_reassignment] + @user.reassign_logs_and_update_organisation(@new_organisation, @log_reassignment) + + redirect_to user_path(@user) + end + private def validate_attributes @@ -157,6 +218,10 @@ private elsif !user_params[:phone].nil? && !valid_phone_number?(user_params[:phone]) @user.errors.add :phone end + + if user_params.key?(:organisation_id) && user_params[:organisation_id].blank? + @user.errors.add :organisation_id, :blank + end end def valid_phone_number?(number) @@ -191,8 +256,10 @@ private def user_params if @user == current_user - if current_user.data_coordinator? || current_user.support? + if current_user.data_coordinator? params.require(:user).permit(:email, :phone, :phone_extension, :name, :password, :password_confirmation, :role, :is_dpo, :is_key_contact, :initial_confirmation_sent) + elsif current_user.support? + params.require(:user).permit(:email, :phone, :phone_extension, :name, :password, :password_confirmation, :role, :is_dpo, :is_key_contact, :initial_confirmation_sent, :organisation_id) else params.require(:user).permit(:email, :phone, :phone_extension, :name, :password, :password_confirmation, :initial_confirmation_sent) end @@ -203,6 +270,14 @@ private end end + def user_params_without_org + user_params.except(:organisation_id) + end + + def log_reassignment_params + params.require(:user).permit(:log_reassignment, :organisation_id) + end + def created_user_redirect_path if current_user.support? users_path @@ -234,4 +309,35 @@ private def session_filters filter_manager.session_filters end + + def updating_organisation? + user_params["organisation_id"].present? && @user.organisation_id != user_params["organisation_id"].to_i + end + + def validate_log_reassignment + return @user.errors.add :log_reassignment, :blank if log_reassignment_params[:log_reassignment].blank? + + case log_reassignment_params[:log_reassignment] + when "reassign_stock_owner" + required_managing_agents = (@user.assigned_to_lettings_logs.visible.map(&:managing_organisation) + @user.assigned_to_sales_logs.visible.map(&:managing_organisation)).uniq + current_managing_agents = @new_organisation.managing_agents + missing_managing_agents = required_managing_agents - current_managing_agents + + if missing_managing_agents.any? + new_organisation = @new_organisation.name + missing_managing_agents = missing_managing_agents.map(&:name).sort.to_sentence + @user.errors.add :log_reassignment, I18n.t("activerecord.errors.models.user.attributes.log_reassignment.missing_managing_agents", new_organisation:, missing_managing_agents:) + end + when "reassign_managing_agent" + required_stock_owners = (@user.assigned_to_lettings_logs.visible.map(&:owning_organisation) + @user.assigned_to_sales_logs.visible.map(&:owning_organisation)).uniq + current_stock_owners = @new_organisation.stock_owners + missing_stock_owners = required_stock_owners - current_stock_owners + + if missing_stock_owners.any? + new_organisation = @new_organisation.name + missing_stock_owners = missing_stock_owners.map(&:name).sort.to_sentence + @user.errors.add :log_reassignment, I18n.t("activerecord.errors.models.user.attributes.log_reassignment.missing_stock_owners", new_organisation:, missing_stock_owners:) + end + end + end end diff --git a/app/helpers/deactivate_confirm_helper.rb b/app/helpers/deactivate_confirm_helper.rb new file mode 100644 index 000000000..a7e9edb19 --- /dev/null +++ b/app/helpers/deactivate_confirm_helper.rb @@ -0,0 +1,8 @@ +module DeactivateConfirmHelper + def affected_title(affected_logs, affected_locations) + title_parts = [] + title_parts << pluralize(affected_logs.count, "log") if affected_logs.count.positive? + title_parts << pluralize(affected_locations.count, "location") if affected_locations.count.positive? + "This change will affect #{title_parts.join(' and ')}." + end +end diff --git a/app/helpers/locations_helper.rb b/app/helpers/locations_helper.rb index f963c7040..fc1008926 100644 --- a/app/helpers/locations_helper.rb +++ b/app/helpers/locations_helper.rb @@ -70,7 +70,7 @@ module LocationsHelper def toggle_location_link(location) return govuk_button_link_to "Deactivate this location", scheme_location_new_deactivation_path(location.scheme, location), warning: true if location.active? || location.deactivates_in_a_long_time? - return govuk_button_link_to "Reactivate this location", scheme_location_new_reactivation_path(location.scheme, location) if location.deactivated? + return govuk_button_link_to "Reactivate this location", scheme_location_new_reactivation_path(location.scheme, location) if location.deactivated? && !location.deactivated_by_scheme? end def delete_location_link(location) @@ -100,14 +100,37 @@ private ActivePeriod = Struct.new(:from, :to) def location_active_periods(location) periods = [ActivePeriod.new(location.available_from, nil)] + location_deactivation_periods = location_deactivation_periods(location) + scheme_deactivation_periods = scheme_deactivation_periods(location, location_deactivation_periods) - sorted_deactivation_periods = remove_nested_periods(location.location_deactivation_periods.sort_by(&:deactivation_date)) + combined_deactivation_periods = location_deactivation_periods + scheme_deactivation_periods + sorted_deactivation_periods = combined_deactivation_periods.sort_by(&:deactivation_date) + + update_periods_with_deactivations(periods, sorted_deactivation_periods) + remove_overlapping_and_empty_periods(periods) + end + + def location_deactivation_periods(location) + periods = remove_nested_periods(location.location_deactivation_periods.sort_by(&:deactivation_date)) + periods.last&.deactivation_date if periods.last&.reactivation_date.nil? + periods + end + + def scheme_deactivation_periods(location, location_deactivation_periods) + return [] unless location.scheme.scheme_deactivation_periods.any? + + location_deactivation_date = location_deactivation_periods.last&.deactivation_date + periods = remove_nested_periods(location.scheme.scheme_deactivation_periods.sort_by(&:deactivation_date)) + periods.select do |period| + period.deactivation_date >= location.available_from && (location_deactivation_date.nil? || period.deactivation_date <= location_deactivation_date) + end + end + + def update_periods_with_deactivations(periods, sorted_deactivation_periods) sorted_deactivation_periods.each do |deactivation| periods.last.to = deactivation.deactivation_date periods << ActivePeriod.new(deactivation.reactivation_date, nil) end - - remove_overlapping_and_empty_periods(periods) end def remove_overlapping_and_empty_periods(periods) diff --git a/app/helpers/schemes_helper.rb b/app/helpers/schemes_helper.rb index f96f9e4c8..0e318d283 100644 --- a/app/helpers/schemes_helper.rb +++ b/app/helpers/schemes_helper.rb @@ -12,7 +12,7 @@ module SchemesHelper def toggle_scheme_link(scheme) return govuk_button_link_to "Deactivate this scheme", scheme_new_deactivation_path(scheme), warning: true if scheme.active? || scheme.deactivates_in_a_long_time? - return govuk_button_link_to "Reactivate this scheme", scheme_new_reactivation_path(scheme) if scheme.deactivated? + return govuk_button_link_to "Reactivate this scheme", scheme_new_reactivation_path(scheme) if scheme.deactivated? || scheme.deactivating_soon? end def delete_scheme_link(scheme) @@ -76,6 +76,15 @@ module SchemesHelper end end + def scheme_status_hint(scheme) + case scheme.status + when :deactivating_soon + "This scheme deactivates on #{scheme.last_deactivation_date.to_formatted_s(:govuk_date)}. Any locations you add will be deactivated on the same date. Reactivate the scheme to add locations active after this date." + when :deactivated + "This scheme deactivated on #{scheme.last_deactivation_date.to_formatted_s(:govuk_date)}. Any locations you add will be deactivated on the same date. Reactivate the scheme to add locations active after this date." + end + end + private ActivePeriod = Struct.new(:from, :to) diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb index 6fcf6ca4c..06fe2bc7d 100644 --- a/app/helpers/user_helper.rb +++ b/app/helpers/user_helper.rb @@ -14,4 +14,46 @@ module UserHelper def delete_user_link(user) govuk_button_link_to "Delete this user", delete_confirmation_user_path(user), warning: true end + + def organisation_change_warning(user, new_organisation) + logs_count = user.assigned_to_lettings_logs.count + user.assigned_to_sales_logs.count + logs_count_text = logs_count == 1 ? "is #{logs_count} log" : "are #{logs_count} logs" + + "You’re moving #{user.name} from #{user.organisation.name} to #{new_organisation.name}. There #{logs_count_text} assigned to them." + end + + def organisation_change_confirmation_warning(user, new_organisation, log_reassignment) + log_reassignment_text = "There are no logs assigned to them." + + logs_count = user.assigned_to_lettings_logs.count + user.assigned_to_sales_logs.count + if logs_count.positive? + case log_reassignment + when "reassign_all" + log_reassignment_text = "The stock owner and managing agent on their logs will change to #{new_organisation.name}." + when "reassign_stock_owner" + log_reassignment_text = "The stock owner on their logs will change to #{new_organisation.name}." + when "reassign_managing_agent" + log_reassignment_text = "The managing agent on their logs will change to #{new_organisation.name}." + when "unassign" + log_reassignment_text = "Their logs will be unassigned." + end + end + + "You’re moving #{user.name} from #{user.organisation.name} to #{new_organisation.name}. #{log_reassignment_text}" + end + + def remove_attributes_from_error_messages(user) + modified_errors = [] + + user.errors.each do |error| + cleaned_message = error.type.gsub(error.attribute.to_s.humanize, "").strip + modified_errors << [error.attribute, cleaned_message] + end + + user.errors.clear + + modified_errors.each do |attribute, message| + user.errors.add(attribute, message) + end + end end diff --git a/app/models/bulk_upload.rb b/app/models/bulk_upload.rb index 7af43ee28..69ab42871 100644 --- a/app/models/bulk_upload.rb +++ b/app/models/bulk_upload.rb @@ -101,6 +101,10 @@ class BulkUpload < ApplicationRecord logs.filter_by_status("in_progress").map(&:missing_answers_count).sum(0) end + def moved_user_name + User.find_by(id: moved_user_id)&.name + end + private def generate_identifier diff --git a/app/models/forms/bulk_upload_lettings_resume/fix_choice.rb b/app/models/forms/bulk_upload_lettings_resume/fix_choice.rb index a97d6e3b9..5ee4d37fd 100644 --- a/app/models/forms/bulk_upload_lettings_resume/fix_choice.rb +++ b/app/models/forms/bulk_upload_lettings_resume/fix_choice.rb @@ -56,7 +56,7 @@ module Forms end def preflight_valid? - bulk_upload.choice != "create-fix-inline" && bulk_upload.choice != "bulk-confirm-soft-validations" + bulk_upload.choice.blank? end def preflight_redirect @@ -65,6 +65,8 @@ module Forms page_bulk_upload_lettings_resume_path(bulk_upload, :chosen) when "bulk-confirm-soft-validations" page_bulk_upload_lettings_soft_validations_check_path(bulk_upload, :chosen) + else + bulk_upload_lettings_result_path(bulk_upload) end end end diff --git a/app/models/forms/bulk_upload_sales_resume/fix_choice.rb b/app/models/forms/bulk_upload_sales_resume/fix_choice.rb index 0cd2ae0f5..b34f50d3a 100644 --- a/app/models/forms/bulk_upload_sales_resume/fix_choice.rb +++ b/app/models/forms/bulk_upload_sales_resume/fix_choice.rb @@ -56,7 +56,7 @@ module Forms end def preflight_valid? - bulk_upload.choice != "create-fix-inline" && bulk_upload.choice != "bulk-confirm-soft-validations" + bulk_upload.choice.blank? end def preflight_redirect @@ -65,6 +65,8 @@ module Forms page_bulk_upload_sales_resume_path(bulk_upload, :chosen) when "bulk-confirm-soft-validations" page_bulk_upload_sales_soft_validations_check_path(bulk_upload, :chosen) + else + bulk_upload_sales_result_path(bulk_upload) end end end diff --git a/app/models/location.rb b/app/models/location.rb index 8efa4ee28..19cf5e211 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -40,6 +40,7 @@ class Location < ApplicationRecord filtered_records = filtered_records .left_outer_joins(:location_deactivation_periods) .joins(scheme: [:owning_organisation]) + .left_outer_joins(scheme: :scheme_deactivation_periods) .order("location_deactivation_periods.created_at DESC") .merge(scopes.reduce(&:or)) end @@ -52,30 +53,47 @@ class Location < ApplicationRecord .or(where(confirmed: nil)) } - scope :deactivated, lambda { + scope :deactivated, lambda { |date = Time.zone.now| deactivated_by_organisation - .or(deactivated_directly) + .or(deactivated_directly(date)) + .or(deactivated_by_scheme(date)) } scope :deactivated_by_organisation, lambda { merge(Organisation.filter_by_inactive) } + scope :deactivated_by_scheme, lambda { |date = Time.zone.now| + merge(Scheme.deactivated_directly(date)) + } + scope :deactivated_directly, lambda { |date = Time.zone.now| merge(LocationDeactivationPeriod.deactivations_without_reactivation) .where("location_deactivation_periods.deactivation_date <= ?", date) } - scope :deactivating_soon, lambda { |date = Time.zone.now| + scope :deactivating_soon_directly, lambda { |date = Time.zone.now| merge(LocationDeactivationPeriod.deactivations_without_reactivation) - .where("location_deactivation_periods.deactivation_date > ?", date) - .where.not(id: joins(scheme: [:owning_organisation]).deactivated_by_organisation.pluck(:id)) + .where("location_deactivation_periods.deactivation_date > ?", date) + } + + scope :deactivating_soon, lambda { |date = Time.zone.now| + deactivating_soon_directly + .or(deactivating_soon_by_scheme(date)) + } + + scope :deactivating_soon_by_scheme, lambda { |date = Time.zone.now| + merge(Scheme.deactivating_soon(date)) } scope :reactivating_soon, lambda { |date = Time.zone.now| where.not("location_deactivation_periods.reactivation_date IS NULL") - .where("location_deactivation_periods.reactivation_date > ?", date) - .where.not(id: joins(scheme: [:owning_organisation]).deactivated_by_organisation.pluck(:id)) + .where("location_deactivation_periods.reactivation_date > ?", date) + .where.not(id: joins(scheme: [:owning_organisation]).deactivated_by_organisation.pluck(:id)) + } + + scope :reactivating_soon_by_scheme, lambda { |date = Time.zone.now| + merge(Scheme.reactivating_soon(date)) } scope :activating_soon, lambda { |date = Time.zone.now| @@ -84,19 +102,21 @@ class Location < ApplicationRecord scope :active_status, lambda { where.not(id: joins(:location_deactivation_periods).reactivating_soon.pluck(:id)) - .where.not(id: joins(scheme: [:owning_organisation]).deactivated_by_organisation.pluck(:id)) - .where.not(id: joins(:location_deactivation_periods).deactivated_directly.pluck(:id)) - .where.not(id: incomplete.pluck(:id)) - .where.not(id: joins(:location_deactivation_periods).deactivating_soon.pluck(:id)) - .where.not(id: activating_soon.pluck(:id)) + .where.not(id: joins(scheme: [:scheme_deactivation_periods]).reactivating_soon_by_scheme.pluck(:id)) + .where.not(id: joins(:location_deactivation_periods).merge(Location.deactivated_directly).pluck(:id)) + .where.not(id: joins(scheme: [:scheme_deactivation_periods]).merge(Location.deactivated_by_scheme).pluck(:id)) + .where.not(id: joins(scheme: [:owning_organisation]).merge(Location.deactivated_by_organisation).pluck(:id)) + .where.not(id: incomplete.pluck(:id)) + .where.not(id: joins(:location_deactivation_periods).merge(Location.deactivating_soon_directly).pluck(:id)) + .where.not(id: joins(scheme: %i[owning_organisation scheme_deactivation_periods]).merge(Location.deactivating_soon_by_scheme).pluck(:id)) + .where.not(id: activating_soon.pluck(:id)) } scope :active, lambda { |date = Time.zone.now| - where.not(id: joins(:location_deactivation_periods).reactivating_soon(date).pluck(:id)) - .where.not(id: joins(scheme: [:owning_organisation]).deactivated_by_organisation.pluck(:id)) - .where.not(id: joins(:location_deactivation_periods).deactivated_directly(date).pluck(:id)) - .where.not(id: incomplete.pluck(:id)) + where.not(id: joins(:location_deactivation_periods).merge(Location.deactivated_directly(date)).pluck(:id)) + .where.not(id: incomplete.pluck(:id)) .where.not(id: activating_soon(date).pluck(:id)) + .where(scheme: Scheme.active(date)) } scope :visible, -> { where(discarded_at: nil) } @@ -163,10 +183,10 @@ class Location < ApplicationRecord return :deleted if discarded_at.present? return :incomplete unless confirmed return :deactivated if scheme.owning_organisation.status_at(date) == :deactivated || - open_deactivation&.deactivation_date.present? && date >= open_deactivation.deactivation_date + open_deactivation&.deactivation_date.present? && date >= open_deactivation.deactivation_date || scheme.status_at(date) == :deactivated + return :deactivating_soon if open_deactivation&.deactivation_date.present? && date < open_deactivation.deactivation_date || scheme.status_at(date) == :deactivating_soon return :activating_soon if startdate.present? && date < startdate - return :deactivating_soon if open_deactivation&.deactivation_date.present? && date < open_deactivation.deactivation_date - return :reactivating_soon if last_deactivation_before(date)&.reactivation_date.present? && date < last_deactivation_before(date).reactivation_date + return :reactivating_soon if last_deactivation_before(date)&.reactivation_date.present? && date < last_deactivation_before(date).reactivation_date || scheme.status_at(date) == :reactivating_soon :active end @@ -187,6 +207,18 @@ class Location < ApplicationRecord status_at(6.months.from_now) == :deactivating_soon end + def deactivated_by_scheme? + status == :deactivated && scheme.status == :deactivated + end + + def deactivating_soon_by_scheme? + status == :deactivating_soon && scheme.status == :deactivating_soon + end + + def reactivating_soon_by_scheme? + status == :reactivating_soon && scheme.status == :reactivating_soon + end + def validate_postcode if !postcode&.match(POSTCODE_REGEXP) error_message = I18n.t("validations.postcode") diff --git a/app/models/scheme.rb b/app/models/scheme.rb index 07ec14731..6d3524723 100644 --- a/app/models/scheme.rb +++ b/app/models/scheme.rb @@ -61,25 +61,27 @@ class Scheme < ApplicationRecord merge(Organisation.filter_by_inactive) } - scope :deactivated_directly, lambda { + scope :deactivated_directly, lambda { |date = Time.zone.now| merge(SchemeDeactivationPeriod.deactivations_without_reactivation) - .where("scheme_deactivation_periods.deactivation_date <= ?", Time.zone.now) + .where("scheme_deactivation_periods.deactivation_date <= ?", date) } - scope :deactivating_soon, lambda { + scope :deactivating_soon, lambda { |date = Time.zone.now| merge(SchemeDeactivationPeriod.deactivations_without_reactivation) - .where("scheme_deactivation_periods.deactivation_date > ? AND scheme_deactivation_periods.deactivation_date < ? ", Time.zone.now, 6.months.from_now) + .where("scheme_deactivation_periods.deactivation_date > ? AND scheme_deactivation_periods.deactivation_date < ? ", date, 6.months.from_now) .where.not(id: joins(:owning_organisation).deactivated_by_organisation.pluck(:id)) } - scope :reactivating_soon, lambda { - where.not("scheme_deactivation_periods.reactivation_date IS NULL") - .where("scheme_deactivation_periods.reactivation_date > ?", Time.zone.now) - .where.not(id: joins(:owning_organisation).deactivated_by_organisation.pluck(:id)) + scope :reactivating_soon, lambda { |date = Time.zone.now| + merge(SchemeDeactivationPeriod.deactivations_with_reactivation) + .where.not("scheme_deactivation_periods.reactivation_date IS NULL") + .where("scheme_deactivation_periods.reactivation_date > ?", date) + .where("scheme_deactivation_periods.deactivation_date <= ?", date) + .where.not(id: joins(:owning_organisation).deactivated_by_organisation.pluck(:id)) } - scope :activating_soon, lambda { - where("schemes.startdate > ?", Time.zone.now) + scope :activating_soon, lambda { |date = Time.zone.now| + where("schemes.startdate > ?", date) } scope :active_status, lambda { @@ -91,6 +93,14 @@ class Scheme < ApplicationRecord .where.not(id: activating_soon.pluck(:id)) } + scope :active, lambda { |date = Time.zone.now| + where.not(id: joins(:scheme_deactivation_periods).reactivating_soon(date).pluck(:id)) + .where.not(id: incomplete.pluck(:id)) + .where.not(id: joins(:owning_organisation).deactivated_by_organisation.pluck(:id)) + .where.not(id: joins(:owning_organisation).joins(:scheme_deactivation_periods).deactivated_directly(date).pluck(:id)) + .where.not(id: activating_soon(date).pluck(:id)) + } + scope :visible, -> { where(discarded_at: nil) } validate :validate_confirmed @@ -275,6 +285,10 @@ class Scheme < ApplicationRecord scheme_deactivation_periods.where("deactivation_date <= ?", date).order("created_at").last end + def last_deactivation_date + scheme_deactivation_periods.order(deactivation_date: :desc).first&.deactivation_date + end + def status @status ||= status_at(Time.zone.now) end @@ -313,6 +327,10 @@ class Scheme < ApplicationRecord status == :deactivated end + def deactivating_soon? + status == :deactivating_soon + end + def deactivates_in_a_long_time? status_at(6.months.from_now) == :deactivating_soon end diff --git a/app/models/scheme_deactivation_period.rb b/app/models/scheme_deactivation_period.rb index 176d15211..cb27534f7 100644 --- a/app/models/scheme_deactivation_period.rb +++ b/app/models/scheme_deactivation_period.rb @@ -48,4 +48,5 @@ class SchemeDeactivationPeriod < ApplicationRecord attr_accessor :deactivation_date_type, :reactivation_date_type scope :deactivations_without_reactivation, -> { where(reactivation_date: nil) } + scope :deactivations_with_reactivation, -> { where.not(reactivation_date: nil) } end diff --git a/app/models/user.rb b/app/models/user.rb index 31956e54d..0a26a254b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -49,6 +49,13 @@ class User < ApplicationRecord support: 99, }.freeze + LOG_REASSIGNMENT = { + reassign_all: "Yes, change the stock owner and the managing agent", + reassign_stock_owner: "Yes, change the stock owner but keep the managing agent the same", + reassign_managing_agent: "Yes, change the managing agent but keep the stock owner the same", + unassign: "No, unassign the logs", + }.freeze + enum role: ROLES scope :search_by_name, ->(name) { where("users.name ILIKE ?", "%#{name}%") } @@ -80,6 +87,8 @@ class User < ApplicationRecord scope :visible, -> { where(discarded_at: nil) } scope :own_and_managing_org_users, ->(organisation) { where(organisation: organisation.child_organisations + [organisation]) } + attr_accessor :log_reassignment + def lettings_logs if support? LettingsLog.all @@ -161,6 +170,7 @@ class User < ApplicationRecord USER_REACTIVATED_TEMPLATE_ID = "ac45a899-490e-4f59-ae8d-1256fc0001f9".freeze FOR_OLD_EMAIL_CHANGED_BY_OTHER_USER_TEMPLATE_ID = "3eb80517-1051-4dfc-b4cc-cb18228a3829".freeze FOR_NEW_EMAIL_CHANGED_BY_OTHER_USER_TEMPLATE_ID = "0cdd0be1-7fa5-4808-8225-ae4c5a002352".freeze + ORGANISATION_UPDATE_TEMPLATE_ID = "4b7716c0-cc5c-41dd-92e4-a0dff03bdf5e".freeze def reset_password_notify_template RESET_PASSWORD_TEMPLATE_ID @@ -270,6 +280,48 @@ class User < ApplicationRecord "#{phone}, Ext. #{phone_extension}" end + def assigned_to_lettings_logs + lettings_logs.where(assigned_to: self) + end + + def assigned_to_sales_logs + sales_logs.where(assigned_to: self) + end + + def reassign_logs_and_update_organisation(new_organisation, log_reassignment) + return unless new_organisation + + ActiveRecord::Base.transaction do + lettings_logs_to_reassign = assigned_to_lettings_logs.visible + sales_logs_to_reassign = assigned_to_sales_logs.visible + current_organisation = organisation + + logs_count = lettings_logs_to_reassign.count + sales_logs_to_reassign.count + return if logs_count.positive? && (log_reassignment.blank? || !LOG_REASSIGNMENT.key?(log_reassignment.to_sym)) + + update!(organisation: new_organisation) + + case log_reassignment + when "reassign_all" + reassign_all_orgs(new_organisation, lettings_logs_to_reassign, sales_logs_to_reassign) + when "reassign_stock_owner" + reassign_stock_owners(new_organisation, lettings_logs_to_reassign, sales_logs_to_reassign) + when "reassign_managing_agent" + reassign_managing_agents(new_organisation, lettings_logs_to_reassign, sales_logs_to_reassign) + when "unassign" + unassign_organisations(lettings_logs_to_reassign, sales_logs_to_reassign, current_organisation) + end + + cancel_related_bulk_uploads + send_organisation_change_email(current_organisation, new_organisation, log_reassignment, logs_count) + rescue StandardError => e + Rails.logger.error("User update failed with: #{e.message}") + Sentry.capture_exception(e) + + raise ActiveRecord::Rollback + end + end + protected # Checks whether a password is needed or not. For validations only. @@ -294,4 +346,70 @@ private DataProtectionConfirmationMailer.send_confirmation_email(self).deliver_later end + + def reassign_all_orgs(new_organisation, lettings_logs_to_reassign, sales_logs_to_reassign) + lettings_logs_to_reassign.update_all(owning_organisation_id: new_organisation.id, managing_organisation_id: new_organisation.id, values_updated_at: Time.zone.now) + sales_logs_to_reassign.update_all(owning_organisation_id: new_organisation.id, managing_organisation_id: new_organisation.id, values_updated_at: Time.zone.now) + end + + def reassign_stock_owners(new_organisation, lettings_logs_to_reassign, sales_logs_to_reassign) + lettings_logs_to_reassign.update_all(owning_organisation_id: new_organisation.id, values_updated_at: Time.zone.now) + sales_logs_to_reassign.update_all(owning_organisation_id: new_organisation.id, values_updated_at: Time.zone.now) + end + + def reassign_managing_agents(new_organisation, lettings_logs_to_reassign, sales_logs_to_reassign) + lettings_logs_to_reassign.update_all(managing_organisation_id: new_organisation.id, values_updated_at: Time.zone.now) + sales_logs_to_reassign.update_all(managing_organisation_id: new_organisation.id, values_updated_at: Time.zone.now) + end + + def unassign_organisations(lettings_logs_to_reassign, sales_logs_to_reassign, current_organisation) + if User.find_by(name: "Unassigned", organisation: current_organisation) + unassigned_user = User.find_by(name: "Unassigned", organisation: current_organisation) + else + unassigned_user = User.new( + name: "Unassigned", + organisation_id:, + is_dpo: false, + encrypted_password: SecureRandom.hex(10), + email: SecureRandom.uuid, + confirmed_at: Time.zone.now, + active: false, + ) + unassigned_user.save!(validate: false) + end + lettings_logs_to_reassign.update_all(assigned_to_id: unassigned_user.id, values_updated_at: Time.zone.now) + sales_logs_to_reassign.update_all(assigned_to_id: unassigned_user.id, values_updated_at: Time.zone.now) + end + + def send_organisation_change_email(current_organisation, new_organisation, log_reassignment, logs_count) + reassigned_logs_text = "" + assigned_logs_count = logs_count == 1 ? "is 1 log" : "are #{logs_count} logs" + + case log_reassignment + when "reassign_all" + reassigned_logs_text = "There #{assigned_logs_count} assigned to you. The stock owner and managing agent on #{logs_count == 1 ? 'this log' : 'these logs'} has been changed from #{current_organisation.name} to #{new_organisation.name}." + when "reassign_stock_owner" + reassigned_logs_text = "There #{assigned_logs_count} assigned to you. The stock owner on #{logs_count == 1 ? 'this log' : 'these logs'} has been changed from #{current_organisation.name} to #{new_organisation.name}." + when "reassign_managing_agent" + reassigned_logs_text = "There #{assigned_logs_count} assigned to you. The managing agent on #{logs_count == 1 ? 'this log' : 'these logs'} has been changed from #{current_organisation.name} to #{new_organisation.name}." + when "unassign" + reassigned_logs_text = "There #{assigned_logs_count} assigned to you. #{logs_count == 1 ? 'This' : 'These'} have now been unassigned." + end + + template_id = ORGANISATION_UPDATE_TEMPLATE_ID + personalisation = { + from_organisation: "#{current_organisation.name} (Organisation ID: #{current_organisation.id})", + to_organisation: "#{new_organisation.name} (Organisation ID: #{new_organisation.id})", + reassigned_logs_text:, + } + DeviseNotifyMailer.new.send_email(email, template_id, personalisation) + end + + def cancel_related_bulk_uploads + lettings_bu_ids = LettingsLog.where(assigned_to: self, status: "pending").map(&:bulk_upload_id).compact.uniq + BulkUpload.where(id: lettings_bu_ids).update!(choice: "cancelled-by-moved-user", moved_user_id: id) + + sales_bu_ids = SalesLog.where(assigned_to: self, status: "pending").map(&:bulk_upload_id).compact.uniq + BulkUpload.where(id: sales_bu_ids).update!(choice: "cancelled-by-moved-user", moved_user_id: id) + end end diff --git a/app/models/validations/sales/sale_information_validations.rb b/app/models/validations/sales/sale_information_validations.rb index 8bfa46783..c5febb693 100644 --- a/app/models/validations/sales/sale_information_validations.rb +++ b/app/models/validations/sales/sale_information_validations.rb @@ -299,7 +299,7 @@ module Validations::Sales::SaleInformationValidations return unless record.mortgage if over_tolerance?(record.mortgage_and_deposit_total, record.stairbought_part_of_value, 1) - %i[mortgage value deposit stairbought type].each do |field| + %i[mortgage value deposit stairbought].each do |field| 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"), @@ -315,7 +315,7 @@ module Validations::Sales::SaleInformationValidations 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| + %i[mortgageused value deposit stairbought].each do |field| 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"), diff --git a/app/models/validations/shared_validations.rb b/app/models/validations/shared_validations.rb index ab9f9d7a8..217b2c170 100644 --- a/app/models/validations/shared_validations.rb +++ b/app/models/validations/shared_validations.rb @@ -102,7 +102,11 @@ module Validations::SharedValidations return unless %i[reactivating_soon activating_soon deactivated].include?(status) closest_reactivation = resource.last_deactivation_before(date) - open_deactivation = resource.open_deactivation + open_deactivation = if resource.is_a?(Location) + resource.open_deactivation || resource.scheme.open_deactivation + else + resource.open_deactivation + end date = case status when :reactivating_soon then closest_reactivation.reactivation_date diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index ff7d871f2..e45614df7 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -10,17 +10,15 @@ class UserPolicy @current_user == @user end - def edit_roles? - (@current_user.data_coordinator? || @current_user.support?) && @user.active? - end - %w[ edit_roles? edit_dpo? edit_key_contact? ].each do |method_name| define_method method_name do - (@current_user.data_coordinator? || @current_user.support?) && @user.active? + return true if @current_user.support? + + @current_user.data_coordinator? && @user.active? end end @@ -30,7 +28,9 @@ class UserPolicy edit_names? ].each do |method_name| define_method method_name do - (@current_user == @user || @current_user.data_coordinator? || @current_user.support?) && @user.active? + return true if @current_user.support? + + (@current_user == @user || @current_user.data_coordinator?) && @user.active? end end @@ -45,6 +45,18 @@ class UserPolicy !has_any_logs_in_editable_collection_period && !has_signed_data_protection_agreement? end + %w[ + edit_organisation? + log_reassignment? + update_log_reassignment? + organisation_change_confirmation? + confirm_organisation_change? + ].each do |method_name| + define_method method_name do + @current_user.support? + end + end + private def has_any_logs_in_editable_collection_period diff --git a/app/services/bulk_upload/lettings/year2024/csv_parser.rb b/app/services/bulk_upload/lettings/year2024/csv_parser.rb index 061f3bbbd..d8d430755 100644 --- a/app/services/bulk_upload/lettings/year2024/csv_parser.rb +++ b/app/services/bulk_upload/lettings/year2024/csv_parser.rb @@ -30,12 +30,15 @@ class BulkUpload::Lettings::Year2024::CsvParser end def row_parsers - @row_parsers ||= body_rows.map do |row| + @row_parsers ||= body_rows.map { |row| + next if row.empty? + stripped_row = row[col_offset..] + hash = Hash[field_numbers.zip(stripped_row)] BulkUpload::Lettings::Year2024::RowParser.new(hash) - end + }.compact end def body_rows diff --git a/app/services/bulk_upload/lettings/year2024/row_parser.rb b/app/services/bulk_upload/lettings/year2024/row_parser.rb index 913e5d9e5..8fc913055 100644 --- a/app/services/bulk_upload/lettings/year2024/row_parser.rb +++ b/app/services/bulk_upload/lettings/year2024/row_parser.rb @@ -513,6 +513,8 @@ class BulkUpload::Lettings::Year2024::RowParser end def log_already_exists? + return false if blank_row? + @log_already_exists ||= LettingsLog .where(status: %w[not_started in_progress completed]) .exists?(duplicate_check_fields.index_with { |field| log.public_send(field) }) diff --git a/app/services/bulk_upload/sales/year2024/csv_parser.rb b/app/services/bulk_upload/sales/year2024/csv_parser.rb index 2dc9d38a1..9ba99c19b 100644 --- a/app/services/bulk_upload/sales/year2024/csv_parser.rb +++ b/app/services/bulk_upload/sales/year2024/csv_parser.rb @@ -30,12 +30,14 @@ class BulkUpload::Sales::Year2024::CsvParser end def row_parsers - @row_parsers ||= body_rows.map do |row| + @row_parsers ||= body_rows.map { |row| + next if row.empty? + stripped_row = row[col_offset..] hash = Hash[field_numbers.zip(stripped_row)] BulkUpload::Sales::Year2024::RowParser.new(hash) - end + }.compact end def body_rows diff --git a/app/services/bulk_upload/sales/year2024/row_parser.rb b/app/services/bulk_upload/sales/year2024/row_parser.rb index d39715f30..8be08d62f 100644 --- a/app/services/bulk_upload/sales/year2024/row_parser.rb +++ b/app/services/bulk_upload/sales/year2024/row_parser.rb @@ -539,6 +539,8 @@ class BulkUpload::Sales::Year2024::RowParser end def log_already_exists? + return false if blank_row? + @log_already_exists ||= SalesLog .where(status: %w[not_started in_progress completed]) .exists?(duplicate_check_fields.index_with { |field| log.public_send(field) }) diff --git a/app/views/bulk_upload_lettings_results/show.html.erb b/app/views/bulk_upload_lettings_results/show.html.erb index 56448f24e..30a6fd585 100644 --- a/app/views/bulk_upload_lettings_results/show.html.erb +++ b/app/views/bulk_upload_lettings_results/show.html.erb @@ -2,6 +2,8 @@ <%= govuk_back_link(href: :back) %> <% end %> +<%= render partial: "bulk_upload_shared/moved_user_banner" %> +
+ <% if current_user.id == @bulk_upload.moved_user_id %> + You moved to a different organisation since this file was uploaded. Reupload the file to get an accurate error report. + <% else %> + Some logs in this upload are assigned to <%= @bulk_upload.moved_user_name %>, who has moved to a different organisation since this file was uploaded. Reupload the file to get an accurate error report. + <% end %> + <% end %> +<% end %> diff --git a/app/views/bulk_upload_shared/guidance.html.erb b/app/views/bulk_upload_shared/guidance.html.erb index c32295b13..e530aa5b5 100644 --- a/app/views/bulk_upload_shared/guidance.html.erb +++ b/app/views/bulk_upload_shared/guidance.html.erb @@ -81,8 +81,8 @@ <%= accordion.with_section(heading_text: "Next steps") do %>
Once you've saved your CSV file, you can upload it via a button at the top of the lettings and sales logs pages.
When your file is done processing, you will receive an email explaining your next steps. If all your data is valid, your logs will be created. If some data is invalid, you’ll receive an email with instructions about how to resolve the errors.
-If your file has errors on fields 1 through 17, you must fix these in the CSV. This is because we need to know these answers to validate the rest of the data. Any errors in these fields will be featured in the error report’s summary tab.
-If none of your errors are in fields 1 through 17, you can choose how to fix the errors. You can either fix them in the CSV and reupload, or create partially complete logs and answer the remaining questions on the CORE site. Any errors that affect a significant number of logs will be featured in the error report’s summary tab to help you decide.
+If your file has errors on fields 1 through 15 for lettings, or 1 through 18 for sales, you must fix these in the CSV. This is because we need to know these answers to validate the rest of the data. Any errors in these fields will be featured in the error report’s summary tab.
+If none of your errors are in fields 1 through 15 for lettings, or 1 through 18 for sales, you can choose how to fix the errors. You can either fix them in the CSV and reupload, or create partially complete logs and answer the remaining questions on the CORE site. Any errors that affect a significant number of logs will be featured in the error report’s summary tab to help you decide.
<% end %> diff --git a/app/views/locations/index.html.erb b/app/views/locations/index.html.erb index 78a362332..64d9bf286 100644 --- a/app/views/locations/index.html.erb +++ b/app/views/locations/index.html.erb @@ -56,10 +56,17 @@ <% end %> <% end %> + <% if status_hint_message = scheme_status_hint(@scheme) %> +