Repository: DFE-Digital/register-trainee-teachers
Pull request #5261: [8463] - SPIKE Determine how best to update complex error messages for API and CSV bulk add new trainees
Author: login: Reyko
Labels: name: deploy
Created: 2025-05-01T13:54:51Z | Merged: 2025-05-12T10:00:37Z

### Context

[8463-spike-determine-how-best-to-update-complex-error-messages-for-api-and-csv-bulk-add-new-trainees](https://trello.com/c/XvWTisFU/8463-spike-determine-how-best-to-update-complex-error-messages-for-api-and-csv-bulk-add-new-trainees)

### Changes proposed in this pull request

* Refactor Api::ErrorAttributeAdapter
* Replicate form validations to Attributes classes
* Add different message values based on record_source api/csv

### Guidance to review

### Important business

- [ ] Does this PR introduce any PII fields that need to be overwritten or deleted in db/scripts/sanitise.sql?
- [ ] Does this PR change the database schema? If so, have you updated the config/analytics.yml file and considered whether you need to send 'import_entity' events?
- [ ] Do we need to send any updates to DQT as part of the work in this PR?
- [ ] Does this PR need an ADR?

NB: Please notify the #twd_data_insights team and ask for a review if new fields are being added to analytics.yml



Diff:
diff --git a/app/models/api/error_attribute_adapter.rb b/app/models/api/error_attribute_adapter.rb
index fce1de59a9..ae6d15d375 100644
--- a/app/models/api/error_attribute_adapter.rb
+++ b/app/models/api/error_attribute_adapter.rb
@@ -15,9 +15,15 @@ def self.included(base)
 
     NESTED_API_ATTRIBUTE_NAMES = {
       hesa_trainee_detail_attributes: "",
+      degrees_attributes: "",
       trainee_disabilities_attributes: "disabilities",
     }.freeze
 
+    EXCLUDE_ATTRIBUTE_NAMES = {
+      course_subject_two: %i[duplicate],
+      course_subject_three: %i[duplicate],
+    }.freeze
+
     module ClassMethods
       def attribute_mappings
         self.csv_field_mappings ||= begin
@@ -36,5 +42,34 @@ def human_attribute_name(attr, options = {})
         end || super
       end
     end
+
+    def errors
+      model = self
+
+      super.tap do |err|
+        err.define_singleton_method(:add) do |attribute, type = :invalid, **options|
+          translation_key = "activemodel.errors.models.#{model.class.name.underscore}.attributes.#{attribute}.#{model.record_source}.#{type}"
+
+          record_source_type = if I18n.exists?(translation_key)
+                                 :"#{model.record_source}.#{type}"
+                               else
+                                 type
+                               end
+          super(attribute, record_source_type, **options)
+        end
+
+        err.define_singleton_method(:full_messages) do
+          errors.map do |error|
+            mapped_error = EXCLUDE_ATTRIBUTE_NAMES[error.attribute]
+
+            if mapped_error.present? && error.type.in?(mapped_error)
+              error.message
+            else
+              error.full_message
+            end
+          end
+        end
+      end
+    end
   end
 end
diff --git a/app/models/api/v1_0_pre/trainee_attributes.rb b/app/models/api/v1_0_pre/trainee_attributes.rb
index 474eaa9d1b..e574f1bf90 100644
--- a/app/models/api/v1_0_pre/trainee_attributes.rb
+++ b/app/models/api/v1_0_pre/trainee_attributes.rb
@@ -5,6 +5,10 @@ module V10Pre
     class TraineeAttributes < Api::V01::TraineeAttributes
       include Api::ErrorAttributeAdapter
 
+      validate :course_subject_two_valid, if: :require_subject?
+      validate :course_subject_three_valid, if: :require_subject?
+      validate :itt_end_date_valid
+
       def build_nested_models(new_attributes)
         new_attributes[:placements_attributes]&.each do |placement_params|
           placements_attributes << PlacementAttributes.new(placement_params, record_source:)
@@ -26,6 +30,33 @@ def build_nested_models(new_attributes)
           )
         end
       end
