From 493697473e288f15a294177a4110536c78f188eb Mon Sep 17 00:00:00 2001 From: cocomarine Date: Fri, 11 Sep 2026 11:48:08 +0100 Subject: [PATCH 1/4] create ownership transfers table --- db/migrate/20260911104254_ownership_transfers.rb | 13 +++++++++++++ db/schema.rb | 12 +++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260911104254_ownership_transfers.rb diff --git a/db/migrate/20260911104254_ownership_transfers.rb b/db/migrate/20260911104254_ownership_transfers.rb new file mode 100644 index 000000000..1b48c36d5 --- /dev/null +++ b/db/migrate/20260911104254_ownership_transfers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class OwnershipTransfers < ActiveRecord::Migration[8.1] + def change + create_table :ownership_transfers, id: :uuid do |t| + t.string :email_address + t.datetime :accepted_at + t.references :school, null: false, foreign_key: true, type: :uuid + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6ab773e2a..b05ab35a8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_28_095502) do +ActiveRecord::Schema[8.1].define(version: 2026_09_11_104254) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -222,6 +222,15 @@ t.index ["visibility"], name: "index_lessons_on_visibility" end + create_table "ownership_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "accepted_at" + t.datetime "created_at", null: false + t.string "email_address" + t.uuid "school_id", null: false + t.datetime "updated_at", null: false + t.index ["school_id"], name: "index_ownership_transfers_on_school_id" + end + create_table "project_errors", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.string "error", null: false @@ -419,6 +428,7 @@ add_foreign_key "lessons", "lessons", column: "copied_from_id" add_foreign_key "lessons", "school_classes" add_foreign_key "lessons", "schools" + add_foreign_key "ownership_transfers", "schools" add_foreign_key "project_errors", "projects" add_foreign_key "projects", "lessons" add_foreign_key "projects", "projects", column: "source_project_id", on_delete: :nullify From cba6d7e849136f879cf3ff5ae78567fcf79f04b9 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Fri, 11 Sep 2026 12:13:52 +0100 Subject: [PATCH 2/4] add ownership transfer model --- app/models/ownership_transfer.rb | 21 +++++++++ spec/factories/ownership_transfer.rb | 8 ++++ spec/models/ownership_transfer_spec.rb | 61 ++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 app/models/ownership_transfer.rb create mode 100644 spec/factories/ownership_transfer.rb create mode 100644 spec/models/ownership_transfer_spec.rb diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb new file mode 100644 index 000000000..b9142d608 --- /dev/null +++ b/app/models/ownership_transfer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class OwnershipTransfer < ApplicationRecord + delegate :name, to: :school, prefix: true + + belongs_to :school + validates :email_address, + format: { with: EmailValidator.regexp, message: I18n.t('validations.invitation.email_address') } + after_create_commit :send_ownership_transfer_request_email + encrypts :email_address + + generates_token_for :ownership_transfer, expires_in: 30.days do + email_address + end + + private + + def send_ownership_transfer_request_email + SchoolOwnershipMailer.with(ownership_transfer: self).request_ownership_transfer.deliver_later + end +end diff --git a/spec/factories/ownership_transfer.rb b/spec/factories/ownership_transfer.rb new file mode 100644 index 000000000..33c89a03a --- /dev/null +++ b/spec/factories/ownership_transfer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ownership_transfer do + email_address { 'new-owner@example.com' } + school factory: :verified_school + end +end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb new file mode 100644 index 000000000..4ae118425 --- /dev/null +++ b/spec/models/ownership_transfer_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe OwnershipTransfer do + include ActionMailer::TestHelper + include ActiveSupport::Testing::TimeHelpers + + it 'has a valid factory' do + ownership_transfer = build(:ownership_transfer) + + expect(ownership_transfer).to be_valid + end + + it 'is invalid with an incorrectly formatted email address' do + ownership_transfer = build(:ownership_transfer, email_address: 'not-an-email-address') + + expect(ownership_transfer).not_to be_valid + end + + # TODO: add mailer test + + it 'generates a token for ownership transfer' do + ownership_transfer = create(:ownership_transfer) + token = ownership_transfer.generate_token_for(:ownership_transfer) + + expect(described_class.find_by_token_for(:ownership_transfer, token)).to eq(ownership_transfer) + end + + it 'generates a token valid for 30 days' do + ownership_transfer = create(:ownership_transfer) + token = ownership_transfer.generate_token_for(:ownership_transfer) + + travel 31.days do + expect(described_class.find_by_token_for(:ownership_transfer, token)).to be_nil + end + end + + it 'invalidates the token if the email address changes' do + ownership_transfer = create(:ownership_transfer) + token = ownership_transfer.generate_token_for(:ownership_transfer) + + ownership_transfer.update(email_address: 'different-owner@example.com') + + expect(described_class.find_by_token_for(:ownership_transfer, token)).to be_nil + end + + it 'delegates #school_name to School#name' do + school = build(:school, name: 'school-name') + ownership_transfer = build(:ownership_transfer, school:) + + expect(ownership_transfer.school_name).to eq('school-name') + end + + it 'non-deterministically encrypts the email_address' do + school = create(:verified_school) + described_class.create!(email_address: 'new-owner@example.com', school:) + + expect(described_class.find_by(email_address: 'new-owner@example.com')).to be_nil + end +end From 2e0735acb50f3750bee402dc2f41fe2736bcca0d Mon Sep 17 00:00:00 2001 From: cocomarine Date: Fri, 11 Sep 2026 17:01:53 +0100 Subject: [PATCH 3/4] update dev editor public url --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 2de05f94c..a079cf34d 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,7 @@ SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX # BYPASS_OAUTH=true HOST_URL=http://localhost:3009 -EDITOR_PUBLIC_URL=http://localhost:3012 +EDITOR_PUBLIC_URL=http://classroom.localhost:3013 PROFILE_API_KEY=test # This has to match the value set in Profile (https://github.com/RaspberryPiFoundation/profile/blob/ca10a4f360b6fe2b04be76264e03283054126b0f/.env.example#L45). From e979839a5e4f21a9f8015d9bae170d62cc2a4efd Mon Sep 17 00:00:00 2001 From: cocomarine Date: Fri, 11 Sep 2026 17:10:50 +0100 Subject: [PATCH 4/4] add mailer, preview, template and test --- app/mailers/school_ownership_mailer.rb | 15 ++++++++++ .../request_ownership_transfer.text.erb | 14 +++++++++ spec/mailers/previews/invitation_preview.rb | 2 +- .../school_ownership_mailer_preview.rb | 10 +++++++ spec/mailers/school_ownership_mailer_spec.rb | 29 +++++++++++++++++++ spec/models/ownership_transfer_spec.rb | 8 ++++- 6 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 app/mailers/school_ownership_mailer.rb create mode 100644 app/views/school_ownership_mailer/request_ownership_transfer.text.erb create mode 100644 spec/mailers/previews/school_ownership_mailer_preview.rb create mode 100644 spec/mailers/school_ownership_mailer_spec.rb diff --git a/app/mailers/school_ownership_mailer.rb b/app/mailers/school_ownership_mailer.rb new file mode 100644 index 000000000..d52b8cfab --- /dev/null +++ b/app/mailers/school_ownership_mailer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class SchoolOwnershipMailer < ApplicationMailer + default from: email_address_with_name('web@raspberrypi.org', 'Raspberry Pi Foundation') + + def request_ownership_transfer + @school = params[:ownership_transfer].school + @token = params[:ownership_transfer].generate_token_for(:ownership_transfer) + + mail(to: params[:ownership_transfer].email_address, + subject: "You have been asked to take ownership of #{@school.name}", + track_opens: 'true', + message_stream: 'outbound') + end +end diff --git a/app/views/school_ownership_mailer/request_ownership_transfer.text.erb b/app/views/school_ownership_mailer/request_ownership_transfer.text.erb new file mode 100644 index 000000000..7a22426af --- /dev/null +++ b/app/views/school_ownership_mailer/request_ownership_transfer.text.erb @@ -0,0 +1,14 @@ +You have been asked to take ownership of: + +<%= @school.name %> + +Log in to your account to accept this request and become the owner of this school account. + +Accept ownership transfer: + +<%= "#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/ownership_transfers/#{@token}" %> + +-- +Raspberry Pi Foundation +Copyright Raspberry Pi Foundation UK registered charity 1129409 +All rights reserved diff --git a/spec/mailers/previews/invitation_preview.rb b/spec/mailers/previews/invitation_preview.rb index ca4b74e70..7ad214e43 100644 --- a/spec/mailers/previews/invitation_preview.rb +++ b/spec/mailers/previews/invitation_preview.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Preview all emails at http://localhost:3000/rails/mailers/invitation +# Preview all emails at http://localhost:3009/rails/mailers/invitation class InvitationPreview < ActionMailer::Preview def invite_teacher school = School.new(name: 'Elmwood Secondary School') diff --git a/spec/mailers/previews/school_ownership_mailer_preview.rb b/spec/mailers/previews/school_ownership_mailer_preview.rb new file mode 100644 index 000000000..a30188d9c --- /dev/null +++ b/spec/mailers/previews/school_ownership_mailer_preview.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Preview all emails at http://localhost:3009/rails/mailers/school_ownership_mailer +class SchoolOwnershipMailerPreview < ActionMailer::Preview + def request_ownership_transfer + school = School.new(name: 'Elmwood Secondary School') + ownership_transfer = OwnershipTransfer.new(email_address: 'teacher@example.com', school:) + SchoolOwnershipMailer.with(ownership_transfer:).request_ownership_transfer + end +end diff --git a/spec/mailers/school_ownership_mailer_spec.rb b/spec/mailers/school_ownership_mailer_spec.rb new file mode 100644 index 000000000..ee94d5d0c --- /dev/null +++ b/spec/mailers/school_ownership_mailer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolOwnershipMailer do + describe 'request_ownership_transfer' do + subject(:email) { described_class.with(ownership_transfer:).request_ownership_transfer } + + let(:ownership_transfer) { create(:ownership_transfer) } + + before do + allow(ENV).to receive(:fetch).with('EDITOR_PUBLIC_URL').and_return('http://example.com') + end + + it 'includes the school name in the body' do + expect(email.body.to_s).to include(ownership_transfer.school.name) + end + + it 'includes a link to respond to the ownership transfer request in the body' do + allow(ownership_transfer).to receive(:generate_token_for).and_return('token-id') + + expect(email.body.to_s).to include('http://example.com/en/ownership_transfers/token-id') + end + + it 'includes the school name in the subject' do + expect(email.subject).to include(ownership_transfer.school.name) + end + end +end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index 4ae118425..69bbb4882 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -18,7 +18,13 @@ expect(ownership_transfer).not_to be_valid end - # TODO: add mailer test + it 'sends an ownership transfer request email after create' do + school = create(:verified_school) + + ownership_transfer = described_class.create!(email_address: 'new-owner@example.com', school:) + + assert_enqueued_email_with SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: } + end it 'generates a token for ownership transfer' do ownership_transfer = create(:ownership_transfer)