Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ EDITOR_ENCRYPTION_KEY=a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789a
# The sandbox creds can be found in 1password under "Google Cloud Console: CEfE Sandbox"
GOOGLE_CLIENT_ID=changeme.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=changeme

# Enable immediate onboarding for schools
ENABLE_IMMEDIATE_SCHOOL_ONBOARDING=true
7 changes: 5 additions & 2 deletions app/controllers/api/schools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ def show
end

def create
result = School::Create.call(school_params:, creator_id: current_user.id)
result = School::Create.call(school_params:, creator_id: current_user.id, token: current_user.token)

if result.success?
@school = result[:school]
render :show, formats: [:json], status: :created
else
render json: { error: result[:error] }, status: :unprocessable_entity
render json: {
error: result[:error],
error_type: result[:error_type] || :unknown_error
}, status: :unprocessable_entity
end
end

Expand Down
3 changes: 2 additions & 1 deletion app/jobs/school_import_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ def import_school(school_data)
School.transaction do
result = School::Create.call(
school_params: school_params,
creator_id: proposed_owner[:id]
creator_id: proposed_owner[:id],
token: @token
)

if result.success?
Expand Down
49 changes: 45 additions & 4 deletions app/models/school.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class School < ApplicationRecord
class DuplicateSchoolError < StandardError; end

has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy
has_many :lessons, dependent: :nullify
has_many :projects, dependent: :nullify
Expand All @@ -15,6 +17,8 @@ class School < ApplicationRecord
validates :website, presence: true, format: { with: VALID_URL_REGEX, message: I18n.t('validations.school.website') }
validates :address_line_1, presence: true
validates :municipality, presence: true
validates :administrative_area, presence: true
validates :postal_code, presence: true
validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes }
validates :reference, uniqueness: { case_sensitive: false, allow_nil: true }, presence: false
validates :district_nces_id, uniqueness: { case_sensitive: false, allow_nil: true }, presence: false
Expand All @@ -30,8 +34,6 @@ class School < ApplicationRecord
validates :verified_at, absence: { if: proc { |school| school.rejected? } }
validates :code,
uniqueness: { allow_nil: true },
presence: { if: proc { |school| school.verified? } },
absence: { unless: proc { |school| school.verified? } },
format: { with: /\d\d-\d\d-\d\d/, allow_nil: true }
validate :verified_at_cannot_be_changed
validate :code_cannot_be_changed
Expand All @@ -40,8 +42,11 @@ class School < ApplicationRecord
before_validation :normalize_district_fields
before_validation :normalize_school_roll_number

before_save :prevent_duplicate_school
before_save :format_uk_postal_code, if: :should_format_uk_postal_code?

after_commit :generate_code!, on: :create, if: -> { FeatureFlags.immediate_school_onboarding? }

def self.find_for_user!(user)
school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id)
raise ActiveRecord::RecordNotFound unless school
Expand All @@ -62,9 +67,18 @@ def rejected?
end

def verify!
generate_code! if ENV['ENABLE_IMMEDIATE_SCHOOL_ONBOARDING'].blank?

update!(verified_at: Time.zone.now)
end

def generate_code!
return code if code.present?

attempts = 0
begin
update!(verified_at: Time.zone.now, code: ForEducationCodeGenerator.generate)
new_code = ForEducationCodeGenerator.generate
update!(code: new_code)
rescue ActiveRecord::RecordInvalid => e
raise unless e.record.errors[:code].include?('has already been taken') && attempts <= 5

Expand All @@ -78,6 +92,8 @@ def reject
end

def reopen
return false unless rejected?

update(rejected_at: nil)
end

Expand Down Expand Up @@ -122,7 +138,7 @@ def rejected_at_cannot_be_changed
end

def code_cannot_be_changed
errors.add(:code, 'cannot be changed after verification') if code_was.present? && code_changed?
errors.add(:code, 'cannot be changed after onboarding') if code_was.present? && code_changed?
end

def should_format_uk_postal_code?
Expand All @@ -139,4 +155,29 @@ def format_uk_postal_code
# ensures UK postcodes are always formatted correctly (as the inward code is always 3 chars long)
self.postal_code = "#{cleaned_postal_code[0..-4]} #{cleaned_postal_code[-3..]}"
end

def prevent_duplicate_school
name_threshold = 0.6
municipality_threshold = 0.7
postal_code_threshold = 0.8
max_edit_distance = 3

normalized_postal = postal_code.to_s.gsub(/\s+/, '')

duplicate_school = School
.where.not(id:)
.where(country_code:)
.where(
'similarity(lower(name), ?) >= ? AND levenshtein(lower(name), ?) <= ?',
name.to_s, name_threshold, name.to_s, max_edit_distance
)
.where('similarity(lower(municipality), ?) >= ?', municipality.to_s, municipality_threshold)
.where(
"similarity(lower(replace(postal_code, ' ', '')), ?) >= ?",
normalized_postal, postal_code_threshold
)
.first

raise DuplicateSchoolError, I18n.t('validations.school.duplicate_school') if duplicate_school
end
end
24 changes: 24 additions & 0 deletions app/services/school_onboarding_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

class SchoolOnboardingService
attr_reader :school

def initialize(school)
@school = school
end

def onboard(token:)
School.transaction do
Role.owner.create!(user_id: school.creator_id, school:)
Role.teacher.create!(user_id: school.creator_id, school:)

ProfileApiClient.create_school(token:, id: school.id, code: school.code)
end
rescue StandardError => e
Sentry.capture_exception(e)
Rails.logger.error { "Failed to onboard school #{@school.id}: #{e.message}" }
false
else
true
end
end
7 changes: 3 additions & 4 deletions app/services/school_verification_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ def initialize(school)
@school = school
end

def verify(token:)
def verify(token: nil)
School.transaction do
school.verify!
Role.owner.create!(user_id: school.creator_id, school:)
Role.teacher.create!(user_id: school.creator_id, school:)
ProfileApiClient.create_school(token:, id: school.id, code: school.code)

SchoolOnboardingService.new(school).onboard(token: token) unless FeatureFlags.immediate_school_onboarding?
end
rescue StandardError => e
Sentry.capture_exception(e)
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ en:
school:
website: "must be a valid URL"
school_roll_number: "must be numbers followed by letters (e.g., 01572D)"
duplicate_school: "A school with very similar details already exists"
invitation:
email_address: "'%<value>s' is invalid"
activerecord:
Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20251218143924_enable_pg_trgm_extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class EnablePgTrgmExtension < ActiveRecord::Migration[7.2]
def change
enable_extension 'pg_trgm'
enable_extension 'fuzzystrmatch'
end
end
4 changes: 3 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions lib/concepts/school/operations/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,24 @@
class School
class Create
class << self
def call(school_params:, creator_id:)
def call(school_params:, creator_id:, token:)
response = OperationResponse.new
response[:school] = build_school(school_params.merge!(creator_id:))
response[:school].save!

School.transaction do
response[:school].save!

SchoolOnboardingService.new(response[:school]).onboard(token:) if FeatureFlags.immediate_school_onboarding?
end

response
rescue School::DuplicateSchoolError => e
response[:error] = e.message
response[:error_type] = :duplication_error
response
rescue StandardError => e
Sentry.capture_exception(e)
response[:error] = response[:school].errors
response[:error] = response[:school].errors.presence || [e.message]
response
end

Expand Down
4 changes: 4 additions & 0 deletions lib/concepts/school/operations/update.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ def call(school:, school_params:)
response[:school].assign_attributes(school_params)
response[:school].save!
response
rescue School::DuplicateSchoolError => e
Sentry.capture_exception(e)
response[:error] = e.message
response
rescue StandardError => e
Sentry.capture_exception(e)
errors = response[:school].errors.full_messages.join(',')
Expand Down
7 changes: 7 additions & 0 deletions lib/feature_flags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module FeatureFlags
def self.immediate_school_onboarding?
ENV['ENABLE_IMMEDIATE_SCHOOL_ONBOARDING'] == 'true'
end
end
2 changes: 2 additions & 0 deletions lib/tasks/seeds_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def create_school(creator_id, school_id = nil)
school.name = Faker::Educator.secondary_school
school.website = Faker::Internet.url(scheme: 'https')
school.address_line_1 = Faker::Address.street_address
school.administrative_area = "#{Faker::Address.city}shire"
school.municipality = Faker::Address.city
school.postal_code = Faker::Address.postcode
school.country_code = Faker::Address.country_code
school.creator_id = creator_id
school.creator_agree_authority = true
Expand Down
28 changes: 17 additions & 11 deletions spec/concepts/school/create_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
name: 'Test School',
website: 'http://www.example.com',
address_line_1: 'Address Line 1',
administrative_area: 'Greater London',
municipality: 'Greater London',
postal_code: 'SW1A 1AA',
country_code: 'GB',
creator_agree_authority: true,
creator_agree_terms_and_conditions: true,
Expand All @@ -20,27 +22,31 @@
let(:token) { UserProfileMock::TOKEN }
let(:creator_id) { SecureRandom.uuid }

before do
allow(ProfileApiClient).to receive(:create_school).and_return(true)
end

it 'returns a successful operation response' do
response = described_class.call(school_params:, creator_id:)
response = described_class.call(school_params:, creator_id:, token:)
expect(response.success?).to be(true)
end

it 'creates a school' do
expect { described_class.call(school_params:, creator_id:) }.to change(School, :count).by(1)
expect { described_class.call(school_params:, creator_id:, token:) }.to change(School, :count).by(1)
end

it 'returns the school in the operation response' do
response = described_class.call(school_params:, creator_id:)
response = described_class.call(school_params:, creator_id:, token:)
expect(response[:school]).to be_a(School)
end

it 'assigns the name' do
response = described_class.call(school_params:, creator_id:)
response = described_class.call(school_params:, creator_id:, token:)
expect(response[:school].name).to eq('Test School')
end

it 'assigns the creator_id' do
response = described_class.call(school_params:, creator_id:)
response = described_class.call(school_params:, creator_id:, token:)
expect(response[:school].creator_id).to eq(creator_id)
end

Expand All @@ -52,26 +58,26 @@
end

it 'does not create a school' do
expect { described_class.call(school_params:, creator_id:) }.not_to change(School, :count)
expect { described_class.call(school_params:, creator_id:, token:) }.not_to change(School, :count)
end

it 'returns a failed operation response' do
response = described_class.call(school_params:, creator_id:)
response = described_class.call(school_params:, creator_id:, token:)
expect(response.failure?).to be(true)
end

it 'returns the correct number of objects in the operation response' do
response = described_class.call(school_params:, creator_id:)
expect(response[:error].count).to eq(9)
response = described_class.call(school_params:, creator_id:, token:)
expect(response[:error].count).to eq(11)
end

it 'returns the correct type of object in the operation response' do
response = described_class.call(school_params:, creator_id:)
response = described_class.call(school_params:, creator_id:, token:)
expect(response[:error].first).to be_a(ActiveModel::Error)
end

it 'sent the exception to Sentry' do
described_class.call(school_params:, creator_id:)
described_class.call(school_params:, creator_id:, token:)
expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError))
end
end
Expand Down
10 changes: 6 additions & 4 deletions spec/factories/school.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

FactoryBot.define do
factory :school do
sequence(:name) { |n| "School #{n}" }
website { 'http://www.example.com' }
address_line_1 { 'Address Line 1' }
municipality { 'Greater London' }
name { "#{Faker::Educator.primary_school} #{Faker::Address.city}" }
website { Faker::Internet.url }
address_line_1 { Faker::Address.street_address }
administrative_area { "#{Faker::Address.city}shire" }
municipality { Faker::Address.city }
postal_code { Faker::Address.postcode }
country_code { 'GB' }
school_roll_number { nil }
creator_id { SecureRandom.uuid }
Expand Down
2 changes: 2 additions & 0 deletions spec/features/school/creating_a_school_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
name: 'Test School',
website: 'http://www.example.com',
address_line_1: 'Address Line 1',
administrative_area: 'Greater London',
municipality: 'Greater London',
postal_code: 'SW1A 1AA',
country_code: 'GB',
creator_agree_authority: true,
creator_agree_terms_and_conditions: true,
Expand Down
5 changes: 2 additions & 3 deletions spec/features/school/showing_a_school_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@
end

it 'responds 403 Forbidden when the user belongs to a different school' do
Role.owner.find_by(user_id: owner.id, school:).delete
school.update!(id: SecureRandom.uuid)
other_school = create(:school)

get("/api/schools/#{school.id}", headers:)
get("/api/schools/#{other_school.id}", headers:)
expect(response).to have_http_status(:forbidden)
end
end
Loading