+
+    private
+
+      def require_subject?
+        EARLY_YEARS_TRAINING_ROUTES.exclude?(training_route)
+      end
+
+      def course_subject_two_valid
+        return if course_subject_two.nil?
+
+        errors.add(:course_subject_two, :duplicate) if course_subject_one == course_subject_two
+      end
+
+      def course_subject_three_valid
+        return if course_subject_three.nil?
+
+        errors.add(:course_subject_three, :duplicate) if [course_subject_one, course_subject_two].include?(course_subject_three)
+      end
+
+      def itt_end_date_valid
+        return if !valid_date_string?(itt_start_date) || !valid_date_string?(itt_end_date)
+
+        parsed_itt_start_date = itt_start_date.is_a?(String) ? Date.iso8601(itt_start_date) : itt_start_date
+        parsed_itt_end_date   = itt_end_date.is_a?(String) ? Date.iso8601(itt_end_date) : itt_end_date
+
+        errors.add(:itt_end_date, :before_or_same_as_start_date) if parsed_itt_start_date >= parsed_itt_end_date
+      end
     end
   end
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2d87bd1fd0..fee16d97fc 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2089,6 +2089,17 @@ en:
               inclusion: *invalid_reference_data
             training_initiative:
               inclusion: *invalid_reference_data
+        api/v10_pre/trainee_attributes:
+          attributes:
+            itt_end_date:
+              api:
+                before_or_same_as_start_date: must be after itt_start_date
+              csv:
+                before_or_same_as_start_date: must be after ITT start date
+            course_subject_two:
+              duplicate: &duplicate_course_subjects Course subject fields contain duplicate values
+            course_subject_three:
+              duplicate: *duplicate_course_subjects
         api/v01/hesa_trainee_detail_attributes:
           attributes:
             course_age_range:
@@ -2125,6 +2136,10 @@ en:
               inclusion: *invalid_reference_data
             uk_degree:
               inclusion: *invalid_reference_data
+        api/v10_pre/degree_attributes:
+          attributes:
+            uk_degree:
+              blank: must be entered if specifying a previous UK degree
         api/create_trainee:
           duplicate: This trainee is already in Register
         api/trainees/award_recommendation_service:
diff --git a/spec/fixtures/files/bulk_update/trainee_uploads/failed_with_two_failed.csv b/spec/fixtures/files/bulk_update/trainee_uploads/failed_with_two_failed.csv
index 43502d2735..1f0b54f157 100644
--- a/spec/fixtures/files/bulk_update/trainee_uploads/failed_with_two_failed.csv
+++ b/spec/fixtures/files/bulk_update/trainee_uploads/failed_with_two_failed.csv
@@ -4,5 +4,6 @@ Provider Trainee ID,Application ID,HESA ID,First Names,Last Name,Previous Last N
 Training Route has invalid reference data values;
 Trainee disabilities attributes contain duplicate values"
 24/25-82,,5205630693043,Abel,Hills,Halvorson,2002-01-07,,10,Abel.Hills@example.com,GB,,,,,,,,,,,202,11,032,100358,,,01,2024-10-01,2025-07-14,13911,2024,,,2024-10-01,2024-08-13,2,4,,,,,,,,,,,,,Validation passed,
-24/25-85,,7982579851416,Richie,Welch,Christiansen,1993-04-19,,99,Richie.Welch@example.com,GB,,,,,,,,,,,202,13,020,100302,,,01,2024-10-01,2025-07-03,13915,2024,,,2024-10-01,2024-08-16,2,4,,,,,,,,,,,,,1 error found,Training Route has invalid reference data values
+24/25-85,,7982579851416,Richie,Welch,Christiansen,1993-04-19,,99,Richie.Welch@example.com,GB,,,,,,,,,,,202,13,020,100302,,,01,2024-10-01,2023-07-03,13915,2024,,,2024-10-01,2024-08-16,2,4,,,,,,,,,,,,,2 errors found,"Training Route has invalid reference data values;
+ITT End Date must be after ITT start date"
 24/25-88,,1423427395203,Scott,Hermiston,Beier,1999-03-10,,10,Scott.Hermiston@example.com,GB,,,,,,,,,,,202,11,021,100891,,,64,2024-10-01,2025-07-15,13911,2024,,,2024-10-01,2024-08-28,2,4,,,,,,,,,,,,,Validation passed,
diff --git a/spec/fixtures/files/bulk_update/trainee_uploads/five_trainees_with_two_failed.csv b/spec/fixtures/files/bulk_update/trainee_uploads/five_trainees_with_two_failed.csv
index cce7300a24..9e6d5fb761 100644
--- a/spec/fixtures/files/bulk_update/trainee_uploads/five_trainees_with_two_failed.csv
+++ b/spec/fixtures/files/bulk_update/trainee_uploads/five_trainees_with_two_failed.csv
@@ -2,5 +2,5 @@ Provider Trainee ID,Application ID,HESA ID,First Names,Last Name,Previous Last N
 24/25-76,,7722480827494,Aundrea,Kub,Bruen,2002-04-03,,11,Aundrea.Kub@example.com,GB,,,,,,,,,,,202,11,021,100409,,,31,2024-10-01,2026-07-26,13915,2024,,,2024-10-01,2024-08-10,2,4,,,,,,,,,,,,
 24/25-79,,1996761576407,,Wintheiser,Roob,1999-04-19,,11,Bess.Wintheiser@example.com,GB,,58,58,,,,,,,,201,13,008,100366,,,31,2024-10-01,2026-07-12,13915,2024,,,2024-10-01,2024-08-16,2,4,,,,,,,,,,,,
 24/25-82,,5205630693043,Abel,Hills,Halvorson,2002-01-07,,10,Abel.Hills@example.com,GB,,,,,,,,,,,202,11,032,100358,,,01,2024-10-01,2025-07-14,13911,2024,,,2024-10-01,2024-08-13,2,4,,,,,,,,,,,,
-24/25-85,,7982579851416,Richie,Welch,Christiansen,1993-04-19,,99,Richie.Welch@example.com,GB,,,,,,,,,,,202,13,020,100302,,,01,2024-10-01,2025-07-03,13915,2024,,,2024-10-01,2024-08-16,2,4,,,,,,,,,,,,
+24/25-85,,7982579851416,Richie,Welch,Christiansen,1993-04-19,,99,Richie.Welch@example.com,GB,,,,,,,,,,,202,13,020,100302,,,01,2024-10-01,2023-07-03,13915,2024,,,2024-10-01,2024-08-16,2,4,,,,,,,,,,,,
 24/25-88,,1423427395203,Scott,Hermiston,Beier,1999-03-10,,10,Scott.Hermiston@example.com,GB,,,,,,,,,,,202,11,021,100891,,,64,2024-10-01,2025-07-15,13911,2024,,,2024-10-01,2024-08-28,2,4,,,,,,,,,,,,
diff --git a/spec/requests/api/v1_0_pre/post_hesa_trainees_spec.rb b/spec/requests/api/v1_0_pre/post_hesa_trainees_spec.rb
index 597160f982..53d67d2744 100644
--- a/spec/requests/api/v1_0_pre/post_hesa_trainees_spec.rb
+++ b/spec/requests/api/v1_0_pre/post_hesa_trainees_spec.rb
@@ -400,6 +400,19 @@
       end
     end
 
+    context "when `itt_end_date` is before itt_start_date" do
+      let(:itt_end_date) { "2022-01-30" }
+
+      before do
+        post endpoint, params: params.to_json, headers: { Authorization: token, **json_headers }
+      end
+
+      it "does not create a trainee record and returns a 422 status with meaningful error message" do
+        expect(response).to have_http_status(:unprocessable_entity)
+        expect(response.parsed_body["errors"]).to include("itt_end_date must be after itt_start_date")
+      end
+    end
+
     context "when graduation_year is in 'yyyy-mm-dd' format" do
       let(:graduation_year) { "2003-01-01" }
 
@@ -572,6 +585,47 @@
     end
 
     context "with course subjects" do
+      context "when there course_subject_two is a duplicate" do
+        before do
+          post endpoint, params: params.to_json, headers: { Authorization: token, **json_headers }
+        end
+
+        let(:params) do
+          {
+            data: data.merge(
+              course_subject_one: Hesa::CodeSets::CourseSubjects::MAPPING.invert[CourseSubjects::BIOLOGY],
+              course_subject_two: Hesa::CodeSets::CourseSubjects::MAPPING.invert[CourseSubjects::BIOLOGY],
+            ),
+          }
+        end
+
+        it do
+          expect(response).to have_http_status(:unprocessable_entity)
+          expect(response.parsed_body[:errors]).to contain_exactly("Course subject fields contain duplicate values")
+        end
+      end
+
+      context "when there course_subject_three is a duplicate" do
+        before do
+          post endpoint, params: params.to_json, headers: { Authorization: token, **json_headers }
+        end
+
+        let(:params) do
+          {
+            data: data.merge(
+              course_subject_one: Hesa::CodeSets::CourseSubjects::MAPPING.invert[CourseSubjects::PRIMARY_TEACHING],
+              course_subject_two: Hesa::CodeSets::CourseSubjects::MAPPING.invert[CourseSubjects::BIOLOGY],
+              course_subject_three: Hesa::CodeSets::CourseSubjects::MAPPING.invert[CourseSubjects::BIOLOGY],
+            ),
+          }
+        end
+
+        it do
+          expect(response).to have_http_status(:unprocessable_entity)
+          expect(response.parsed_body[:errors]).to contain_exactly("Course subject fields contain duplicate values")
+        end
+      end
+
       context "when HasCourseAttributes#primary_education_phase? is true" do
         let(:course_age_range) { "13914" }
 
@@ -1032,11 +1086,25 @@
       it "return status code 422 with a meaningful error message" do
         expect(response).to have_http_status(:unprocessable_entity)
         expect(response.parsed_body["message"]).to include("Validation failed: 1 error prohibited this trainee from being saved")
-        expect(response.parsed_body["errors"]).to include("degrees_attributes uk_degree has invalid reference data values")
+        expect(response.parsed_body["errors"]).to include("uk_degree has invalid reference data values")
       end
     end
   end
 
+  context "when a degree is missing" do
+    before do
+      params[:data][:degrees_attributes].first.delete(:uk_degree)
+
+      post endpoint, params: params.to_json, headers: { Authorization: token, **json_headers }
+    end
+
+    it "return status code 422 with a meaningful error message" do
+      expect(response).to have_http_status(:unprocessable_entity)
+      expect(response.parsed_body["message"]).to include("Validation failed: 1 error prohibited this trainee from being saved")
+      expect(response.parsed_body["errors"]).to include("uk_degree must be entered if specifying a previous UK degree")
+    end
+  end
+
   context "with a fund_code is ineligible for funding" do
     before do
       params[:data][:training_route] = Hesa::CodeSets::TrainingRoutes::MAPPING.invert[TRAINING_ROUTE_ENUMS[:teacher_degree_apprenticeship]]
diff --git a/spec/requests/api/v1_0_pre/put_trainee_spec.rb b/spec/requests/api/v1_0_pre/put_trainee_spec.rb
index 9a44ea0a08..85a781607f 100644
--- a/spec/requests/api/v1_0_pre/put_trainee_spec.rb
+++ b/spec/requests/api/v1_0_pre/put_trainee_spec.rb
@@ -379,7 +379,7 @@
 
       expect(response).to have_http_status(:unprocessable_entity)
       expect(response.parsed_body).to have_key("errors")
-      expect(response.parsed_body[:errors]).to contain_exactly(["course_details", { "itt_end_date" => ["The Expected end date must be after the start date"] }])
+      expect(response.parsed_body[:errors]).to contain_exactly("itt_end_date must be after itt_start_date")
       expect(trainee.reload.itt_start_date).to eq(original_itt_start_date)
       expect(trainee.reload.itt_end_date).to eq(original_itt_end_date)
     end


Reviews:
user: login: stevehook
body: This looks good to me. It's a tidy solution for providing different messages for different record sources (and only when necessary) and moving away from the dependency on the various form objects will really help I think.

Would be good to know what others think too.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T15:52:39Z
user: login: Reyko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T08:09:35Z
user: login: Reyko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T08:21:16Z
user: login: tomtrentham
body: This approach looks sensible. Couple of questions on the specifics.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T12:07:07Z
user: login: Reyko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:31:03Z
user: login: tomtrentham
body: This is good. Couple of thoughts on error messages but those probably need product review now.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T08:47:19Z
user: login: Reyko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T08:50:25Z

Review comments:
diff_hunk: @@ -36,5 +42,34 @@ def human_attribute_name(attr, options = {})
         end || super
       end
     end
+
+    def errors
+      model = self
+
+      super.tap do |err|
+        err.define_singleton_method(:add) do |attribute, type = :invalid, **options|
+          translation_key = "activemodel.errors.models.#{model.class.name.underscore}.attributes.#{attribute}.#{model.record_source}.#{type}"
+
+          record_source_type = if I18n.exists?(translation_key)
+                                 :"#{model.record_source}.#{type}"
+                               else
+                                 type
+                               end
+          super(attribute, record_source_type, **options)
+        end
+
+        err.define_singleton_method(:full_messages) do
+          errors.map do |error|
path: app/models/api/error_attribute_adapter.rb
user: login: stevehook
body: Not sure but should this be a call to `super`?
created_at: 2025-05-02T15:40:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,6 +30,30 @@ def build_nested_models(new_attributes)
           )
         end
       end
+
+    private
+
+      def require_subject?
+        EARLY_YEARS_TRAINING_ROUTES.exclude?(training_route)
+      end
+
+      def course_subject_two_valid
+        return if course_subject_two.nil?
+
+        errors.add(:course_subject_two, :duplicate) if course_subject_one == course_subject_two && course_subject_three.nil?
+      end
+
+      def course_subject_three_valid
+        return if course_subject_three.nil?
+
+        errors.add(:course_subject_three, :duplicate) if [course_subject_one, course_subject_two].include?(course_subject_three)
+      end
+
+      def itt_end_date_valid
+        return if itt_start_date.nil? && itt_end_date.nil?
+
+        errors.add(:itt_end_date, :before_or_same_as_start_date) if itt_start_date.to_s >= itt_end_date.to_s
path: app/models/api/v1_0_pre/trainee_attributes.rb
user: login: stevehook
body: I guess the detail doesn't matter here but curious about why we need to convert to strings and what happens when one value is nil
created_at: 2025-05-02T15:45:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,9 +15,15 @@ def self.included(base)
 
     NESTED_API_ATTRIBUTE_NAMES = {
       hesa_trainee_detail_attributes: "",
+      degrees_attributes: "",
       trainee_disabilities_attributes: "disabilities",
     }.freeze
 
+    NO_ATTRIBUTE_NAMES = {
path: app/models/api/error_attribute_adapter.rb
user: login: stevehook
body: Not sure what this should be called but the name didn't help me work out what it does!
created_at: 2025-05-02T15:48:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,5 +42,34 @@ def human_attribute_name(attr, options = {})
         end || super
       end
     end
+
+    def errors
+      model = self
+
+      super.tap do |err|
+        err.define_singleton_method(:add) do |attribute, type = :invalid, **options|
+          translation_key = "activemodel.errors.models.#{model.class.name.underscore}.attributes.#{attribute}.#{model.record_source}.#{type}"
+
+          record_source_type = if I18n.exists?(translation_key)
+                                 :"#{model.record_source}.#{type}"
+                               else
+                                 type
+                               end
+          super(attribute, record_source_type, **options)
+        end
+
+        err.define_singleton_method(:full_messages) do
+          errors.map do |error|
path: app/models/api/error_attribute_adapter.rb
user: login: Reyko
body: Unfortunately it is not possible to call `super` inside `define_singleton_method`
created_at: 2025-05-06T08:09:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,6 +30,30 @@ def build_nested_models(new_attributes)
           )
         end
       end
+
+    private
+
+      def require_subject?
+        EARLY_YEARS_TRAINING_ROUTES.exclude?(training_route)
+      end
+
+      def course_subject_two_valid
+        return if course_subject_two.nil?
+
+        errors.add(:course_subject_two, :duplicate) if course_subject_one == course_subject_two && course_subject_three.nil?
+      end
+
+      def course_subject_three_valid
+        return if course_subject_three.nil?
+
+        errors.add(:course_subject_three, :duplicate) if [course_subject_one, course_subject_two].include?(course_subject_three)
+      end
+
+      def itt_end_date_valid
+        return if itt_start_date.nil? && itt_end_date.nil?
+
+        errors.add(:itt_end_date, :before_or_same_as_start_date) if itt_start_date.to_s >= itt_end_date.to_s
path: app/models/api/v1_0_pre/trainee_attributes.rb
user: login: Reyko
body: On POST trainees those are `Strings` while on PUT trainees, when reading an existing value it will be a `Date`. If one value is `nil` it will return `false`.
created_at: 2025-05-06T08:21:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,9 +15,15 @@ def self.included(base)
 
     NESTED_API_ATTRIBUTE_NAMES = {
       hesa_trainee_detail_attributes: "",
+      degrees_attributes: "",
       trainee_disabilities_attributes: "disabilities",
     }.freeze
 
+    NO_ATTRIBUTE_NAMES = {
path: app/models/api/error_attribute_adapter.rb
user: login: tomtrentham
body: How about `EXCLUDE_ATTRIBUTE_NAMES` ?
created_at: 2025-05-06T11:38:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,6 +30,30 @@ def build_nested_models(new_attributes)
           )
         end
       end
+
+    private
+
+      def require_subject?
+        EARLY_YEARS_TRAINING_ROUTES.exclude?(training_route)
+      end
+
+      def course_subject_two_valid
+        return if course_subject_two.nil?
+
+        errors.add(:course_subject_two, :duplicate) if course_subject_one == course_subject_two && course_subject_three.nil?
path: app/models/api/v1_0_pre/trainee_attributes.rb
user: login: tomtrentham
body: Could this check be bypassed if `course_subject_three` was present?
created_at: 2025-05-06T11:54:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,6 +30,30 @@ def build_nested_models(new_attributes)
           )
         end
       end
+
+    private
+
+      def require_subject?
+        EARLY_YEARS_TRAINING_ROUTES.exclude?(training_route)
+      end
+
+      def course_subject_two_valid
+        return if course_subject_two.nil?
+
+        errors.add(:course_subject_two, :duplicate) if course_subject_one == course_subject_two && course_subject_three.nil?
+      end
+
+      def course_subject_three_valid
+        return if course_subject_three.nil?
+
+        errors.add(:course_subject_three, :duplicate) if [course_subject_one, course_subject_two].include?(course_subject_three)
+      end
+
+      def itt_end_date_valid
+        return if itt_start_date.nil? && itt_end_date.nil?
+
+        errors.add(:itt_end_date, :before_or_same_as_start_date) if itt_start_date.to_s >= itt_end_date.to_s
path: app/models/api/v1_0_pre/trainee_attributes.rb
user: login: tomtrentham
body: It does feel slightly odd doing a string comparison. Could we convert both to dates and iso8601 formats so we know we're doing a date comparison?
created_at: 2025-05-06T12:02:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,6 +30,30 @@ def build_nested_models(new_attributes)
           )
         end
       end
+
+    private
+
+      def require_subject?
+        EARLY_YEARS_TRAINING_ROUTES.exclude?(training_route)
+      end
+
+      def course_subject_two_valid
+        return if course_subject_two.nil?
+
+        errors.add(:course_subject_two, :duplicate) if course_subject_one == course_subject_two && course_subject_three.nil?
+      end
+
+      def course_subject_three_valid
+        return if course_subject_three.nil?
+
+        errors.add(:course_subject_three, :duplicate) if [course_subject_one, course_subject_two].include?(course_subject_three)
+      end
+
+      def itt_end_date_valid
+        return if itt_start_date.nil? && itt_end_date.nil?
+
+        errors.add(:itt_end_date, :before_or_same_as_start_date) if itt_start_date.to_s >= itt_end_date.to_s
path: app/models/api/v1_0_pre/trainee_attributes.rb
user: login: Reyko
body: Refactored 5cb0fc164c2a166175eca1c586989b4ae3f03210
created_at: 2025-05-07T14:31:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2089,6 +2091,17 @@ en:
               inclusion: *invalid_reference_data
             training_initiative:
               inclusion: *invalid_reference_data
