diff --git a/app/models/recruitment_cycle_timetable.rb b/app/models/recruitment_cycle_timetable.rb new file mode 100644 index 00000000000..95826a8a707 --- /dev/null +++ b/app/models/recruitment_cycle_timetable.rb @@ -0,0 +1,80 @@ +class RecruitmentCycleTimetable < ApplicationRecord + validates :recruitment_cycle_year, + :find_opens_at, + :apply_opens_at, + :apply_deadline_at, + :reject_by_default_at, + :decline_by_default_at, + :find_closes_at, + presence: true + validates :recruitment_cycle_year, uniqueness: { allow_nil: false } + validate :sequential_dates + validate :christmas_holiday_validation + validate :easter_holiday_validation + +private + + def christmas_holiday_validation + return if [christmas_holiday_range, find_opens_at, find_closes_at].any?(&:blank?) + + holidays = Holidays.between( + christmas_holiday_range.first, + christmas_holiday_range.last, :gb + ).map do |holiday| + holiday[:name] + end + + if !christmas_holiday_range.in? cycle_range + errors.add(:christmas_holiday_range, :christmas_holiday_range_should_be_in_cycle) + elsif holidays.exclude? 'Christmas Day' + errors.add(:christmas_holiday_range, :christmas_holiday_range_should_include_christmas) + end + end + + def easter_holiday_validation + return if [easter_holiday_range, find_opens_at, find_closes_at].any?(&:blank?) + + holidays = Holidays.between( + easter_holiday_range.first, + easter_holiday_range.last, :gb + ).map do |holiday| + holiday[:name] + end + + if !easter_holiday_range.in? cycle_range + errors.add(:easter_holiday_range, :easter_holiday_range_should_be_within_cycle) + + elsif holidays.exclude?('Easter Sunday') + errors.add(:easter_holiday_range, :easter_holiday_range_should_include_easter) + end + end + + def cycle_range + find_opens_at..find_closes_at + end + + def sequential_dates + required_dates = [ + find_opens_at, + apply_opens_at, + apply_deadline_at, + reject_by_default_at, + decline_by_default_at, + find_closes_at, + ] + + return if required_dates.any?(&:blank?) + + if find_opens_at.after? apply_opens_at + errors.add(:apply_opens_at, :apply_opens_after_find_opens) + elsif apply_opens_at.after? apply_deadline_at + errors.add(:apply_deadline_at, :apply_deadline_after_apply_opens) + elsif apply_deadline_at.after? reject_by_default_at + errors.add(:reject_by_default_at, :reject_by_default_after_apply_deadline) + elsif reject_by_default_at.after? decline_by_default_at + errors.add(:decline_by_default_at, :decline_by_default_after_reject_by_default) + elsif decline_by_default_at.after? find_closes_at + errors.add(:find_closes_at, :find_closes_after_decline_by_default) + end + end +end diff --git a/config/analytics.yml b/config/analytics.yml index 1a7c2352cd9..a30d11036fd 100644 --- a/config/analytics.yml +++ b/config/analytics.yml @@ -519,3 +519,17 @@ shared: - created_at - updated_at - status + :recruitment_cycle_timetables: + - id + - find_opens_at + - apply_opens_at + - apply_deadline_at + - reject_by_default_at + - decline_by_default_at + - find_closes_at + - christmas_holiday_range + - easter_holiday_range + - recruitment_cycle_year + - created_at + - updated_at + diff --git a/config/locales/en.yml b/config/locales/en.yml index 27f20cb2967..a6983ff46a6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -551,6 +551,24 @@ en: pg_teaching_apprenticeship: Postgraduate teaching apprenticeship errors: models: + recruitment_cycle_timetable: + attributes: + apply_opens_at: + apply_opens_after_find_opens: Apply opens after find opens + apply_deadline_at: + apply_deadline_after_apply_opens: Apply deadline must be after apply opens + reject_by_default_at: + reject_by_default_after_apply_deadline: Reject by default must be after the apply deadline + decline_by_default_at: + decline_by_default_after_reject_by_default: Decline by default must be after reject by default + find_closes_at: + find_closes_after_decline_by_default: Find closes after decline by default + easter_holiday_range: + easter_holiday_range_should_be_within_cycle: Easter holiday range should be within cycle + easter_holiday_range_should_include_easter: Easter holiday range should include easter + christmas_holiday_range: + christmas_holiday_range_should_be_in_cycle: Christmas holiday range should be within cycle + christmas_holiday_range_should_include_christmas: Christmas holiday range should include Christmas day candidate: attributes: email_address: diff --git a/db/migrate/20250127161433_create_recruitment_cycle_timetable.rb b/db/migrate/20250127161433_create_recruitment_cycle_timetable.rb new file mode 100644 index 00000000000..611a51fbf87 --- /dev/null +++ b/db/migrate/20250127161433_create_recruitment_cycle_timetable.rb @@ -0,0 +1,18 @@ +class CreateRecruitmentCycleTimetable < ActiveRecord::Migration[8.0] + def change + create_table :recruitment_cycle_timetables do |t| + t.datetime :find_opens_at + t.datetime :apply_opens_at + t.datetime :apply_deadline_at + t.datetime :reject_by_default_at + t.datetime :decline_by_default_at + t.datetime :find_closes_at + t.daterange :christmas_holiday_range + t.daterange :easter_holiday_range + t.integer :recruitment_cycle_year + t.index [:recruitment_cycle_year], unique: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8b1bfbdbb34..34d27fcf954 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_23_164914) do +ActiveRecord::Schema[8.0].define(version: 2025_01_27_161433) do create_sequence "qualifications_public_id_seq", start: 120000 # These are extensions that must be enabled in order to support this database @@ -750,6 +750,21 @@ t.index ["vendor_id"], name: "index_providers_on_vendor_id" end + create_table "recruitment_cycle_timetables", force: :cascade do |t| + t.datetime "find_opens_at" + t.datetime "apply_opens_at" + t.datetime "apply_deadline_at" + t.datetime "reject_by_default_at" + t.datetime "decline_by_default_at" + t.datetime "find_closes_at" + t.daterange "christmas_holiday_range" + t.daterange "easter_holiday_range" + t.integer "recruitment_cycle_year" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["recruitment_cycle_year"], name: "index_recruitment_cycle_timetables_on_recruitment_cycle_year", unique: true + end + create_table "reference_tokens", force: :cascade do |t| t.bigint "application_reference_id", null: false t.string "hashed_token", null: false diff --git a/docs/domain-model.png b/docs/domain-model.png index fc698ff808a..ef0eea413e6 100644 Binary files a/docs/domain-model.png and b/docs/domain-model.png differ diff --git a/spec/factories/recruitment_cycle_timetable.rb b/spec/factories/recruitment_cycle_timetable.rb new file mode 100644 index 00000000000..50a6c3205c9 --- /dev/null +++ b/spec/factories/recruitment_cycle_timetable.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :recruitment_cycle_timetable do + recruitment_cycle_year { CycleTimetable.current_year } + + find_opens_at { CycleTimetable.find_opens } + apply_opens_at { CycleTimetable.apply_opens } + apply_deadline_at { CycleTimetable.apply_deadline } + reject_by_default_at { CycleTimetable.reject_by_default } + decline_by_default_at { CycleTimetable.decline_by_default_date } + find_closes_at { CycleTimetable.find_closes } + christmas_holiday_range { CycleTimetable.holidays[:christmas] } + easter_holiday_range { CycleTimetable.holidays[:easter] } + end +end diff --git a/spec/models/recruitment_cycle_timetable_spec.rb b/spec/models/recruitment_cycle_timetable_spec.rb new file mode 100644 index 00000000000..b01ee4f1fe6 --- /dev/null +++ b/spec/models/recruitment_cycle_timetable_spec.rb @@ -0,0 +1,79 @@ +require 'rails_helper' + +RSpec.describe RecruitmentCycleTimetable do + describe 'validations' do + it { is_expected.to validate_presence_of(:recruitment_cycle_year) } + it { is_expected.to validate_presence_of(:find_opens_at) } + it { is_expected.to validate_presence_of(:apply_opens_at) } + it { is_expected.to validate_presence_of(:apply_deadline_at) } + it { is_expected.to validate_presence_of(:reject_by_default_at) } + it { is_expected.to validate_presence_of(:decline_by_default_at) } + it { is_expected.to validate_presence_of(:find_closes_at) } + it { is_expected.to validate_uniqueness_of(:recruitment_cycle_year) } + + describe 'validates christmas range' do + it 'validates christmas range is within cycle' do + timetable = build(:recruitment_cycle_timetable) + timetable.christmas_holiday_range = timetable.find_opens_at - 3.days..timetable.christmas_holiday_range.last + + expect(timetable.valid?).to be false + expect(timetable.errors[:christmas_holiday_range]).to eq ['Christmas holiday range should be within cycle'] + end + + it 'validates christmas range includes christmas day' do + timetable = build(:recruitment_cycle_timetable) + timetable.christmas_holiday_range = + timetable.christmas_holiday_range.last..timetable.christmas_holiday_range.last + 10.days + + expect(timetable.valid?).to be false + expect(timetable.errors[:christmas_holiday_range]) + .to eq ['Christmas holiday range should include Christmas day'] + end + end + + describe 'validates easter range' do + it 'validates easter range is within cycle' do + timetable = build(:recruitment_cycle_timetable) + timetable.easter_holiday_range = timetable.easter_holiday_range.last..timetable.find_closes_at + 2.days + + expect(timetable.valid?).to be false + expect(timetable.errors[:easter_holiday_range]).to eq ['Easter holiday range should be within cycle'] + end + + it 'validates easter range includes Easter Day' do + timetable = build(:recruitment_cycle_timetable) + timetable.easter_holiday_range = + timetable.easter_holiday_range.last..timetable.easter_holiday_range.last + 10.days + + expect(timetable.valid?).to be false + expect(timetable.errors[:easter_holiday_range]).to eq ['Easter holiday range should include easter'] + end + end + + describe 'validates sequential order of dates' do + it 'validates apply opens after find opens' do + timetable = build(:recruitment_cycle_timetable) + timetable.find_opens_at = timetable.apply_opens_at + 1.day + + expect(timetable.valid?).to be false + expect(timetable.errors[:apply_opens_at]).to eq ['Apply opens after find opens'] + end + + it 'validates apply deadline is after apply opens' do + timetable = build(:recruitment_cycle_timetable) + timetable.apply_opens_at = timetable.apply_deadline_at + 1.day + + expect(timetable.valid?).to be false + expect(timetable.errors[:apply_deadline_at]).to eq ['Apply deadline must be after apply opens'] + end + + it 'validates reject by default is after apply deadline' do + timetable = build(:recruitment_cycle_timetable) + timetable.apply_deadline_at = timetable.reject_by_default_at + 1.day + + expect(timetable.valid?).to be false + expect(timetable.errors[:reject_by_default_at]).to eq ['Reject by default must be after the apply deadline'] + end + end + end +end