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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
de6c09d
Formatting
inulty-dfe May 29, 2024
658157c
Stop calling save on the course in the AssignProgramTypService
inulty-dfe May 29, 2024
32f6517
Update the duplicate subject choice error message
inulty-dfe May 29, 2024
bbfdc1b
fixup dont save course
inulty-dfe May 29, 2024
4ae9be6
Update Subject form content
inulty-dfe May 29, 2024
dd4d645
Add subordinate_subject_id attribute to the Course
inulty-dfe May 29, 2024
02f51d0
Do not call valid? on the course
inulty-dfe May 29, 2024
e38badd
Change from using Course#subjects to Course#course_subjects
inulty-dfe May 29, 2024
83f93a9
Sort course_subject positions with nils
inulty-dfe May 29, 2024
9b7535c
Fix subject_id ordering in modern languages controller
inulty-dfe May 29, 2024
878dfda
Add error to course if secondary subject ids are duplicated
inulty-dfe May 29, 2024
ebfd3af
Add error to course if master_id is blank but subordinate id is not
inulty-dfe May 29, 2024
c4851a8
Add specs
inulty-dfe May 29, 2024
f658610
fixup creation service
inulty-dfe May 29, 2024
a7c4481
Use course_subjects
inulty-dfe May 29, 2024
d2f4012
initialize subordinate
inulty-dfe May 29, 2024
054b920
Set subject ids on model when the form is invalid
inulty-dfe May 29, 2024
85a4534
Remove blank subject_ids
inulty-dfe May 29, 2024
92876cc
Pass uncompacted subject params to AssignSubjectService
inulty-dfe May 29, 2024
a266681
fixup subject id order
inulty-dfe May 29, 2024
3764980
Add errors specifically when master is missing
inulty-dfe May 30, 2024
44b9427
Validate subject params directly in the controller
inulty-dfe May 30, 2024
5f7ae53
Add more detailed specs for choosing subjects
inulty-dfe May 30, 2024
b2b3a95
Rubocop
inulty-dfe May 30, 2024
84bb08f
Validate course subject params before assigning course attributes
inulty-dfe May 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ def actual_params
:skip_languages_goto_confirmation,
:accredited_provider_code,
:campaign_name,
:master_subject_id
:master_subject_id,
:subordinate_subject_id
)
.permit(
:start_date,
Expand Down
10 changes: 4 additions & 6 deletions app/controllers/publish/courses/modern_languages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def continue
def edit
authorize(provider)

return if selected_non_language_subjects_ids.include? modern_languages_subject_id.to_s
return if selected_non_language_subjects_ids.include? modern_languages_subject_id

redirect_to(
details_publish_provider_recruitment_cycle_course_path(
Expand Down Expand Up @@ -67,7 +67,7 @@ def current_step
private

def updated_subject_list
@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

end

def course_subjects_form
Expand All @@ -87,9 +87,7 @@ def selected_subjects(param_key)

ids = params.dig(:course, param_key)&.map(&:to_i) || []

@course.edit_course_options[edit_course_options_key].filter_map do |subject|
subject.id.to_s if ids.include?(subject.id)
end
ids.intersection(@course.edit_course_options[edit_course_options_key].map(&:id))
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 piece of code would rearrange the subject_ids array in the order that they appear in the result of @course.edit_course_options[edit_course_options_key]

Now the subject ids stay in the same order they were before this is called

end

def selected_language_subjects_ids
Expand All @@ -101,7 +99,7 @@ def selected_non_language_subjects_ids
end

def has_modern_languages_subject?
@course.subjects.any? { |subject| subject.id == modern_languages_subject_id }
@course.course_subjects.any? { |subject| subject.subject.id == modern_languages_subject_id }
end

def build_course_params
Expand Down
88 changes: 51 additions & 37 deletions app/controllers/publish/courses/subjects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,56 +18,71 @@ def continue

def update
authorize(provider)
if params[:course][:master_subject_id] == SecondarySubject.physics.id.to_s
course.update(master_subject_id: params[:course][:master_subject_id])
redirect_to(
engineers_teach_physics_publish_provider_recruitment_cycle_course_path(
@course.provider_code,
@course.recruitment_cycle_year,
@course.course_code,
course: { master_subject_id: SecondarySubject.physics.id.to_s, subjects_ids: selected_subject_ids }
if validate_subject_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 is a short circuit to avoid calling AssignSubjectService in the update flow.
The AssignSubjectService is not able to handle the new and update validation for duplicate and missing subjects. This is the easiest way to validate in both flows while managing the errors that are added to the course

if params[:course][:master_subject_id] == SecondarySubject.physics.id.to_s
course.update(master_subject_id: params[:course][:master_subject_id])
redirect_to(
engineers_teach_physics_publish_provider_recruitment_cycle_course_path(
@course.provider_code,
@course.recruitment_cycle_year,
@course.course_code,
course: { master_subject_id: SecondarySubject.physics.id.to_s, subjects_ids: selected_subject_ids }
)
)
)

elsif selected_subject_ids.include?(modern_languages_subject_id.to_s)
course.update(master_subject_id: params[:course][:master_subject_id])
redirect_to(
modern_languages_publish_provider_recruitment_cycle_course_path(
@course.provider_code,
@course.recruitment_cycle_year,
@course.course_code,
course: { subjects_ids: selected_subject_ids }

elsif selected_subject_ids.include?(modern_languages_subject_id.to_s)
course.update(master_subject_id: params[:course][:master_subject_id])
redirect_to(
modern_languages_publish_provider_recruitment_cycle_course_path(
@course.provider_code,
@course.recruitment_cycle_year,
@course.course_code,
course: { subjects_ids: selected_subject_ids }
)
)
)

elsif course_subjects_form.save!
course_updated_message(section_key)
# TODO: move this to the form?
course.update(master_subject_id: params[:course][:master_subject_id])
course.update(name: course.generate_name)
course.update(campaign_name: nil) unless course.master_subject_id == SecondarySubject.physics.id

redirect_to(
details_publish_provider_recruitment_cycle_course_path(
@course.provider_code,
@course.recruitment_cycle_year,
@course.course_code

elsif course.errors.none? && course_subjects_form.save!
Comment on lines -43 to +44
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 actually isn't necessary anymore since the validate_subject_ids was added to the top of the action

course_updated_message(section_key)
# TODO: move this to the form?
course.update(master_subject_id: params[:course][:master_subject_id])
course.update(name: course.generate_name)
course.update(campaign_name: nil) unless course.master_subject_id == SecondarySubject.physics.id

redirect_to(
details_publish_provider_recruitment_cycle_course_path(
@course.provider_code,
@course.recruitment_cycle_year,
@course.course_code
)
)
)
end
else
@errors = @course.errors.messages
course.master_subject_id = selected_master
course.subordinate_subject_id = selected_subordinate
Comment on lines +61 to +62
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 used to repopulate the form fields in the event of an invalid form

render :edit
end
end

private

def validate_subject_ids
if selected_master.blank?
course.errors.add(:subjects, :course_creation)
return false
elsif selected_master == selected_subordinate
course.errors.add(:subjects, :duplicate)
return false
end
true
end

def campaign_name_check
params[:course][:campaign_name] = '' unless @course.master_subject_id == SecondarySubject.physics.id
end

def course_subjects_form
@course_subjects_form ||= CourseSubjectsForm.new(@course, params: selected_subject_ids)
@course_subjects_form ||= CourseSubjectsForm.new(@course, params: [selected_master, selected_subordinate])
Comment on lines -70 to +85
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These subject_ids are passed directly into the AssignSubjectsService.

The first two subject_ids in this array should always be [master, subordinate].

This makes it easier to handle the secondary subjects, and their precedence.

end

def modern_languages_subject_id
Expand All @@ -87,18 +102,17 @@ def error_keys
end

def selected_master
@selected_master ||= params[:course][:master_subject_id] if params[:course][:master_subject_id].present?
params[:course][:master_subject_id].presence
end

def selected_subordinate
@selected_subordinate ||= params[:course][:subordinate_subject_id] if params[:course][:subordinate_subject_id].present?
params[:course][:subordinate_subject_id].presence
end

def build_course_params
previous_subject_selections = params[:course][:subjects_ids]

params[:course][:subjects_ids] = selected_subject_ids
params[:course].delete(:subordinate_subject_id)

build_new_course # to get languages edit_options

Expand Down
22 changes: 9 additions & 13 deletions app/decorators/course_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def funding

def subject_name
if object.subjects.size == 1
object.subjects.first.subject_name
object.course_subjects.first.subject.subject_name
else
object.name
end
Expand All @@ -81,7 +81,7 @@ def computed_subject_name_or_names
elsif (number_of_subjects == 1 || modern_languages_other?) && LANGUAGE_SUBJECT_CODES.exclude?(subjects.first.subject_code)
first_subject_name.downcase
elsif number_of_subjects == 2
transformed_subjects = subjects.map { |subject| LANGUAGE_SUBJECT_CODES.include?(subject.subject_code) ? subject.subject_name : subject.subject_name.downcase }
transformed_subjects = course_subjects.map { |cs| LANGUAGE_SUBJECT_CODES.include?(cs.subject.subject_code) ? cs.subject.subject_name : cs.subject.subject_name.downcase }
"#{transformed_subjects.first} with #{transformed_subjects.second}"
else
object.name.gsub('Modern Languages', 'modern languages')
Expand All @@ -103,7 +103,7 @@ def bursary_first_line_ending
def bursary_requirements
requirements = ['a degree of 2:2 or above in any subject']

if object.subjects.any? { |subject| subject.subject_name.downcase == 'primary with mathematics' }
if object.course_subjects.any? { |subject| subject.subject.subject_name.downcase == 'primary with mathematics' }
mathematics_requirement = 'at least grade B in maths A-level (or an equivalent)'
requirements.push(mathematics_requirement)
end
Expand Down Expand Up @@ -143,7 +143,7 @@ def apprenticeship
end

def sorted_subjects
object.subjects.map(&:subject_name).sort.join('<br>').html_safe
object.course_subjects.map { |cs| cs.subject.subject_name }.join('<br>').html_safe
end

def chosen_subjects
Expand Down Expand Up @@ -255,13 +255,9 @@ def selected_subject_ids
selectable_subject_ids & selected_subject_ids
end

def subordinate_subject_id
selected_subject_ids - [master_subject_id] if master_subject_id
end

def subject_present?(subject_to_find)
subjects.any? do |course_subject|
course_subject.id == subject_to_find.id
course_subjects.any? do |course_subject|
course_subject.subject.id == subject_to_find.id
end
end

Expand Down Expand Up @@ -481,7 +477,7 @@ def number_of_subjects
end

def first_subject_name
subjects.first.subject_name
course_subjects.first.subject.subject_name
end

def modern_languages_other?
Expand All @@ -493,11 +489,11 @@ def main_subject_is_modern_languages?
end

def main_subject
Subject.find(course.master_subject_id)
@main_subject ||= Subject.find(course.master_subject_id)
end

def additional_subjects
object.subjects.reject { |subject| subject.id == main_subject.id }
object.course_subjects.reject { |subject| subject.subject_id == main_subject.id }.map(&:subject)
end

def format_name(subjects)
Expand Down
4 changes: 2 additions & 2 deletions app/forms/publish/course_subjects_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class CourseSubjectsForm < BaseCourseForm
alias subject_ids params

def initialize(model, params: {})
@previous_subject_names = model.subjects.map(&:subject_name)
@previous_subject_names = model.course_subjects.map { |cs| cs.subject.subject_name }
@previous_course_name = model.name
super
end
Expand All @@ -15,7 +15,7 @@ def initialize(model, params: {})
attr_reader :previous_subject_names, :previous_course_name

def valid?
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.

end

def compute_fields
Expand Down
31 changes: 24 additions & 7 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ class Course < ApplicationRecord
has_associated_audits
audited

attribute :subordinate_subject_id, :integer
after_initialize do
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
Comment on lines +23 to +31
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.

end

validates :course_code,
uniqueness: { scope: :provider_id },
presence: true,
Expand Down Expand Up @@ -566,7 +579,7 @@ def publish_enrichment(current_user)
end

def is_modern_language_course?
subjects.any? { |s| s == SecondarySubject.modern_languages }
course_subjects.any? { |cs| cs.subject == SecondarySubject.modern_languages }
end

def sites=(desired_sites)
Expand Down Expand Up @@ -795,15 +808,19 @@ def remove_carat_from_error_messages
end

def modern_language_subjects
subjects.where(type: 'ModernLanguagesSubject')
if persisted? && course_subjects.all?(&:persisted?)
subjects.where(type: 'ModernLanguagesSubject')
else
course_subjects.select { |cs| cs.subject.type == 'ModernLanguagesSubject' }.map(&:subject)
end
end

def master_subject_nil?
master_subject_id.nil?
end

def has_any_modern_language_subject_type?
subjects.any? { |subject| subject.type == 'ModernLanguagesSubject' }
course_subjects.any? { |cs| cs.subject.type == 'ModernLanguagesSubject' }
end

def current_published_enrichment
Expand Down Expand Up @@ -990,24 +1007,24 @@ def has_the_modern_languages_secondary_subject_type?
raise 'SecondarySubject not found' if SecondarySubject.nil?
raise 'SecondarySubject.modern_languages not found' if SecondarySubject.modern_languages.nil?

subjects.any? { |subject| subject&.id == SecondarySubject.modern_languages.id }
course_subjects.any? { |cs| cs.subject&.id == SecondarySubject.modern_languages.id }
end

def validate_has_languages
errors.add(:modern_languages_subjects, :select_a_language) unless has_any_modern_language_subject_type?
end

def validate_subject_count
if subjects.empty?
if course_subjects.empty?
errors.add(:subjects, :course_creation)
return
end

case level
when 'primary', 'further_education'
errors.add(:subjects, 'has too many subjects') if subjects.count > 1
errors.add(:subjects, 'has too many subjects') if course_subjects.count > 1
when 'secondary'
errors.add(:subjects, 'has too many subjects') if subjects.count > 2 && !has_any_modern_language_subject_type?
errors.add(:subjects, 'has too many subjects') if course_subjects.count > 2 && !has_any_modern_language_subject_type?
end
end

Expand Down
1 change: 1 addition & 0 deletions app/policies/course_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def permitted_new_course_attributes
can_sponsor_skilled_worker_visa
campaign_name
master_subject_id
subordinate_subject_id
subjects_ids
]
end
Expand Down
2 changes: 0 additions & 2 deletions app/services/courses/assign_program_type_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ def execute(funding_type, course)
when 'fee'
course.program_type = calculate_fee_program(course)
end
# 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.


private
Expand Down
Loading
Loading