Skip to content

Commit e973111

Browse files
committed
1069: Add immediate onboarding
1 parent 52a5fb8 commit e973111

20 files changed

Lines changed: 445 additions & 186 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,6 @@ EDITOR_ENCRYPTION_KEY=a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789a
5151
# The sandbox creds can be found in 1password under "Google Cloud Console: CEfE Sandbox"
5252
GOOGLE_CLIENT_ID=changeme.apps.googleusercontent.com
5353
GOOGLE_CLIENT_SECRET=changeme
54+
55+
# Enable immediate onboarding for schools
56+
ENABLE_IMMEDIATE_SCHOOL_ONBOARDING=true

app/controllers/api/schools_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def show
1616
end
1717

1818
def create
19-
result = School::Create.call(school_params:, creator_id: current_user.id)
19+
result = School::Create.call(school_params:, creator_id: current_user.id, token: current_user.token)
2020

2121
if result.success?
2222
@school = result[:school]

app/jobs/school_import_job.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ def import_school(school_data)
7171
School.transaction do
7272
result = School::Create.call(
7373
school_params: school_params,
74-
creator_id: proposed_owner[:id]
74+
creator_id: proposed_owner[:id],
75+
token: @token
7576
)
7677

7778
if result.success?

app/models/school.rb

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

33
class School < ApplicationRecord
4+
class DuplicateSchoolError < StandardError; end
5+
46
has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy
57
has_many :lessons, dependent: :nullify
68
has_many :projects, dependent: :nullify
@@ -15,6 +17,8 @@ class School < ApplicationRecord
1517
validates :website, presence: true, format: { with: VALID_URL_REGEX, message: I18n.t('validations.school.website') }
1618
validates :address_line_1, presence: true
1719
validates :municipality, presence: true
20+
validates :administrative_area, presence: true
21+
validates :postal_code, presence: true
1822
validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes }
1923
validates :reference, uniqueness: { case_sensitive: false, allow_nil: true }, presence: false
2024
validates :district_nces_id, uniqueness: { case_sensitive: false, allow_nil: true }, presence: false
@@ -30,8 +34,6 @@ class School < ApplicationRecord
3034
validates :verified_at, absence: { if: proc { |school| school.rejected? } }
3135
validates :code,
3236
uniqueness: { allow_nil: true },
33-
presence: { if: proc { |school| school.verified? } },
34-
absence: { unless: proc { |school| school.verified? } },
3537
format: { with: /\d\d-\d\d-\d\d/, allow_nil: true }
3638
validate :verified_at_cannot_be_changed
3739
validate :code_cannot_be_changed
@@ -40,8 +42,11 @@ class School < ApplicationRecord
4042
before_validation :normalize_district_fields
4143
before_validation :normalize_school_roll_number
4244

45+
before_save :prevent_duplicate_school
4346
before_save :format_uk_postal_code, if: :should_format_uk_postal_code?
4447

48+
after_commit :generate_code!, on: :create, if: -> { FeatureFlags.immediate_school_onboarding? }
49+
4550
def self.find_for_user!(user)
4651
school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id)
4752
raise ActiveRecord::RecordNotFound unless school
@@ -62,9 +67,18 @@ def rejected?
6267
end
6368

6469
def verify!
70+
generate_code! if ENV['ENABLE_IMMEDIATE_SCHOOL_ONBOARDING'].blank?
71+
72+
update!(verified_at: Time.zone.now)
73+
end
74+
75+
def generate_code!
76+
return code if code.present?
77+
6578
attempts = 0
6679
begin
67-
update!(verified_at: Time.zone.now, code: ForEducationCodeGenerator.generate)
80+
new_code = ForEducationCodeGenerator.generate
81+
update!(code: new_code)
6882
rescue ActiveRecord::RecordInvalid => e
6983
raise unless e.record.errors[:code].include?('has already been taken') && attempts <= 5
7084

@@ -78,6 +92,8 @@ def reject
7892
end
7993

8094
def reopen
95+
return false unless rejected?
96+
8197
update(rejected_at: nil)
8298
end
8399

@@ -122,7 +138,7 @@ def rejected_at_cannot_be_changed
122138
end
123139

124140
def code_cannot_be_changed
125-
errors.add(:code, 'cannot be changed after verification') if code_was.present? && code_changed?
141+
errors.add(:code, 'cannot be changed after onboarding') if code_was.present? && code_changed?
126142
end
127143

128144
def should_format_uk_postal_code?
@@ -139,4 +155,29 @@ def format_uk_postal_code
139155
# ensures UK postcodes are always formatted correctly (as the inward code is always 3 chars long)
140156
self.postal_code = "#{cleaned_postal_code[0..-4]} #{cleaned_postal_code[-3..]}"
141157
end
158+
159+
def prevent_duplicate_school
160+
name_threshold = 0.6
161+
municipality_threshold = 0.7
162+
postal_code_threshold = 0.8
163+
max_edit_distance = 3
164+
165+
normalized_postal = postal_code.to_s.gsub(/\s+/, '')
166+
167+
duplicate_school = School
168+
.where.not(id:)
169+
.where(country_code:)
170+
.where(
171+
'similarity(lower(name), ?) >= ? AND levenshtein(lower(name), ?) <= ?',
172+
name.to_s, name_threshold, name.to_s, max_edit_distance
173+
)
174+
.where('similarity(lower(municipality), ?) >= ?', municipality.to_s, municipality_threshold)
175+
.where(
176+
"similarity(lower(replace(postal_code, ' ', '')), ?) >= ?",
177+
normalized_postal, postal_code_threshold
178+
)
179+
.first
180+
181+
raise DuplicateSchoolError, I18n.t('validations.school.duplicate_school') if duplicate_school
182+
end
142183
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
class SchoolOnboardingService
4+
attr_reader :school
5+
6+
def initialize(school)
7+
@school = school
8+
end
9+
10+
def onboard(token:)
11+
School.transaction do
12+
Role.owner.create!(user_id: school.creator_id, school:)
13+
Role.teacher.create!(user_id: school.creator_id, school:)
14+
15+
ProfileApiClient.create_school(token:, id: school.id, code: school.code)
16+
end
17+
rescue StandardError => e
18+
Sentry.capture_exception(e)
19+
Rails.logger.error { "Failed to onboard school #{@school.id}: #{e.message}" }
20+
false
21+
else
22+
true
23+
end
24+
end

app/services/school_verification_service.rb

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ def initialize(school)
77
@school = school
88
end
99

10-
def verify(token:)
10+
def verify(token: nil)
1111
School.transaction do
1212
school.verify!
13-
Role.owner.create!(user_id: school.creator_id, school:)
14-
Role.teacher.create!(user_id: school.creator_id, school:)
15-
ProfileApiClient.create_school(token:, id: school.id, code: school.code)
13+
14+
SchoolOnboardingService.new(school).onboard(token: token) unless FeatureFlags.immediate_school_onboarding?
1615
end
1716
rescue StandardError => e
1817
Sentry.capture_exception(e)

config/locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ en:
1616
school:
1717
website: "must be a valid URL"
1818
school_roll_number: "must be numbers followed by letters (e.g., 01572D)"
19+
duplicate_school: "A school with very similar details already exists"
1920
invitation:
2021
email_address: "'%<value>s' is invalid"
2122
activerecord:

lib/concepts/school/operations/create.rb

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,23 @@
33
class School
44
class Create
55
class << self
6-
def call(school_params:, creator_id:)
6+
def call(school_params:, creator_id:, token:)
77
response = OperationResponse.new
88
response[:school] = build_school(school_params.merge!(creator_id:))
9-
response[:school].save!
9+
10+
School.transaction do
11+
response[:school].save!
12+
13+
SchoolOnboardingService.new(response[:school]).onboard(token:) if FeatureFlags.immediate_school_onboarding?
14+
end
15+
16+
response
17+
rescue School::DuplicateSchoolError => e
18+
response[:error] = e.message
1019
response
1120
rescue StandardError => e
1221
Sentry.capture_exception(e)
13-
response[:error] = response[:school].errors
22+
response[:error] = response[:school].errors.presence || [e.message]
1423
response
1524
end
1625

lib/concepts/school/operations/update.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ def call(school:, school_params:)
99
response[:school].assign_attributes(school_params)
1010
response[:school].save!
1111
response
12+
rescue School::DuplicateSchoolError => e
13+
Sentry.capture_exception(e)
14+
response[:error] = e.message
15+
response
1216
rescue StandardError => e
1317
Sentry.capture_exception(e)
1418
errors = response[:school].errors.full_messages.join(',')

lib/feature_flags.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
module FeatureFlags
4+
def self.immediate_school_onboarding?
5+
ENV['ENABLE_IMMEDIATE_SCHOOL_ONBOARDING'] == 'true'
6+
end
7+
end

0 commit comments

Comments
 (0)