+        api/v10_pre/trainee_attributes:
+          attributes:
+            itt_end_date:
+              api:
+                before_or_same_as_start_date: must be after itt_start_date
+              csv:
+                before_or_same_as_start_date: must be after ITT start date
+            course_subject_two:
+              duplicate: &duplicate_course_subjects Course subject attributes contain duplicate values
path: config/locales/en.yml
user: login: tomtrentham
body: Maybe use 'fields' rather than 'attributes' to match terminology elsewhere?
```suggestion
              duplicate: &duplicate_course_subjects Course subject fields contain duplicate values
```
created_at: 2025-05-08T08:39:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2071,6 +2071,8 @@ en:
               blank: Enter a comment
         api/v01/trainee_attributes:
           attributes:
+            date_of_birth:
+              future: enter a date that is in the past, for example 31 3 1980
path: config/locales/en.yml
user: login: tomtrentham
body: Might be good to use iso8601 format for the example
```suggestion
              future: enter a date that is in the past, for example "1980-03-31"
```
created_at: 2025-05-08T08:43:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,6 +30,30 @@ def build_nested_models(new_attributes)
           )
         end
       end
+
+    private
+
+      def require_subject?
+        EARLY_YEARS_TRAINING_ROUTES.exclude?(training_route)
+      end
+
+      def course_subject_two_valid
+        return if course_subject_two.nil?
+
+        errors.add(:course_subject_two, :duplicate) if course_subject_one == course_subject_two && course_subject_three.nil?
path: app/models/api/v1_0_pre/trainee_attributes.rb
user: login: Reyko
body: Yes you are right. I added this because we modify the course subjects by injecting `primary teaching` https://github.com/DFE-Digital/register-trainee-teachers/blob/main/app/services/concerns/primary_course_subjects.rb#L6

Refactored 6d3d3ca7ff1eb3b47dcb55d449f467d481f9deb6
created_at: 2025-05-08T08:50:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-02T08:34:57Z
body: ## [Codecov](https://app.codecov.io/gh/DFE-Digital/register-trainee-teachers/pull/5261?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.51%. Comparing base [(`322ee1b`)](https://app.codecov.io/gh/DFE-Digital/register-trainee-teachers/commit/322ee1bab1a1869f23b45431fab94d755bb3b762?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital) to head [(`409dbad`)](https://app.codecov.io/gh/DFE-Digital/register-trainee-teachers/commit/409dbadafcafeffd3ad3ceac8176eab00023001d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital).
> Report is 10 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #5261   +/-   ##
=======================================
  Coverage   96.51%   96.51%           
=======================================
  Files         797      797           
  Lines       17086    17119   +33     
=======================================
+ Hits        16490    16523   +33     
  Misses        596      596           
```

| [Flag](https://app.codecov.io/gh/DFE-Digital/register-trainee-teachers/pull/5261/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/DFE-Digital/register-trainee-teachers/pull/5261/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital) | `96.51% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/DFE-Digital/register-trainee-teachers/pull/5261?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DFE-Digital).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-02T09:29:46Z
body: Review app deployed to https://register-pr-5261.test.teacherservices.cloud
<!-- Sticky Pull Request Commentaks -->
author_association: CONTRIBUTOR
user: login: tomtrentham
created_at: 2025-05-07T08:20:22Z
body: ![](https://github.trello.services/images/mini-trello-icon.png) [[SPIKE] Determine how best to update complex error messages for API and CSV bulk add new trainees](https://trello.com/c/XvWTisFU/8463-spike-determine-how-best-to-update-complex-error-messages-for-api-and-csv-bulk-add-new-trainees)
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-08T10:43:22Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DFE-Digital_register-trainee-teacher-data&pullRequest=5261) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=DFE-Digital_register-trainee-teacher-data&pullRequest=5261&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DFE-Digital_register-trainee-teacher-data&pullRequest=5261&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DFE-Digital_register-trainee-teacher-data&pullRequest=5261&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DFE-Digital_register-trainee-teacher-data&pullRequest=5261&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DFE-Digital_register-trainee-teacher-data&pullRequest=5261&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=DFE-Digital_register-trainee-teacher-data&pullRequest=5261)


author_association: NONE