Skip to content

Commit a744a1b

Browse files
committed
Resolve merge conflict
2 parents 06b57f7 + 282632b commit a744a1b

36 files changed

Lines changed: 1535 additions & 277 deletions

.clabot

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
"magdalenajadach",
2525
"MarcScott",
2626
"Mohamed-RPF",
27+
"mwtrew",
2728
"patch0",
2829
"peconia",
2930
"PetarSimonovic",
3031
"pjbRPF",
3132
"sra405",
32-
"tuzz"
33+
"tuzz",
34+
"zetter-rpf"
3335
],
3436
"message": "We require contributors to sign our Contributor License Agreement, and we don't have you on file. In order for us to review and merge your code, please complete [this form](https://form.raspberrypi.org/4873530) and we'll get you added and review your contribution as soon as possible."
3537
}

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,6 @@ GOOGLE_CLIENT_SECRET=changeme
5454

5555
# E2E tests can supply this to enable test utility endpoints
5656
RESEED_API_KEY=changeme
57+
58+
# Enable immediate onboarding for schools
59+
ENABLE_IMMEDIATE_SCHOOL_ONBOARDING=true

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/lessons_controller.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ class LessonsController < ApiController
77
load_and_authorize_resource :lesson
88

99
def index
10-
archive_scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived
11-
scope = params[:school_class_id] ? archive_scope.where(school_class_id: params[:school_class_id]) : archive_scope
12-
ordered_scope = scope.order(created_at: :asc)
13-
14-
accessible_lessons = ordered_scope.accessible_by(current_ability).includes(project: :remixes)
10+
accessible_lessons = filtered_lessons_scope
11+
.accessible_by(current_ability)
12+
.includes(project: :remixes)
1513
@lessons_with_users = accessible_lessons.with_users
14+
1615
if current_user&.school_teacher?(school) || current_user&.school_owner?(school)
1716
render :teacher_index, formats: [:json], status: :ok
1817
else
@@ -75,6 +74,13 @@ def destroy
7574

7675
private
7776

77+
def filtered_lessons_scope
78+
archive_scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived
79+
scope = params[:school_class_id] ? archive_scope.where(school_class_id: params[:school_class_id]) : archive_scope
80+
scope = scope.joins(:project).where(projects: { identifier: params[:project_identifier] }) if params[:project_identifier].present?
81+
scope.order(created_at: :asc)
82+
end
83+
7884
def verify_school_class_belongs_to_school
7985
return if base_params[:school_class_id].blank?
8086
return if school&.classes&.pluck(:id)&.include?(base_params[:school_class_id])

app/controllers/api/school_students_controller.rb

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,62 @@ def update
110110
end
111111

112112
def destroy
113-
result = SchoolStudent::Delete.call(school: @school, student_id: params[:id], token: current_user.token)
113+
remove_students([params[:id]])
114+
end
114115

115-
if result.success?
116-
head :no_content
117-
else
118-
render json: { error: result[:error] }, status: :unprocessable_entity
116+
def destroy_batch
117+
# DELETE /api/schools/:school_id/students/batch
118+
# Params: { student_ids: ["uuid1", "uuid2", ...] }
119+
#
120+
# Returns 200 OK with one of:
121+
# - Success: { results: [{ user_id: "..." }, ...] }
122+
# - Partial failure: { results: [...], error: "N student(s) failed to be removed" }
123+
#
124+
# Each result may contain:
125+
# - { user_id:, error: } - deletion failed
126+
# - { user_id:, skipped:, reason: } - student skipped (e.g., not in this school)
127+
# - { user_id: } - deletion succeeded
128+
129+
student_ids = student_ids_params
130+
131+
if student_ids.blank?
132+
render json: {
133+
error: 'No student IDs provided',
134+
error_type: :unprocessable_entity
135+
},
136+
status: :unprocessable_entity
137+
return
119138
end
139+
140+
# Remove duplicates to avoid redundant processing
141+
unique_student_ids = student_ids.uniq
142+
remove_students(unique_student_ids)
120143
end
121144

122145
private
123146

147+
def remove_students(student_ids)
148+
service = StudentRemovalService.new(
149+
students: student_ids,
150+
school: @school,
151+
remove_from_profile: true,
152+
token: current_user.token
153+
)
154+
155+
results = service.remove_students
156+
157+
# Check if any errors occurred
158+
errors = results.select { |r| r[:error] }
159+
if errors.any?
160+
render json: {
161+
results: results,
162+
error: "#{errors.size} student(s) failed to be removed"
163+
}, status: :ok
164+
else
165+
render json: { results: results }, status: :ok
166+
end
167+
end
168+
124169
def school_student_params
125170
params.require(:school_student).permit(:username, :password, :name)
126171
end
@@ -135,6 +180,10 @@ def school_students_params
135180
end
136181
end
137182

183+
def student_ids_params
184+
params.fetch(:student_ids, [])
185+
end
186+
138187
def create_safeguarding_flags
139188
create_teacher_safeguarding_flag
140189
create_owner_safeguarding_flag

app/controllers/api/schools_controller.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ 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]
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: 6 additions & 2 deletions
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?
@@ -128,7 +129,10 @@ def build_school_params(school_data)
128129
creator_agree_authority: true,
129130
creator_agree_terms_and_conditions: true,
130131
creator_agree_responsible_safeguarding: true,
131-
user_origin: 'experience_cs'
132+
user_origin: 'experience_cs',
133+
district_name: school_data[:district_name],
134+
district_nces_id: school_data[:district_nces_id],
135+
school_roll_number: school_data[:school_roll_number]
132136
}.compact
133137
end
134138

app/models/ability.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def define_school_owner_abilities(school:)
6767
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } })
6868
can(%i[read create destroy], :school_owner)
6969
can(%i[read create destroy], :school_teacher)
70-
can(%i[read create create_batch update destroy], :school_student)
70+
can(%i[read create create_batch update destroy destroy_batch], :school_student)
7171
can(%i[create create_copy], Lesson, school_id: school.id)
7272
can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public])
7373
can(%i[read destroy], Feedback, school_project: { school_id: school.id })

app/models/school.rb

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,22 @@ class School < ApplicationRecord
1515
validates :website, presence: true, format: { with: VALID_URL_REGEX, message: I18n.t('validations.school.website') }
1616
validates :address_line_1, presence: true
1717
validates :municipality, presence: true
18+
validates :administrative_area, presence: true
19+
validates :postal_code, presence: true
1820
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
21+
validates :reference,
22+
uniqueness: { conditions: -> { where(rejected_at: nil) }, case_sensitive: false, allow_blank: true, message: I18n.t('validations.school.reference_urn_exists') },
23+
format: { with: /\A\d{5,6}\z/, allow_nil: true, message: I18n.t('validations.school.reference') },
24+
if: :united_kingdom?
25+
validates :district_nces_id,
26+
format: { with: /\A\d{7}\z/, allow_nil: true, message: I18n.t('validations.school.district_nces_id') },
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
@@ -30,8 +39,6 @@ class School < ApplicationRecord
3039
validates :verified_at, absence: { if: proc { |school| school.rejected? } }
3140
validates :code,
3241
uniqueness: { allow_nil: true },
33-
presence: { if: proc { |school| school.verified? } },
34-
absence: { unless: proc { |school| school.verified? } },
3542
format: { with: /\d\d-\d\d-\d\d/, allow_nil: true }
3643
validate :verified_at_cannot_be_changed
3744
validate :code_cannot_be_changed
@@ -42,6 +49,9 @@ class School < ApplicationRecord
4249

4350
before_save :format_uk_postal_code, if: :should_format_uk_postal_code?
4451

52+
# TODO: Remove the conditional once the feature flag is retired
53+
after_create :generate_code!, if: -> { FeatureFlags.immediate_school_onboarding? }
54+
4555
def self.find_for_user!(user)
4656
school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id)
4757
raise ActiveRecord::RecordNotFound unless school
@@ -62,9 +72,19 @@ def rejected?
6272
end
6373

6474
def verify!
75+
# TODO: Remove this line once the feature flag is retired
76+
generate_code! unless FeatureFlags.immediate_school_onboarding?
77+
78+
update!(verified_at: Time.zone.now)
79+
end
80+
81+
def generate_code!
82+
return code if code.present?
83+
6584
attempts = 0
6685
begin
67-
update!(verified_at: Time.zone.now, code: ForEducationCodeGenerator.generate)
86+
new_code = ForEducationCodeGenerator.generate
87+
update!(code: new_code)
6888
rescue ActiveRecord::RecordInvalid => e
6989
raise unless e.record.errors[:code].include?('has already been taken') && attempts <= 5
7090

@@ -78,6 +98,8 @@ def reject
7898
end
7999

80100
def reopen
101+
return false unless rejected?
102+
81103
update(rejected_at: nil)
82104
end
83105

@@ -122,13 +144,21 @@ def rejected_at_cannot_be_changed
122144
end
123145

124146
def code_cannot_be_changed
125-
errors.add(:code, 'cannot be changed after verification') if code_was.present? && code_changed?
147+
errors.add(:code, 'cannot be changed after onboarding') if code_was.present? && code_changed?
126148
end
127149

128150
def should_format_uk_postal_code?
129151
country_code == 'GB' && postal_code.to_s.length >= 5
130152
end
131153

154+
def united_kingdom?
155+
country_code == 'GB'
156+
end
157+
158+
def united_states?
159+
country_code == 'US'
160+
end
161+
132162
def ireland?
133163
country_code == 'IE'
134164
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

0 commit comments

Comments
 (0)