Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix duplicate subject validation on secondary course selection #4224

Conversation

inulty-dfe
Copy link
Contributor

@inulty-dfe inulty-dfe commented May 22, 2024

Context

The primary concern of this PR is to validate the Second level courses' subject inputs.

  1. Validate duplicate Secondary Subject inputs
  2. Validate missing master subject, even when subordinate subject supplied

When choosing two subjects to associate with a Second level course, the validation preventing a provider from choosing the same subject twice was not working.

When the validation was fixed, it was noticed that the form fields do not maintain the values that the user entered.

Changes proposed in this pull request && Guidance to review

Add subordinate_subject_id attribute to Course

The form sends master_subject_id, subordinate_subject_id and subject_ids but does not process subordinate_subject_id.

This is because master_subject_id is a persisted column on the schema, and subordinate_subject_id is not even an attribute.

I've added subordinate_subject_id as an ActiveModel attribute on the Course so we can work with it explicitly.

This requires an after_initialize hook on the model to populate the property from the associated subjects.

To understand how this happens, you need to understand all about Subjects

But the long story short is that we can populate the form subordinate_subject_id with this attribute.

"master_subject_id"=>"24", "subjects_ids"=>["24", "24"]

Differences between New and Edit

The Course controllers work significantly differently between creating a new course and editing and existing course. The two process share some of the same modules however.

New Course

- SubjectsController#continue
- CourseBasicDetailConcern#build_new_course
- ::Courses::CreationService.call
- AssignSubjectsService

The problem here is that this service is called on every step of the course creation service with accumulating parameters from the view which need to be maintained over requests, including when using the back button. This makes it very messy and tricky to manage state.

The subject parameters are passed to AssignSubjectsService.
Not only is this service called on every step of the create path, it is also called during the editing path.

The params passed to it are an array of subject_ids

The subject_ids are built from the parameters in the URL for the new path.

How the subject_ids are built and managed is explained:

Select master dropdown and blank subordinate
#=> subject_ids [1]
Select subordinate dropdown and blank master
#=> subject_ids [2]

Click back button after selecting only the suboridinate input, and the form populates the master input with the values supplied to the subordinate input previously.

If modern languages is one of the subject ids, the next page is multiple select of languages. These language ids need to be manages across pages in the URL like the original subject ids.

The subject ids array can have more than 3 elements.

The current implementation PREPENDS the language ids to this list of subject ids in the URL.

This means the master id could be the first, second or third+ position in the subject_ids array.

Edit Course

- SubjectsController#continue
- CourseSubjectsForm#build_new_course
- AssignSubjectsService

AssignSubjectsService

This service receives an array of subject_ids

The subject_ids could contain

[master, subordinate]
[subordinate]
[languages_id_1, master, subordinate]
[languages_id_1, languages_id_2, master, subordinate]

It's hard to work with such a variable set of values. It's better if the position of the values is relatively consistent.

The Course Creation process unshifts language ids onto the subject_ids array. I've changed it so they are pushed onto the end now.

The secondary_subject_ids are passed to the AssignSubjectsService without compacting away the missing values.

[master, subordinate, languages_id_1]
[master, subordinate, languages_id_1, languages_id_2]

Errors

The Course creation / edit system depends heavily on errors. The errors on the object need to be managed carefully in order to show the right errors at the right time.

If we add custom, non-model errors to the object, and then call #valid? on the object, the custom errors are cleared and the model errors are applied.

c = Course.new
c.errors
#=> []
c.errors.add(:prop, :validation)
=> #<ActiveModel::Error attribute=prop, type=valdation, options={}>
c.valid?
#=> false
 c.errors
=> #<ActiveModel::Errors [#<ActiveModel::Error attribute=course_code, type=blank, options={:on=>[:create, :update]}>, #<ActiveModel::Error attribute=maths, type=inclusion, o.................
c.errors.key?(:course_code)
#=> true
c.errors.key?(:prop)
#=> false

We must avoid calling valid? on an object that we have added custom errors to.

course_subjects

When we do course.subjects = subjects for a new record, that tries to save the records and raises an exception.

