Skip to content

Commit 284caef

Browse files
Make NCES ID mandatory for US schools (#638)
# Make NCES ID mandatory for US schools Add conditional presence validation for US NCES ID (district_nces_id) fields, with format validation and scoped uniqueness to allow reuse after school rejection. This improves validation error handling so users receive specific, actionable error messages instead of generic form errors. ## Status Closes: https://github.com/orgs/RaspberryPiFoundation/projects/51/views/11?pane=issue&itemId=120513360&issue=RaspberryPiFoundation%7Cdigital-editor-issues%7C780 Related to: (standalone url) ## Points for consideration: ### Security - Partial unique indexes prevent duplicate entries among active (non-rejected) schools - Format validation enforces strict digit patterns (5-6 digits for [URN](https://get-information-schools.service.gov.uk/glossary), 12 digits for [NCES ID](https://nces.ed.gov/ccd/psadd.asp)) - Case-insensitive uniqueness check prevents bypass attempts ## What's changed? ### Database: - Converted `reference` index to partial unique index (`WHERE rejected_at IS NULL`) - Converted `district_nces_id` index to partial unique index (`WHERE rejected_at IS NULL`) - This allows rejected schools to release their identifiers for reuse by legitimate schools ### Model (School): - Added conditional presence validation: `reference` required for UK (`country_code == 'GB'`) - Added conditional presence validation: `district_nces_id` required for US (`country_code == 'US'`) - Added format validation: URN must be 5-6 digits, NCES ID must be 12 digits - Added scoped uniqueness: only active (non-rejected) schools are checked for duplicates - Added `united_kingdom?` and `united_states?` helper methods ### API Error Responses: | Field | Validation | Error Message | |-------|------------|---------------| | `reference` | presence | `"can't be blank"` | | `reference` | uniqueness | `"has already been taken"` | | `reference` | format | `"must be 5-6 digits (e.g., 100000)"` | | `district_nces_id` | presence | `"can't be blank"` | | `district_nces_id` | uniqueness | `"has already been taken"` | | `district_nces_id` | format | `"must be 12 digits (e.g., 010000000001)"` | ### Tests: - 16 comprehensive validation tests covering: - Conditional presence (UK/US specific) - Format validation (valid/invalid patterns) - Scoped uniqueness (allows reuse after rejection) - Updated factory to include valid `reference` for default GB schools --------- Co-authored-by: Jamie Benstead <57325966+jamiebenstead@users.noreply.github.com> Co-authored-by: Jamie Benstead <jamie.benstead@gmail.com>
1 parent 5ba2041 commit 284caef

15 files changed

Lines changed: 234 additions & 43 deletions

File tree

Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ GEM
558558
PLATFORMS
559559
aarch64-linux
560560
arm64-darwin-24
561+
arm64-darwin-25
561562
x86_64-linux
562563

563564
DEPENDENCIES

app/controllers/api/schools_controller.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ def create
2222
@school = result[:school]
2323
render :show, formats: [:json], status: :created
2424
else
25-
render json: { error: result[:error] }, status: :unprocessable_entity
25+
render json: {
26+
error: result[:error],
27+
error_types: result[:error_types]
28+
}, status: :unprocessable_entity
2629
end
2730
end
2831

app/jobs/school_import_job.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ def build_school_params(school_data)
128128
creator_agree_authority: true,
129129
creator_agree_terms_and_conditions: true,
130130
creator_agree_responsible_safeguarding: true,
131-
user_origin: 'experience_cs'
131+
user_origin: 'experience_cs',
132+
district_name: school_data[:district_name],
133+
district_nces_id: school_data[:district_nces_id],
134+
school_roll_number: school_data[:school_roll_number]
132135
}.compact
133136
end
134137

app/models/school.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,21 @@ class School < ApplicationRecord
1616
validates :address_line_1, presence: true
1717
validates :municipality, presence: true
1818
validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes }
19-
validates :reference, uniqueness: { case_sensitive: false, allow_nil: true }, presence: false
20-
validates :district_nces_id, uniqueness: { case_sensitive: false, allow_nil: true }, presence: false
19+
validates :reference,
20+
uniqueness: { conditions: -> { where(rejected_at: nil) }, case_sensitive: false, allow_blank: true, message: I18n.t('validations.school.reference_urn_exists') },
21+
format: { with: /\A\d{5,6}\z/, allow_nil: true, message: I18n.t('validations.school.reference') },
22+
if: :united_kingdom?
23+
validates :district_nces_id,
24+
uniqueness: { conditions: -> { where(rejected_at: nil) }, case_sensitive: false, allow_blank: true, message: I18n.t('validations.school.district_nces_id_exists') },
25+
format: { with: /\A\d{12}\z/, allow_nil: true, message: I18n.t('validations.school.district_nces_id') },
26+
presence: true,
27+
if: :united_states?
28+
validates :district_name, presence: true, if: :united_states?
2129
validates :school_roll_number,
22-
uniqueness: { conditions: -> { where(rejected_at: nil) }, case_sensitive: false, allow_nil: true },
23-
presence: { if: :ireland? },
24-
format: { with: /\A[0-9]+[A-Z]+\z/, allow_nil: true, message: I18n.t('validations.school.school_roll_number') }
30+
uniqueness: { conditions: -> { where(rejected_at: nil) }, case_sensitive: false, allow_blank: true, message: I18n.t('validations.school.school_roll_number_exists') },
31+
format: { with: /\A[0-9]+[A-Z]+\z/, allow_nil: true, message: I18n.t('validations.school.school_roll_number') },
32+
presence: true,
33+
if: :ireland?
2534
validates :creator_id, presence: true, uniqueness: true
2635
validates :creator_agree_authority, presence: true, acceptance: true
2736
validates :creator_agree_terms_and_conditions, presence: true, acceptance: true
@@ -129,6 +138,14 @@ def should_format_uk_postal_code?
129138
country_code == 'GB' && postal_code.to_s.length >= 5
130139
end
131140

141+
def united_kingdom?
142+
country_code == 'GB'
143+
end
144+
145+
def united_states?
146+
country_code == 'US'
147+
end
148+
132149
def ireland?
133150
country_code == 'IE'
134151
end

config/locales/en.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ en:
1515
validations:
1616
school:
1717
website: "must be a valid URL"
18+
reference: "must be 5-6 digits (e.g., 100000)"
19+
reference_urn_exists: "URN number already exists"
20+
district_nces_id: "must be 12 digits (e.g., 010000000001)"
21+
district_nces_id_exists: "NCES ID already exists"
1822
school_roll_number: "must be numbers followed by letters (e.g., 01572D)"
23+
school_roll_number_exists: "School roll number already exists"
1924
invitation:
2025
email_address: "'%<value>s' is invalid"
2126
activerecord:
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
class ChangeReferenceAndNcesIdIndexesToPartial < ActiveRecord::Migration[7.2]
4+
def change
5+
# Convert reference (UK URN) index to partial index
6+
# This allows rejected schools to release their URN for reuse
7+
remove_index :schools, :reference
8+
add_index :schools, :reference, unique: true, where: 'rejected_at IS NULL'
9+
10+
# Convert district_nces_id (US NCES ID) index to partial index
11+
# This allows rejected schools to release their NCES ID for reuse
12+
remove_index :schools, :district_nces_id
13+
add_index :schools, :district_nces_id, unique: true, where: 'rejected_at IS NULL'
14+
end
15+
end
16+

db/schema.rb

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/concepts/school/operations/create.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ def call(school_params:, creator_id:)
1111
rescue StandardError => e
1212
Sentry.capture_exception(e)
1313
response[:error] = response[:school].errors
14+
response[:error_types] = response[:school].errors.details
15+
1416
response
1517
end
1618

lib/tasks/seeds_helper.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,23 @@ module SeedsHelper
1616
def create_school(creator_id, school_id = nil)
1717
School.find_or_create_by!(creator_id:, id: school_id) do |school|
1818
Rails.logger.info 'Seeding a school...'
19+
country_code = Faker::Address.country_code
1920
school.name = Faker::Educator.secondary_school
2021
school.website = Faker::Internet.url(scheme: 'https')
2122
school.address_line_1 = Faker::Address.street_address
2223
school.municipality = Faker::Address.city
23-
school.country_code = Faker::Address.country_code
24+
school.country_code = country_code
2425
school.creator_id = creator_id
2526
school.creator_agree_authority = true
2627
school.creator_agree_terms_and_conditions = true
2728
school.creator_agree_to_ux_contact = true
29+
# Country-specific required fields
30+
school.reference = format('%06d', rand(100_000..999_999)) if country_code == 'GB'
31+
if country_code == 'US'
32+
school.district_nces_id = format('%012d', rand(10**12))
33+
school.district_name = Faker::Address.community
34+
end
35+
school.school_roll_number = "#{rand(10_000..99_999)}#{('A'..'Z').to_a.sample}" if country_code == 'IE'
2836
end
2937
end
3038

spec/concepts/school/create_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
address_line_1: 'Address Line 1',
1111
municipality: 'Greater London',
1212
country_code: 'GB',
13+
reference: '100000',
1314
creator_agree_authority: true,
1415
creator_agree_terms_and_conditions: true,
1516
creator_agree_to_ux_contact: true,

0 commit comments

Comments
 (0)