There is an ActiveRecord Validation on dupliate subjects, and also a database unique constraint on the foreign keys. So it's impossible to duplicate a subject on a course.

When this happens, the course.subjects does not maintain the two assigned subjects.

 c = Course.last
 c.subjects
 #=> [#<SecondarySubject:0x00007f5be540b918 id: 10, type: "SecondarySubject", subject_code: "C1", subject_name: "Biology">,
 #<SecondarySubject:0x00007f5be540b7d8 id: 33, type: "SecondarySubject", subject_code: nil, su.......
subject = SecondarySubject.first
c.subjects = [subject, subject]
#=> ActiveRecord::RecordInvalid: Validation failed: Subject has already been taken

c.errors
#=> []

c.subjects
=> [#<SecondarySubject:0x00007f5be5400a18 id: 8, type: "SecondarySubject", subject_code: "W1", subject_name: "Art and design">]

It would be great to be able to use the assigned values to repopulate the form directly.

If we begin to use the course.course_subjects association throughout the application, we have more control over the assignment. We can build the association without creating objects.

subject_ids.each do |subject_id|
  course.course_subjects.build(subject_id:)
end
c = Course.new

c.course_subjects.build(subject_id: subject.id)
=> #<CourseSubject:0x00007f5be53758c8 id: nil, course_id: nil, subject_id: 8, created_at: nil, updated_at: nil, position: 0>

c.course_subjects.build(subject_id: subject.id)
=> #<CourseSubject:0x00007f5be5373208 id: nil, course_id: nil, subject_id: 8, created_at: nil, updated_at: nil, position: 1>

c.course_subjects
=> [#<CourseSubject:0x00007f5be53758c8 id: nil, course_id: nil, subject_id: 8, created_at: nil, updated_at: nil, position: 0>,
 #<CourseSubject:0x00007f5be5373208 id: nil, course_id: nil, subject_id: 8, created_at: nil, updated_at: nil, position: 1>]

We can use this to populate the form.

The main benefit of always using course.course_subjects over course.subjects is consistency between new and persisted records.

Videos

New

Validating Primary, Further Education and Secondary Subject inputs

Screencast.from.30-05-24.14.53.28.webm

Edit

Validating Secondary Subject inputs

Screencast.from.30-05-24.14.57.16.webm

Checklist

  • Make sure all information from the Trello card is in here
  • Attach to Trello card
  • Rebased main
  • Cleaned commit history
  • Tested by running locally
  • Inform data insights team due to database changes

@inulty-dfe inulty-dfe force-pushed the 1564-publish-add-validation-if-a-provider-selects-the-same-subject-in-1st-and-2nd-fields branch 6 times, most recently from d9b9f08 to 4e064e6 Compare May 24, 2024 09:14
@inulty-dfe inulty-dfe force-pushed the 1564-publish-add-validation-if-a-provider-selects-the-same-subject-in-1st-and-2nd-fields branch from 2f2d61d to fa53e42 Compare May 29, 2024 13:04
inulty-dfe added 13 commits May 29, 2024 14:10
  This seems like it can only be a vestige of an old implementation
  we are adding errors explicitly to the course in the process of
  updating or creating it. When custom errors are on the course,
  calling `valid?` will clear the errors unless they are defined on the
  model
  A course has many subjects.

  When the course is new and we assign subjects like:

  `course = some_subjects`

  if there are errors, then we can still access the subjects via:

  `course.subjects`

  When the course has been persisted, if there is an error in that
  assignment, course.subjects returns the value previous to assignment.

  More importantly, assigning subjects to a persisted course will make a
  commit to the database. It will also call save on the course and
  trigger any errors. This clears existing errors on the course.

  It is far better to consistently "soft assign" the subjects to avoid
  these pitfalls.
  When the params `subject_id=[2,1,3]` reach the
  ModernLanguagesController we check the ids against a list of other
  ids.

  The array returned from this check is in a different order than the
  array that went in for checking.

  This PR ensures the subject_ids array is the same order as passed in
@inulty-dfe inulty-dfe force-pushed the 1564-publish-add-validation-if-a-provider-selects-the-same-subject-in-1st-and-2nd-fields branch from fa53e42 to c4851a8 Compare May 29, 2024 15:14
  This is not a good idea, keeping the empty subject id allows us to
  determine the source of the subject_id based on it's position in the
  subjects array.

  The code is too complicated and brittle to change this now
  By passing the empty subordinate param we can destructure the array of
  subjects assigning the master and subordinate accurately.
  Run AssignSubjectService for FurtherEducation and when subject_ids are
  present
  It's easier to separate the parameter validation from the course
  validation in the Subjects controller. This commit covers all subject
  types including physics, modern languages, primary, secondary and
  further education
@inulty-dfe inulty-dfe force-pushed the 1564-publish-add-validation-if-a-provider-selects-the-same-subject-in-1st-and-2nd-fields branch from b091b2b to 84bb08f Compare May 30, 2024 12:23
@inulty-dfe inulty-dfe marked this pull request as ready for review May 30, 2024 13:43
Copy link
Contributor Author

@inulty-dfe inulty-dfe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left some notes on this PR because it's difficult to read

Comment on lines -158 to +164
"course%5Bis_send%5D=0&course%5Blevel%5D=#{level}&course%5Bmaster_subject_id%5D=#{course_subject.id}&course%5Bsubjects_ids%5D%5B%5D=#{course_subject.id}"
[
'course%5Bis_send%5D=0',
"course%5Blevel%5D=#{level}",
"course%5Bmaster_subject_id%5D=#{course_subject.id}",
"course%5Bsubjects_ids%5D%5B%5D=#{course_subject.id}",
'course%5Bsubordinate_subject_id%5D='
].join('&')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this makes the string more readable


require 'rails_helper'

feature 'selecting a subject', { can_edit_current_and_next_cycles: false } do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I deleted the new_subject_spec and split it into new_primary_subject_spec and new_secondary_subject_spec

I added more specs to support the changes and validation.

Comment on lines +2701 to +2708
context 'when course has two subjects' do
it 'set the subordinate subject id to the second subjects_id' do
subordinate_subject = find_or_create(:secondary_subject, :physics)
course.subjects << subordinate_subject
expect(course.reload).to have_attributes({ subordinate_subject_id: subordinate_subject.id })
end
end
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tests the Course.after_initialize assignment of the second Secondary subject to the subordinate_subject_id attribute

Comment on lines +37 to +55
describe 'subordinate subject present but master missing' do
let(:subject_ids) { [] }

context 'with a new course' do
let(:course) { Course.new }

it 'raises missing subject error' do
expect(subject.errors.full_messages).to include('Select a subject')
end
end

context 'with a persisted course' do
let(:course) { create(:course) }

it 'raises missing subject error' do
expect(subject.errors.full_messages).to include('Select a subject')
end
end
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only gets excercised in the "new course flow" becuase we short circuit the validation in the SubjectsController for update.

@updated_subject_list ||= selected_language_subjects_ids.concat(selected_non_language_subjects_ids)
@updated_subject_list ||= selected_non_language_subjects_ids.concat(selected_language_subjects_ids)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This contributes to the new ordering of subject ids where the master and subordinate subject ids are at the front and not unshited back when languages are added

super && assign_subjects_service.valid?
super && assign_subjects_service.errors.none?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The course is returned from assign_subjects_service.

Calling .valid? on the course will clear out any validations we've added that do not appear on the Course model as validations.

Comment on lines +23 to +31
if is_primary?
self.master_subject_id ||= course_subjects.first&.subject_id
else
sub = course_subjects.select(&:position)

self.master_subject_id ||= sub.first&.subject&.id

self.subordinate_subject_id ||= sub&.second&.subject&.id
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not account for Further Education, but there is no form inputs for further Education level courses. The master and subordinate properties are only used for input and validation.

# NOTE: This looks like unwarranted side effects
course.save
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hangover from a previous version of the application.

This service gets called during the building of a new course. When course.save is called, validate is called on the course, and then all custom errors added to the model are cleared and only the validations defined on the model are run.

This is not what we want when incrementally building up errors in the new course flow.

Comment on lines +29 to +34
if course_attributes[:master_subject_id].blank? && course_attributes[:subordinate_subject_id].present?
course.errors.add(:subjects, :course_creation)
elsif subject_ids.present? || course_attributes[:level] == 'further_education'
AssignSubjectsService.call(course:, subject_ids:)
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is here so we can avoid validating the subjects unless under specific circumstances.

We don't want to call AssignSubjectsService.call(course:, subject_ids:) when we have not reached the subjects form (choosing primary / secondary level)
We don't want to call it if there are no subject ids either, except in the case when the level is further education because no subject ids are passed for this level type and the Service is responsible for assigning the FurtherEducation subject to that course.

Comment on lines -46 to +52
AssignSubjectsService.call(course:, subject_ids:)
course.valid?(:new) if course.errors.blank?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid calling valid? on a model when you've added custom errors.

Calling valid? will clear your custom errors unless they are defined on the model as validations.

@inulty-dfe inulty-dfe requested a review from a team May 30, 2024 15:08
Copy link
Contributor

@elceebee elceebee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few little things, but no blocks. You have navigated this nightmare terrain as well as possible.

and_there_is_a_secondary_course_i_want_to_edit
and_the_course_has_two_subjects
when_i_visit_the_edit_course_subject_page
then_i_should_see_populated_selects('Drama', 'Latin')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
then_i_should_see_populated_selects('Drama', 'Latin')
then_i_see_populated_selects('Drama', 'Latin')

I won't mark them all, but just to keep us from having to change this later if we introduce the rubocop rule that prohibits it, let's get rid of the 'should's in the tests

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, and had to restrain myself from changing them initially.

when_i_select_a_subordinate_subject(:business_studies)
and_i_click_continue
then_i_am_met_with_course_details_page
and_i_should_see_subjects_updated_success_message
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
and_i_should_see_subjects_updated_success_message
and_i_see_subjects_updated_success_message

and_there_is_a_secondary_course_i_want_to_edit
and_the_course_has_two_languages
when_i_visit_the_edit_course_subject_page
then_i_should_see_populated_selects('Drama', 'Modern Languages')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
then_i_should_see_populated_selects('Drama', 'Modern Languages')
then_i_see_populated_selects('Drama', 'Modern Languages')

expect(page).to have_content('Languages')
end

def then_i_am_met_with_an_error
within('.govuk-error-summary') do
expect(page).to have_content('Select a subject')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nitpicking, but I think we should make sure we see the error in the summary and on the input -- So could do expect(page).to have_content('Select a subject).twice Or you can be more specific and check it is in the error summary as on the input field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good shout, I'll remember to do that in future

@@ -13,30 +13,69 @@
when_i_select_a_primary_subject('Primary with English')
and_i_click_continue
then_i_am_met_with_course_details_page
and_i_should_see_a_success_message
and_i_should_see_subject_updated_success_message
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see we have covered the further education scenarios in the unit testing -- are there features tests that cover that journey? (they might exist, but just haven't been changed, so I don't see them in this PR). Would be good to add a create / update scenario with FE if they don't already exist. That way, if we ever find ourselves in a position to do some refactoring, we can be confident we haven't broken any essential journeys.

@inulty-dfe
Copy link
Contributor Author

I'm merging now. We have tomorrow to pickup any issues. And these changes target the course creation / editing process which is highly unlikely to cause problems overnight.

@inulty-dfe inulty-dfe merged commit 9bae7b5 into main May 30, 2024
19 checks passed
@inulty-dfe inulty-dfe deleted the 1564-publish-add-validation-if-a-provider-selects-the-same-subject-in-1st-and-2nd-fields branch May 30, 2024 16:11
@inulty-dfe
Copy link
Contributor Author

It's worth mentioning that there is an edge-case issue identified by @elceebee where setting master subject to Modern Languages and subordinate subject to Mathematics, if clicking back link, to the subjects page after selecting languages, the master and subordinate subjects are switched.

This is considered an edge case but should be documented here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants