From 136ec45a4b469af4f12f91515eea414b3478e13f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 16:12:36 +0000 Subject: [PATCH 001/124] Add a schools table --- db/migrate/20240201160923_create_schools.rb | 13 +++++++++++++ db/schema.rb | 12 +++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240201160923_create_schools.rb diff --git a/db/migrate/20240201160923_create_schools.rb b/db/migrate/20240201160923_create_schools.rb new file mode 100644 index 000000000..49c90e5f6 --- /dev/null +++ b/db/migrate/20240201160923_create_schools.rb @@ -0,0 +1,13 @@ +class CreateSchools < ActiveRecord::Migration[7.0] + def change + create_table :schools, id: :uuid do |t| + t.uuid :organisation_id, null: false + t.uuid :owner_id, null: false + t.string :name, null: false + t.datetime :verified_at + t.timestamps + end + + add_index :schools, :organisation_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 98c4280ab..419162f8e 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[7.0].define(version: 2023_11_06_151705) do +ActiveRecord::Schema[7.0].define(version: 2024_02_01_160923) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -139,6 +139,16 @@ t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" end + create_table "schools", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "organisation_id", null: false + t.uuid "owner_id", null: false + t.string "name", null: false + t.datetime "verified_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["organisation_id"], name: "index_schools_on_organisation_id", unique: true + end + create_table "words", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "word" t.index ["word"], name: "index_words_on_word" From 17b9d16a13f2f1fb28810b7ad7e7419c5e600405 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 16:23:07 +0000 Subject: [PATCH 002/124] Add a School model --- app/models/school.rb | 7 ++++++ spec/factories/school.rb | 9 +++++++ spec/models/school_spec.rb | 51 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 app/models/school.rb create mode 100644 spec/factories/school.rb create mode 100644 spec/models/school_spec.rb diff --git a/app/models/school.rb b/app/models/school.rb new file mode 100644 index 000000000..fb5b123b1 --- /dev/null +++ b/app/models/school.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class School < ApplicationRecord + validates :organisation_id, presence: true, uniqueness: { case_sensitive: false } + validates :owner_id, presence: true + validates :name, presence: true +end diff --git a/spec/factories/school.rb b/spec/factories/school.rb new file mode 100644 index 000000000..f24e346aa --- /dev/null +++ b/spec/factories/school.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :school do + organisation_id { SecureRandom.uuid } + owner_id { SecureRandom.uuid } + sequence(:name) { |n| "School #{n}" } + end +end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb new file mode 100644 index 000000000..8d33f3894 --- /dev/null +++ b/spec/models/school_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School do + describe 'validations' do + subject(:school) { build(:school) } + + it 'has a valid default factory' do + expect(school).to be_valid + end + + it 'can save the default factory' do + expect { school.save! }.not_to raise_error + end + + it 'requires an organisation_id' do + school.organisation_id = ' ' + expect(school).to be_invalid + end + + it 'requires a UUID organisation_id' do + school.organisation_id = 'invalid' + expect(school).to be_invalid + end + + it 'requires an owner_id' do + school.owner_id = ' ' + expect(school).to be_invalid + end + + it 'requires a UUID owner_id' do + school.owner_id = 'invalid' + expect(school).to be_invalid + end + + it 'requires a unique organisation_id' do + school.save! + + duplicate_id = school.organisation_id.upcase + duplicate_school = build(:school, organisation_id: duplicate_id) + + expect(duplicate_school).to be_invalid + end + + it 'requires a name' do + school.name = ' ' + expect(school).to be_invalid + end + end +end From f628a1a328003afd7ecf1e9a58accef13a5d96d6 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 17:08:40 +0000 Subject: [PATCH 003/124] Add a school_classes table --- db/migrate/20240201165749_create_school_classes.rb | 12 ++++++++++++ db/schema.rb | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240201165749_create_school_classes.rb diff --git a/db/migrate/20240201165749_create_school_classes.rb b/db/migrate/20240201165749_create_school_classes.rb new file mode 100644 index 000000000..bf9e18504 --- /dev/null +++ b/db/migrate/20240201165749_create_school_classes.rb @@ -0,0 +1,12 @@ +class CreateSchoolClasses < ActiveRecord::Migration[7.0] + def change + create_table :school_classes, id: :uuid do |t| + t.belongs_to :school, null: false + t.uuid :teacher_id, null: false + t.string :name, null: false + t.timestamps + end + + add_index :school_classes, %i[school_id teacher_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index 419162f8e..611bd59c4 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[7.0].define(version: 2024_02_01_160923) do +ActiveRecord::Schema[7.0].define(version: 2024_02_01_165749) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -139,6 +139,16 @@ t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" end + create_table "school_classes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "school_id", null: false + t.uuid "teacher_id", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["school_id", "teacher_id"], name: "index_school_classes_on_school_id_and_teacher_id" + t.index ["school_id"], name: "index_school_classes_on_school_id" + end + create_table "schools", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "organisation_id", null: false t.uuid "owner_id", null: false From 643884c937d4240dd701ad82deb5f9406e5d92a5 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 17:16:32 +0000 Subject: [PATCH 004/124] Add a SchoolClass model --- app/models/school_class.rb | 7 ++++++ spec/factories/school_class.rb | 9 ++++++++ spec/models/school_class_spec.rb | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 app/models/school_class.rb create mode 100644 spec/factories/school_class.rb create mode 100644 spec/models/school_class_spec.rb diff --git a/app/models/school_class.rb b/app/models/school_class.rb new file mode 100644 index 000000000..a42ab7efd --- /dev/null +++ b/app/models/school_class.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SchoolClass < ApplicationRecord + belongs_to :school + validates :teacher_id, presence: true + validates :name, presence: true +end diff --git a/spec/factories/school_class.rb b/spec/factories/school_class.rb new file mode 100644 index 000000000..97e0e1604 --- /dev/null +++ b/spec/factories/school_class.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :school_class do + school + teacher_id { SecureRandom.uuid } + sequence(:name) { |n| "Class #{n}" } + end +end diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb new file mode 100644 index 000000000..42465d802 --- /dev/null +++ b/spec/models/school_class_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass do + describe 'validations' do + subject(:school_class) { build(:school_class) } + + it 'has a valid default factory' do + expect(school_class).to be_valid + end + + it 'can save the default factory' do + expect { school_class.save! }.not_to raise_error + end + + it 'requires a school' do + school_class.school = nil + expect(school_class).to be_invalid + end + + it 'requires a teacher_id' do + school_class.teacher_id = ' ' + expect(school_class).to be_invalid + end + + it 'requires a UUID teacher_id' do + school_class.teacher_id = 'invalid' + expect(school_class).to be_invalid + end + + it 'requires a name' do + school_class.name = ' ' + expect(school_class).to be_invalid + end + end +end From 8376cc6125bc6afa0860639ee986c223ad0b790f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 17:18:37 +0000 Subject: [PATCH 005/124] Add a class_members table --- db/migrate/20240201171700_create_class_members.rb | 12 ++++++++++++ db/schema.rb | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240201171700_create_class_members.rb diff --git a/db/migrate/20240201171700_create_class_members.rb b/db/migrate/20240201171700_create_class_members.rb new file mode 100644 index 000000000..ec72464d7 --- /dev/null +++ b/db/migrate/20240201171700_create_class_members.rb @@ -0,0 +1,12 @@ +class CreateClassMembers < ActiveRecord::Migration[7.0] + def change + create_table :class_members, id: :uuid do |t| + t.belongs_to :school_class, null: false + t.uuid :student_id, null: false + t.timestamps + end + + add_index :class_members, :student_id + add_index :class_members, %i[school_class_id student_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 611bd59c4..13ee4ac5f 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[7.0].define(version: 2024_02_01_165749) do +ActiveRecord::Schema[7.0].define(version: 2024_02_01_171700) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -44,6 +44,16 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "class_members", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "school_class_id", null: false + t.uuid "student_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["school_class_id", "student_id"], name: "index_class_members_on_school_class_id_and_student_id", unique: true + t.index ["school_class_id"], name: "index_class_members_on_school_class_id" + t.index ["student_id"], name: "index_class_members_on_student_id" + end + create_table "components", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "project_id" t.string "name", null: false From 1c75c70fcd0ce92d5c0c7d718f1f7e08fd400a3e Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 17:22:00 +0000 Subject: [PATCH 006/124] Add a ClassMember model --- app/models/class_member.rb | 10 +++++++++ spec/factories/class_member.rb | 8 +++++++ spec/models/class_member_spec.rb | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 app/models/class_member.rb create mode 100644 spec/factories/class_member.rb create mode 100644 spec/models/class_member_spec.rb diff --git a/app/models/class_member.rb b/app/models/class_member.rb new file mode 100644 index 000000000..cff7c6e62 --- /dev/null +++ b/app/models/class_member.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ClassMember < ApplicationRecord + belongs_to :school_class + + validates :student_id, presence: true, uniqueness: { + scope: :school_class_id, + case_sensitive: false + } +end diff --git a/spec/factories/class_member.rb b/spec/factories/class_member.rb new file mode 100644 index 000000000..c9d9aac30 --- /dev/null +++ b/spec/factories/class_member.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :class_member do + school_class + student_id { SecureRandom.uuid } + end +end diff --git a/spec/models/class_member_spec.rb b/spec/models/class_member_spec.rb new file mode 100644 index 000000000..1d46b7613 --- /dev/null +++ b/spec/models/class_member_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassMember do + describe 'validations' do + subject(:class_member) { build(:class_member) } + + it 'has a valid default factory' do + expect(class_member).to be_valid + end + + it 'can save the default factory' do + expect { class_member.save! }.not_to raise_error + end + + it 'requires a school_class' do + class_member.school_class = nil + expect(class_member).to be_invalid + end + + it 'requires a student_id' do + class_member.student_id = ' ' + expect(class_member).to be_invalid + end + + it 'requires a UUID student_id' do + class_member.student_id = 'invalid' + expect(class_member).to be_invalid + end + + it 'requires a unique student_id within the school_class' do + class_member.save! + duplicate = build(:class_member, student_id: class_member.student_id, school_class: class_member.school_class) + expect(duplicate).to be_invalid + end + end +end From 13e7428c09ca6d1d52a27aba84843de5cf07a660 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 17:44:57 +0000 Subject: [PATCH 007/124] Add a school#classes association --- app/models/school.rb | 2 ++ spec/models/school_spec.rb | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/app/models/school.rb b/app/models/school.rb index fb5b123b1..cc8602afa 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class School < ApplicationRecord + has_many :classes, class_name: :SchoolClass, inverse_of: :school + validates :organisation_id, presence: true, uniqueness: { case_sensitive: false } validates :owner_id, presence: true validates :name, presence: true diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 8d33f3894..02415f227 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -3,6 +3,15 @@ require 'rails_helper' RSpec.describe School do + describe 'associations' do + it 'has many classes' do + school = create(:school) + school_class = create(:school_class, school: school) + + expect(school.classes).to eq [school_class] + end + end + describe 'validations' do subject(:school) { build(:school) } From 868d6c081238b11391f51dcf8924404d1debc4a0 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 1 Feb 2024 18:07:02 +0000 Subject: [PATCH 008/124] Add dependent: :destroy to school/classes/members assocations --- app/models/school.rb | 2 +- app/models/school_class.rb | 2 ++ spec/models/school_class_spec.rb | 15 +++++++++++++++ spec/models/school_spec.rb | 17 ++++++++++++++--- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/models/school.rb b/app/models/school.rb index cc8602afa..89cb7534e 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class School < ApplicationRecord - has_many :classes, class_name: :SchoolClass, inverse_of: :school + has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy validates :organisation_id, presence: true, uniqueness: { case_sensitive: false } validates :owner_id, presence: true diff --git a/app/models/school_class.rb b/app/models/school_class.rb index a42ab7efd..7bec2483e 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -2,6 +2,8 @@ class SchoolClass < ApplicationRecord belongs_to :school + has_many :members, class_name: :ClassMember, inverse_of: :school_class, dependent: :destroy + validates :teacher_id, presence: true validates :name, presence: true end diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index 42465d802..9ff9f880a 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -3,6 +3,21 @@ require 'rails_helper' RSpec.describe SchoolClass do + describe 'associations' do + it 'has many members' do + school_class = create(:school_class, members: [build(:class_member), build(:class_member)]) + expect(school_class.members.size).to eq(2) + end + + context 'when a school_class is destroyed' do + let!(:school_class) { create(:school_class, members: [build(:class_member)]) } + + it 'also destroys class members to avoid making them invalid' do + expect { school_class.destroy! }.to change(ClassMember, :count).by(-1) + end + end + end + describe 'validations' do subject(:school_class) { build(:school_class) } diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 02415f227..8c01543f4 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -5,10 +5,21 @@ RSpec.describe School do describe 'associations' do it 'has many classes' do - school = create(:school) - school_class = create(:school_class, school: school) + school = create(:school, classes: [build(:school_class), build(:school_class)]) + expect(school.classes.size).to eq(2) + end + + context 'when a school is destroyed' do + let!(:school_class) { create(:school_class, members: [build(:class_member)]) } + let!(:school) { create(:school, classes: [school_class]) } + + it 'also destroys school classes to avoid making them invalid' do + expect { school.destroy! }.to change(SchoolClass, :count).by(-1) + end - expect(school.classes).to eq [school_class] + it 'also destroys class members to avoid making them invalid' do + expect { school.destroy! }.to change(ClassMember, :count).by(-1) + end end end From bf6024fad5d90be73c45c017acbf16477fc00992 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 5 Feb 2024 13:24:30 +0000 Subject: [PATCH 009/124] Add a School::Create operation --- lib/concepts/school/operations/create.rb | 24 ++++++++++ spec/concepts/school/create_spec.rb | 59 ++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 lib/concepts/school/operations/create.rb create mode 100644 spec/concepts/school/create_spec.rb diff --git a/lib/concepts/school/operations/create.rb b/lib/concepts/school/operations/create.rb new file mode 100644 index 000000000..931f1e433 --- /dev/null +++ b/lib/concepts/school/operations/create.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class School + class Create + class << self + def call(school_hash:) + response = OperationResponse.new + response[:school] = build_school(school_hash) + response[:school].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = 'Error creating school' + response + end + + private + + def build_school(school_hash) + School.new(school_hash) + end + end + end +end diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb new file mode 100644 index 000000000..11526ecf6 --- /dev/null +++ b/spec/concepts/school/create_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School::Create, type: :unit do + let(:school_hash) do + { + name: 'Test School', + organisation_id: '00000000-00000000-00000000-00000000', + owner_id: '11111111-11111111-11111111-11111111' + } + end + + it 'creates a school' do + expect { described_class.call(school_hash:) }.to change(School, :count).by(1) + end + + it 'returns the school in the operation response' do + response = described_class.call(school_hash:) + expect(response[:school]).to be_a(School) + end + + it 'assigns the name' do + response = described_class.call(school_hash:) + expect(response[:school].name).to eq('Test School') + end + + it 'assigns the organisation_id' do + response = described_class.call(school_hash:) + expect(response[:school].organisation_id).to eq('00000000-00000000-00000000-00000000') + end + + context 'when creation fails' do + let(:school_hash) { {} } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a school' do + expect { described_class.call(school_hash:) }.not_to change(School, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(school_hash:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_hash:) + expect(response[:error]).to eq('Error creating school') + end + + it 'sent the exception to Sentry' do + described_class.call(school_hash:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end From dbc97081ec2ae8bc345fd980a697322fd7c2867c Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 5 Feb 2024 15:40:59 +0000 Subject: [PATCH 010/124] Add a SchoolsController --- app/controllers/api/schools_controller.rb | 25 ++++++++++++ app/views/api/schools/show.json.jbuilder | 3 ++ config/routes.rb | 2 + spec/features/creating_a_school_spec.rb | 48 +++++++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 app/controllers/api/schools_controller.rb create mode 100644 app/views/api/schools/show.json.jbuilder create mode 100644 spec/features/creating_a_school_spec.rb diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb new file mode 100644 index 000000000..645337181 --- /dev/null +++ b/app/controllers/api/schools_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + class SchoolsController < ApiController + before_action :authorize_user, only: %i[create] + + def create + school_hash = school_params.merge(owner_id: current_user) + result = School::Create.call(school_hash:) + + if result.success? + @school = result[:school] + render :show, formats: [:json] + else + render json: { error: result[:error] }, status: :internal_server_error + end + end + + private + + def school_params + params.permit(:name, :organisation_id) + end + end +end diff --git a/app/views/api/schools/show.json.jbuilder b/app/views/api/schools/show.json.jbuilder new file mode 100644 index 000000000..57bf82a8b --- /dev/null +++ b/app/views/api/schools/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.call(@school, :organisation_id, :owner_id, :name, :verified_at) diff --git a/config/routes.rb b/config/routes.rb index 4b7007e51..72bcdd055 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,8 @@ end resource :project_errors, only: %i[create] + + resources :schools, only: %i[create] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb new file mode 100644 index 000000000..bb5cf3ce3 --- /dev/null +++ b/spec/features/creating_a_school_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a school', type: :request do + let(:user_id) { '11111111-11111111-11111111-11111111' } + let(:headers) { { Authorization: 'dummy-token' } } + + let(:params) do + { + name: 'Test School', + organisation_id: '00000000-00000000-00000000-00000000' + } + end + + before do + stub_fetch_oauth_user_id(user_id) + end + + it 'responds 200 OK' do + post('/api/schools', headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school JSON' do + post('/api/schools', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School') + end + + it "assigns the current user as the school's owner" do + post('/api/schools', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:owner_id]).to eq(user_id) + end + + it 'responds 500 Server Error when params are invalid' do + post('/api/schools', headers:) + expect(response).to have_http_status(:internal_server_error) + end + + it 'responds 401 Unauthorized when no token is given' do + post '/api/schools' + expect(response).to have_http_status(:unauthorized) + end +end From 40f9d36d96585a23a0b71b0305888a395b0c28e7 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 5 Feb 2024 16:04:41 +0000 Subject: [PATCH 011/124] Add address fields to School https://raspberrypifoundation.slack.com/archives/C02JBAA2NFP/p1707141511357779?thread_ts=1707139201.904629&cid=C02JBAA2NFP --- app/controllers/api/schools_controller.rb | 11 ++++++++++- app/models/school.rb | 3 +++ app/views/api/schools/show.json.jbuilder | 14 +++++++++++++- db/migrate/20240201160923_create_schools.rb | 8 ++++++++ db/schema.rb | 6 ++++++ spec/concepts/school/create_spec.rb | 5 ++++- spec/factories/school.rb | 3 +++ spec/features/creating_a_school_spec.rb | 5 ++++- spec/models/school_spec.rb | 15 +++++++++++++++ 9 files changed, 66 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 645337181..5de834a7f 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -19,7 +19,16 @@ def create private def school_params - params.permit(:name, :organisation_id) + params.permit( + :name, + :organisation_id, + :address_line_1, # rubocop:disable Naming/VariableNumber + :address_line_2, # rubocop:disable Naming/VariableNumber + :municipality, + :administrative_area, + :postal_code, + :country_code + ) end end end diff --git a/app/models/school.rb b/app/models/school.rb index 89cb7534e..cf2ca206c 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -6,4 +6,7 @@ class School < ApplicationRecord validates :organisation_id, presence: true, uniqueness: { case_sensitive: false } validates :owner_id, presence: true validates :name, presence: true + validates :address_line_1, presence: true # rubocop:disable Naming/VariableNumber + validates :municipality, presence: true + validates :country_code, presence: true end diff --git a/app/views/api/schools/show.json.jbuilder b/app/views/api/schools/show.json.jbuilder index 57bf82a8b..db0c97c15 100644 --- a/app/views/api/schools/show.json.jbuilder +++ b/app/views/api/schools/show.json.jbuilder @@ -1,3 +1,15 @@ # frozen_string_literal: true -json.call(@school, :organisation_id, :owner_id, :name, :verified_at) +json.call( + @school, + :organisation_id, + :owner_id, + :address_line_1, # rubocop:disable Naming/VariableNumber + :address_line_2, # rubocop:disable Naming/VariableNumber + :municipality, + :administrative_area, + :postal_code, + :country_code, + :name, + :verified_at +) diff --git a/db/migrate/20240201160923_create_schools.rb b/db/migrate/20240201160923_create_schools.rb index 49c90e5f6..e13df6a04 100644 --- a/db/migrate/20240201160923_create_schools.rb +++ b/db/migrate/20240201160923_create_schools.rb @@ -4,6 +4,14 @@ def change t.uuid :organisation_id, null: false t.uuid :owner_id, null: false t.string :name, null: false + + t.string :address_line_1, null: false + t.string :address_line_2 + t.string :municipality, null: false + t.string :administrative_area + t.string :postal_code + t.string :country_code, null: false + t.datetime :verified_at t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 13ee4ac5f..87b1e5294 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -163,6 +163,12 @@ t.uuid "organisation_id", null: false t.uuid "owner_id", null: false t.string "name", null: false + t.string "address_line_1", null: false + t.string "address_line_2" + t.string "municipality", null: false + t.string "administrative_area" + t.string "postal_code" + t.string "country_code", null: false t.datetime "verified_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb index 11526ecf6..77cabdaa6 100644 --- a/spec/concepts/school/create_spec.rb +++ b/spec/concepts/school/create_spec.rb @@ -7,7 +7,10 @@ { name: 'Test School', organisation_id: '00000000-00000000-00000000-00000000', - owner_id: '11111111-11111111-11111111-11111111' + owner_id: '11111111-11111111-11111111-11111111', + address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber + municipality: 'Greater London', + country_code: 'GB' } end diff --git a/spec/factories/school.rb b/spec/factories/school.rb index f24e346aa..a2fecce53 100644 --- a/spec/factories/school.rb +++ b/spec/factories/school.rb @@ -5,5 +5,8 @@ organisation_id { SecureRandom.uuid } owner_id { SecureRandom.uuid } sequence(:name) { |n| "School #{n}" } + address_line_1 { 'Address Line 1' } + municipality { 'Greater London' } + country_code { 'GB' } end end diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index bb5cf3ce3..3e4c48de3 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -9,7 +9,10 @@ let(:params) do { name: 'Test School', - organisation_id: '00000000-00000000-00000000-00000000' + organisation_id: '00000000-00000000-00000000-00000000', + address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber + municipality: 'Greater London', + country_code: 'GB' } end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 8c01543f4..419ca5530 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -67,5 +67,20 @@ school.name = ' ' expect(school).to be_invalid end + + it 'requires an address_line_1' do + school.address_line_1 = ' ' + expect(school).to be_invalid + end + + it 'requires a municipality' do + school.municipality = ' ' + expect(school).to be_invalid + end + + it 'requires a country_code' do + school.country_code = ' ' + expect(school).to be_invalid + end end end From cabbebb64742454545898368e3d54afa8129ede3 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 5 Feb 2024 16:15:17 +0000 Subject: [PATCH 012/124] Add the countries gem --- Gemfile | 1 + Gemfile.lock | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Gemfile b/Gemfile index dc31b7ef8..b99a1b5b2 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ ruby '~> 3.2.0' gem 'aws-sdk-s3', require: false gem 'bootsnap', require: false gem 'cancancan', '~> 3.3' +gem 'countries' gem 'faraday' gem 'github_webhook', '~> 1.4' gem 'globalid' diff --git a/Gemfile.lock b/Gemfile.lock index c5b30421b..ab1edd839 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,6 +96,8 @@ GEM climate_control (1.2.0) coderay (1.1.3) concurrent-ruby (1.2.2) + countries (5.7.1) + unaccent (~> 0.3) crack (0.4.5) rexml crass (1.0.6) @@ -327,6 +329,7 @@ GEM timeout (0.3.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unaccent (0.4.0) unicode-display_width (2.4.2) uniform_notifier (1.16.0) uri (0.12.0) @@ -351,6 +354,7 @@ DEPENDENCIES bullet cancancan (~> 3.3) climate_control + countries dotenv-rails factory_bot_rails faker From 2d22484e9b2c5b3a966a9962b33f80f6d922c8ac Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 5 Feb 2024 16:17:59 +0000 Subject: [PATCH 013/124] Validate that country_codes are ISO 3166-1 alpha-2 --- app/models/school.rb | 2 +- spec/models/school_spec.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/models/school.rb b/app/models/school.rb index cf2ca206c..29d4126e5 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -8,5 +8,5 @@ class School < ApplicationRecord validates :name, presence: true validates :address_line_1, presence: true # rubocop:disable Naming/VariableNumber validates :municipality, presence: true - validates :country_code, presence: true + validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes } end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 419ca5530..ea1d9d199 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -82,5 +82,10 @@ school.country_code = ' ' expect(school).to be_invalid end + + it "requires an 'ISO 3166-1 alpha-2' country_code" do + school.country_code = 'GBR' + expect(school).to be_invalid + end end end From 70280886c0651fceeea0a37aaa4cb79b6fd69485 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 6 Feb 2024 13:30:48 +0000 Subject: [PATCH 014/124] Respond with either 400 or 422 depending on the request params --- app/controllers/api/schools_controller.rb | 2 +- app/controllers/api_controller.rb | 9 +++++++-- spec/features/creating_a_school_spec.rb | 9 +++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 5de834a7f..c162bae93 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -12,7 +12,7 @@ def create @school = result[:school] render :show, formats: [:json] else - render json: { error: result[:error] }, status: :internal_server_error + render json: { error: result[:error] }, status: :unprocessable_entity end end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index dd7b4bf36..9c1a4f5ba 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -4,7 +4,8 @@ class ApiController < ActionController::API include Identifiable unless Rails.application.config.consider_all_requests_local - rescue_from ActiveRecord::RecordNotFound, with: -> { notfound } + rescue_from ActionController::ParameterMissing, with: -> { bad_request } + rescue_from ActiveRecord::RecordNotFound, with: -> { not_found } rescue_from CanCan::AccessDenied, with: -> { denied } end @@ -14,7 +15,11 @@ def authorize_user head :unauthorized unless current_user end - def notfound + def bad_request + head :bad_request + end + + def not_found head :not_found end diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index 3e4c48de3..59a7829e0 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -39,9 +39,14 @@ expect(data[:owner_id]).to eq(user_id) end - it 'responds 500 Server Error when params are invalid' do + it 'responds 400 Bad Request when params are missing' do post('/api/schools', headers:) - expect(response).to have_http_status(:internal_server_error) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post('/api/schools', headers:, params: { name: ' ' }) + expect(response).to have_http_status(:unprocessable_entity) end it 'responds 401 Unauthorized when no token is given' do From 07672753598c3932c6025bc26892ea7c279d55f2 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 6 Feb 2024 13:31:13 +0000 Subject: [PATCH 015/124] =?UTF-8?q?Nest=20params=20inside=20a=20=E2=80=98s?= =?UTF-8?q?chool=E2=80=99=20hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/api/schools_controller.rb | 2 +- spec/features/creating_a_school_spec.rb | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index c162bae93..c26aeb411 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -19,7 +19,7 @@ def create private def school_params - params.permit( + params.require(:school).permit( :name, :organisation_id, :address_line_1, # rubocop:disable Naming/VariableNumber diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index 59a7829e0..c8f0c7825 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -8,11 +8,13 @@ let(:params) do { - name: 'Test School', - organisation_id: '00000000-00000000-00000000-00000000', - address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber - municipality: 'Greater London', - country_code: 'GB' + school: { + name: 'Test School', + organisation_id: '00000000-00000000-00000000-00000000', + address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber + municipality: 'Greater London', + country_code: 'GB' + } } end @@ -45,7 +47,7 @@ end it 'responds 422 Unprocessable Entity when params are invalid' do - post('/api/schools', headers:, params: { name: ' ' }) + post('/api/schools', headers:, params: { school: { name: ' ' } }) expect(response).to have_http_status(:unprocessable_entity) end From 6b9c3727e5eeb38cdd55ff9674462d9d49218337 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 6 Feb 2024 13:35:29 +0000 Subject: [PATCH 016/124] Add the validation errors to the School::Create response object --- lib/concepts/school/operations/create.rb | 3 ++- spec/concepts/school/create_spec.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/concepts/school/operations/create.rb b/lib/concepts/school/operations/create.rb index 931f1e433..b76959c9b 100644 --- a/lib/concepts/school/operations/create.rb +++ b/lib/concepts/school/operations/create.rb @@ -10,7 +10,8 @@ def call(school_hash:) response rescue StandardError => e Sentry.capture_exception(e) - response[:error] = 'Error creating school' + errors = response[:school].errors.full_messages.join(',') + response[:error] = "Error creating school: #{errors}" response end diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb index 77cabdaa6..befe8d245 100644 --- a/spec/concepts/school/create_spec.rb +++ b/spec/concepts/school/create_spec.rb @@ -51,7 +51,7 @@ it 'returns the error message in the operation response' do response = described_class.call(school_hash:) - expect(response[:error]).to eq('Error creating school') + expect(response[:error]).to match(/Error creating school/) end it 'sent the exception to Sentry' do From 1601f840ba4f957c990a0fe14f9d89366e441fdd Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 9 Feb 2024 13:58:40 +0000 Subject: [PATCH 017/124] Fix specs after rebasing against the new User model --- app/controllers/api/schools_controller.rb | 2 +- spec/features/creating_a_school_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index c26aeb411..303b35dc0 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -5,7 +5,7 @@ class SchoolsController < ApiController before_action :authorize_user, only: %i[create] def create - school_hash = school_params.merge(owner_id: current_user) + school_hash = school_params.merge(owner_id: current_user.id) result = School::Create.call(school_hash:) if result.success? diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index c8f0c7825..4931c3d5b 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe 'Creating a school', type: :request do - let(:user_id) { '11111111-11111111-11111111-11111111' } + let(:user_id) { stubbed_user_id } let(:headers) { { Authorization: 'dummy-token' } } let(:params) do @@ -19,7 +19,7 @@ end before do - stub_fetch_oauth_user_id(user_id) + stub_fetch_oauth_user end it 'responds 200 OK' do From f9886d25a67eee84bb9c8b019f1edeccbf884484 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 9 Feb 2024 14:08:32 +0000 Subject: [PATCH 018/124] Change the users.json mock data to include the new school roles --- lib/hydra_public_api_client.rb | 2 +- spec/fixtures/users.json | 52 +++++++++++++++++++++++----------- spec/models/user_spec.rb | 20 ++++++------- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/lib/hydra_public_api_client.rb b/lib/hydra_public_api_client.rb index c6397e9e8..d07f4ad96 100644 --- a/lib/hydra_public_api_client.rb +++ b/lib/hydra_public_api_client.rb @@ -4,7 +4,7 @@ class HydraPublicApiClient API_URL = ENV.fetch('https://github.com/HYDRA_PUBLIC_URL', 'http://localhost:9001/') - BYPASS_AUTH_USER_ID = 'b6301f34-b970-4d4f-8314-f877bad8b150' + BYPASS_AUTH_USER_ID = '00000000-0000-0000-0000-000000000000' class << self def fetch_oauth_user(...) diff --git a/spec/fixtures/users.json b/spec/fixtures/users.json index 656b19173..8ebe3e6d5 100644 --- a/spec/fixtures/users.json +++ b/spec/fixtures/users.json @@ -1,39 +1,57 @@ { "users": [ { - "id": "b6301f34-b970-4d4f-8314-f877bad8b150", - "email": "jane.doe@example.com", + "id": "00000000-0000-0000-0000-000000000000", + "email": "school-owner@example.com", "username": null, "parentalEmail": null, - "name": "Jane Doe", - "nickname": "Jane", + "name": "School Owner", + "nickname": "Owner", "country": "United Kingdom", "country_code": "GB", "postcode": null, "dateOfBirth": null, - "verifiedAt": "2023-08-14T11:02:27.038Z", - "createdAt": "2023-08-14T11:02:27.038Z", - "updatedAt": "2023-08-14T13:41:46.874Z", + "verifiedAt": "2024-01-01T12:00:00.000Z", + "createdAt": "2024-01-01T12:00:00.000Z", + "updatedAt": "2024-01-01T12:00:00.000Z", "discardedAt": null, - "lastLoggedInAt": "2023-08-14T13:41:46.863Z", - "roles": "school-teacher" + "lastLoggedInAt": "2024-01-01T12:00:00.000Z", + "roles": "school-owner" }, { - "id": "bbb9b8fd-f357-4238-983d-6f87b99bdbb2", - "email": "john.doe@example.com", + "id": "11111111-1111-1111-1111-111111111111", + "email": "school-teacher@example.com", "username": null, "parentalEmail": null, - "name": "John Doe", - "nickname": "John", + "name": "School Teacher", + "nickname": "Teacher", + "country": "United Kingdom", + "country_code": "GB", + "postcode": null, + "dateOfBirth": null, + "verifiedAt": "2024-01-01T12:00:00.000Z", + "createdAt": "2024-01-01T12:00:00.000Z", + "updatedAt": "2024-01-01T12:00:00.000Z", + "discardedAt": null, + "lastLoggedInAt": "2024-01-01T12:00:00.000Z", + "roles": "school-teacher" + }, + { + "id": "22222222-2222-2222-22222222222222222", + "email": null, + "username": "student123", + "parentalEmail": null, + "name": "School Student", + "nickname": "Student", "country": "United Kingdom", "country_code": "GB", "postcode": null, "dateOfBirth": null, - "verifiedAt": "2023-08-14T11:02:27.038Z", - "createdAt": "2023-08-14T11:02:27.038Z", - "updatedAt": "2023-09-18T06:53:02.312Z", + "verifiedAt": "2024-01-01T12:00:00.000Z", + "createdAt": "2024-01-01T12:00:00.000Z", + "updatedAt": "2024-01-01T12:00:00.000Z", "discardedAt": null, - "lastLoggedInAt": "2023-09-18T06:53:02.277Z", + "lastLoggedInAt": "2024-01-01T12:00:00.000Z", "roles": "school-student" } ] diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 31c70c770..01c4bc403 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -75,19 +75,19 @@ end it 'returns a user with the correct ID' do - expect(user.id).to eq 'b6301f34-b970-4d4f-8314-f877bad8b150' + expect(user.id).to eq '00000000-0000-0000-0000-000000000000' end it 'returns a user with the correct name' do - expect(user.name).to eq 'Jane Doe' + expect(user.name).to eq 'School Owner' end it 'returns a user with the correct email' do - expect(user.email).to eq 'jane.doe@example.com' + expect(user.email).to eq 'school-owner@example.com' end it 'returns a user with the correct roles' do - expect(user.roles).to eq 'school-teacher' + expect(user.roles).to eq 'school-owner' end context 'when BYPASS_AUTH is true' do @@ -103,7 +103,7 @@ end it 'returns a stubbed user' do - expect(user.name).to eq('Jane Doe') + expect(user.name).to eq('School Owner') end end end @@ -127,7 +127,7 @@ end describe '#where' do - subject(:user) { described_class.where(id: 'b6301f34-b970-4d4f-8314-f877bad8b150').first } + subject(:user) { described_class.where(id: '00000000-0000-0000-0000-000000000000').first } let(:stubbed_response) { File.read('spec/fixtures/users.json') } @@ -149,15 +149,15 @@ end it 'returns a user with the correct ID' do - expect(user.id).to eq 'b6301f34-b970-4d4f-8314-f877bad8b150' + expect(user.id).to eq '00000000-0000-0000-0000-000000000000' end it 'returns a user with the correct name' do - expect(user.name).to eq 'Jane Doe' + expect(user.name).to eq 'School Owner' end it 'returns a user with the correct email' do - expect(user.email).to eq 'jane.doe@example.com' + expect(user.email).to eq 'school-owner@example.com' end context 'when BYPASS_AUTH is true' do @@ -173,7 +173,7 @@ end it 'returns a stubbed user' do - expect(user.name).to eq('Jane Doe') + expect(user.name).to eq('School Owner') end end end From a8d79461db63ad7d409f41b3031dbedbc4a2df44 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 9 Feb 2024 14:45:22 +0000 Subject: [PATCH 019/124] Extract methods to stub the hydra/userinfo APIs into spec/support --- app/models/user.rb | 2 +- spec/models/user_spec.rb | 37 ++++----------------------- spec/support/hydra_public_api_mock.rb | 37 ++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 151a5a0ee..6d4bb8b8f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -68,7 +68,7 @@ def self.from_omniauth(token:) auth = auth.stringify_keys args = auth.slice(*ATTRIBUTES) - args['id'] = auth['sub'] + args['id'] ||= auth['sub'] new(args) end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 01c4bc403..d8b5b4578 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -48,26 +48,10 @@ end describe '#from_omniauth' do - subject(:user) { described_class.from_omniauth(token: 'my-access-token') } - - let(:stubbed_response) do - json = File.read('spec/fixtures/users.json') - first = JSON.parse(json)['users'][0] - - first['sub'] = first.delete('id') - first.to_json - end - - let(:hydra_public_url) { HydraPublicApiClient::API_URL } + subject(:user) { described_class.from_omniauth(token: 'access-token') } before do - stub_request(:get, "#{hydra_public_url}/userinfo") - .with(headers: { Authorization: 'Bearer my-access-token' }) - .to_return( - status: 200, - headers: { content_type: 'application/json' }, - body: stubbed_response - ) + stub_hydra_public_api end it 'returns an instance of the described class' do @@ -99,7 +83,7 @@ it 'does not call the API' do user - expect(WebMock).not_to have_requested(:get, "#{hydra_public_url}/userinfo") + expect(WebMock).not_to have_requested(:get, /.*/) end it 'returns a stubbed user' do @@ -129,19 +113,8 @@ describe '#where' do subject(:user) { described_class.where(id: '00000000-0000-0000-0000-000000000000').first } - let(:stubbed_response) { File.read('spec/fixtures/users.json') } - - let(:userinfo_api_url) { UserinfoApiClient::API_URL } - let(:userinfo_api_key) { UserinfoApiClient::API_KEY } - before do - stub_request(:get, "#{userinfo_api_url}/users") - .with(headers: { Authorization: "Bearer #{userinfo_api_key}" }) - .to_return( - status: 200, - headers: { content_type: 'application/json' }, - body: stubbed_response - ) + stub_userinfo_api end it 'returns an instance of the described class' do @@ -169,7 +142,7 @@ it 'does not call the API' do user - expect(WebMock).not_to have_requested(:get, "#{userinfo_api_url}/users") + expect(WebMock).not_to have_requested(:get, /.*/) end it 'returns a stubbed user' do diff --git a/spec/support/hydra_public_api_mock.rb b/spec/support/hydra_public_api_mock.rb index 4dcde6a61..7d02612c4 100644 --- a/spec/support/hydra_public_api_mock.rb +++ b/spec/support/hydra_public_api_mock.rb @@ -3,21 +3,46 @@ module HydraPublicApiMock USERS = File.read('spec/fixtures/users.json') + # Stubs that API that returns user profile data for a given list of UUIDs. + def stub_userinfo_api + stub_request(:get, "#{UserinfoApiClient::API_URL}/users") + .with(headers: { Authorization: "Bearer #{UserinfoApiClient::API_KEY}" }) + .to_return do |request| + user_ids = JSON.parse(request.body).fetch('https://github.com/userIds', []) + indexes = user_ids.map { |user_id| stubbed_user_index(user_id:) }.compact + users = indexes.map { |user_index| stubbed_user_attributes(user_index:) } + + { body: { users: }.to_json, headers: { 'Content-Type' => 'application/json' } } + end + end + + # Stubs the API that returns user profile data for the logged in user. + def stub_hydra_public_api(user_index: 0, token: 'access-token') + stub_request(:get, "#{HydraPublicApiClient::API_URL}/userinfo") + .with(headers: { Authorization: "Bearer #{token}" }) + .to_return( + status: 200, + headers: { content_type: 'application/json' }, + body: stubbed_user_attributes(user_index:).to_json + ) + end + + # Stubs the API *client* that returns user profile data for the logged in user. def stub_fetch_oauth_user(user_index: 0) attributes = stubbed_user_attributes(user_index:) allow(HydraPublicApiClient).to receive(:fetch_oauth_user).and_return(attributes) end def stubbed_user_attributes(user_index: 0) - return nil unless user_index - - attributes = JSON.parse(USERS)['users'][user_index] - attributes['sub'] = attributes.delete('id') - attributes + JSON.parse(USERS)['users'][user_index] if user_index end def stubbed_user_id(user_index: 0) - stubbed_user_attributes(user_index:)&.fetch('https://github.com/sub') + stubbed_user_attributes(user_index:)&.fetch('https://github.com/id') + end + + def stubbed_user_index(user_id: '00000000-0000-0000-0000-000000000000') + JSON.parse(USERS)['users'].find_index { |attributes| attributes['id'] == user_id } end def stubbed_user From 457c218d4adc414a234673291fc23c46453b1e63 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 9 Feb 2024 14:55:18 +0000 Subject: [PATCH 020/124] Rename HydraPublicApiMock -> UserProfileMock --- spec/rails_helper.rb | 2 +- spec/support/{hydra_public_api_mock.rb => user_profile_mock.rb} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename spec/support/{hydra_public_api_mock.rb => user_profile_mock.rb} (98%) diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 109b93f3d..290dba32d 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -83,7 +83,7 @@ config.include GraphqlQueryHelpers, type: :graphql_query config.include PhraseIdentifierMock - config.include HydraPublicApiMock + config.include UserProfileMock if Bullet.enable? config.before { Bullet.start_request } diff --git a/spec/support/hydra_public_api_mock.rb b/spec/support/user_profile_mock.rb similarity index 98% rename from spec/support/hydra_public_api_mock.rb rename to spec/support/user_profile_mock.rb index 7d02612c4..504b576e0 100644 --- a/spec/support/hydra_public_api_mock.rb +++ b/spec/support/user_profile_mock.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module HydraPublicApiMock +module UserProfileMock USERS = File.read('spec/fixtures/users.json') # Stubs that API that returns user profile data for a given list of UUIDs. From cbcac3492b5c36b67c8ce6d0554f1f005ac11681 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 9 Feb 2024 15:06:38 +0000 Subject: [PATCH 021/124] Remove support for stubbing Hydra via the Ruby client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #stub_fetch_oauth_user methods seems superfluous since we can already stub HydraPublicApi requests at the HTTP level. This exercises more of the code so I think it’s better to remove this method. --- app/models/user.rb | 2 +- spec/features/creating_a_school_spec.rb | 4 ++-- .../mutations/create_component_mutation_spec.rb | 2 +- .../graphql/mutations/create_project_mutation_spec.rb | 2 +- .../graphql/mutations/delete_project_mutation_spec.rb | 4 ++-- spec/graphql/mutations/remix_project_mutation_spec.rb | 4 ++-- .../mutations/update_component_mutation_spec.rb | 4 ++-- .../graphql/mutations/update_project_mutation_spec.rb | 4 ++-- spec/graphql/queries/project_query_spec.rb | 2 +- spec/graphql/queries/projects_query_spec.rb | 4 ++-- spec/models/user_spec.rb | 2 +- spec/requests/graphql_spec.rb | 6 +++--- spec/requests/projects/create_spec.rb | 6 +++--- spec/requests/projects/destroy_spec.rb | 4 ++-- spec/requests/projects/images_spec.rb | 8 ++++---- spec/requests/projects/index_spec.rb | 6 +++--- spec/requests/projects/remix_spec.rb | 11 ++++++++--- spec/requests/projects/show_spec.rb | 4 ++-- spec/requests/projects/update_spec.rb | 8 ++++---- spec/support/user_profile_mock.rb | 11 +++-------- 20 files changed, 49 insertions(+), 49 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 6d4bb8b8f..150fea9f6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -64,7 +64,7 @@ def self.from_omniauth(token:) return nil if token.blank? auth = HydraPublicApiClient.fetch_oauth_user(token:) - return nil unless auth + return nil if auth.blank? auth = auth.stringify_keys args = auth.slice(*ATTRIBUTES) diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index 4931c3d5b..d4d5e5721 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -4,7 +4,7 @@ RSpec.describe 'Creating a school', type: :request do let(:user_id) { stubbed_user_id } - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:params) do { @@ -19,7 +19,7 @@ end before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'responds 200 OK' do diff --git a/spec/graphql/mutations/create_component_mutation_spec.rb b/spec/graphql/mutations/create_component_mutation_spec.rb index fbeed1431..caca695ce 100644 --- a/spec/graphql/mutations/create_component_mutation_spec.rb +++ b/spec/graphql/mutations/create_component_mutation_spec.rb @@ -47,7 +47,7 @@ let(:project) { create(:project, user_id: stubbed_user_id) } before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'returns the component ID' do diff --git a/spec/graphql/mutations/create_project_mutation_spec.rb b/spec/graphql/mutations/create_project_mutation_spec.rb index 84939a72e..5dc3bc063 100644 --- a/spec/graphql/mutations/create_project_mutation_spec.rb +++ b/spec/graphql/mutations/create_project_mutation_spec.rb @@ -45,7 +45,7 @@ let(:current_user) { stubbed_user } before do - stub_fetch_oauth_user + stub_hydra_public_api mock_phrase_generation end diff --git a/spec/graphql/mutations/delete_project_mutation_spec.rb b/spec/graphql/mutations/delete_project_mutation_spec.rb index e98f49eed..1472139a7 100644 --- a/spec/graphql/mutations/delete_project_mutation_spec.rb +++ b/spec/graphql/mutations/delete_project_mutation_spec.rb @@ -37,7 +37,7 @@ let(:current_user) { stubbed_user } before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'deletes the project' do @@ -55,7 +55,7 @@ context 'with another users project' do before do - stub_fetch_oauth_user(user_index: 1) + stub_hydra_public_api(user_index: 1) end it 'returns an error' do diff --git a/spec/graphql/mutations/remix_project_mutation_spec.rb b/spec/graphql/mutations/remix_project_mutation_spec.rb index 6e0aac9e6..ea66132e6 100644 --- a/spec/graphql/mutations/remix_project_mutation_spec.rb +++ b/spec/graphql/mutations/remix_project_mutation_spec.rb @@ -23,7 +23,7 @@ before do project - stub_fetch_oauth_user + stub_hydra_public_api end it { expect(mutation).to be_a_valid_graphql_query } @@ -54,7 +54,7 @@ context 'when user cannot view original project' do before do - stub_fetch_oauth_user(user_index: 1) + stub_hydra_public_api(user_index: 1) end it 'returns "not permitted to read" error' do diff --git a/spec/graphql/mutations/update_component_mutation_spec.rb b/spec/graphql/mutations/update_component_mutation_spec.rb index 21b9f49a7..4d981067b 100644 --- a/spec/graphql/mutations/update_component_mutation_spec.rb +++ b/spec/graphql/mutations/update_component_mutation_spec.rb @@ -29,7 +29,7 @@ before do # Instantiate component component - stub_fetch_oauth_user + stub_hydra_public_api end context 'when unauthenticated' do @@ -79,7 +79,7 @@ context 'with another users component' do before do - stub_fetch_oauth_user(user_index: 1) + stub_hydra_public_api(user_index: 1) end it 'returns an error' do diff --git a/spec/graphql/mutations/update_project_mutation_spec.rb b/spec/graphql/mutations/update_project_mutation_spec.rb index be10f1023..e92adfb83 100644 --- a/spec/graphql/mutations/update_project_mutation_spec.rb +++ b/spec/graphql/mutations/update_project_mutation_spec.rb @@ -26,7 +26,7 @@ before do # Instantiate project project - stub_fetch_oauth_user + stub_hydra_public_api end context 'when unauthenticated' do @@ -68,7 +68,7 @@ context 'with another users project' do before do - stub_fetch_oauth_user(user_index: 1) + stub_hydra_public_api(user_index: 1) end it 'returns an error' do diff --git a/spec/graphql/queries/project_query_spec.rb b/spec/graphql/queries/project_query_spec.rb index 028c7aa33..fbe2f5b17 100644 --- a/spec/graphql/queries/project_query_spec.rb +++ b/spec/graphql/queries/project_query_spec.rb @@ -92,7 +92,7 @@ let(:project) { create(:project, user_id: stubbed_user_id) } before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'returns the project global id' do diff --git a/spec/graphql/queries/projects_query_spec.rb b/spec/graphql/queries/projects_query_spec.rb index b037724cc..a7962fd72 100644 --- a/spec/graphql/queries/projects_query_spec.rb +++ b/spec/graphql/queries/projects_query_spec.rb @@ -49,7 +49,7 @@ let(:project) { create(:project, user_id: stubbed_user_id) } before do - stub_fetch_oauth_user + stub_hydra_public_api end it { expect(query).to be_a_valid_graphql_query } @@ -87,7 +87,7 @@ let(:project) { create(:project, user_id: stubbed_user_id) } before do - stub_fetch_oauth_user + stub_hydra_public_api end it { expect(query).to be_a_valid_graphql_query } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d8b5b4578..2e13e6925 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -48,7 +48,7 @@ end describe '#from_omniauth' do - subject(:user) { described_class.from_omniauth(token: 'access-token') } + subject(:user) { described_class.from_omniauth(token: UserProfileMock::TOKEN) } before do stub_hydra_public_api diff --git a/spec/requests/graphql_spec.rb b/spec/requests/graphql_spec.rb index 09f9b1466..88caeb6f6 100644 --- a/spec/requests/graphql_spec.rb +++ b/spec/requests/graphql_spec.rb @@ -64,11 +64,11 @@ it_behaves_like 'an unidentified request' context 'with a token' do - let(:token) { 'valid-token' } + let(:token) { UserProfileMock::TOKEN } context 'when the token is invalid' do before do - stub_fetch_oauth_user(user_index: nil) + stub_hydra_public_api(user_index: nil) end it_behaves_like 'an unidentified request' @@ -76,7 +76,7 @@ context 'when the token is valid' do before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'sets the current_user in the context' do diff --git a/spec/requests/projects/create_spec.rb b/spec/requests/projects/create_spec.rb index cdb9c8c0b..e2b4e395b 100644 --- a/spec/requests/projects/create_spec.rb +++ b/spec/requests/projects/create_spec.rb @@ -6,11 +6,11 @@ let(:project) { create(:project, user_id: stubbed_user_id) } context 'when auth is correct' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } context 'when creating project is successful' do before do - stub_fetch_oauth_user + stub_hydra_public_api response = OperationResponse.new response[:project] = project @@ -26,7 +26,7 @@ context 'when creating project fails' do before do - stub_fetch_oauth_user + stub_hydra_public_api response = OperationResponse.new response[:error] = 'Error creating project' diff --git a/spec/requests/projects/destroy_spec.rb b/spec/requests/projects/destroy_spec.rb index 091e95a9c..236f728f2 100644 --- a/spec/requests/projects/destroy_spec.rb +++ b/spec/requests/projects/destroy_spec.rb @@ -5,10 +5,10 @@ RSpec.describe 'Project delete requests' do context 'when user is logged in' do let!(:project) { create(:project, user_id: stubbed_user_id, locale: nil) } - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_fetch_oauth_user + stub_hydra_public_api end context 'when deleting a project the user owns' do diff --git a/spec/requests/projects/images_spec.rb b/spec/requests/projects/images_spec.rb index 5c9b54b5b..bec90499b 100644 --- a/spec/requests/projects/images_spec.rb +++ b/spec/requests/projects/images_spec.rb @@ -19,10 +19,10 @@ describe 'create' do context 'when auth is correct' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'attaches file to project' do @@ -49,10 +49,10 @@ end context 'when authed user is not creator' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_fetch_oauth_user(user_index: 1) + stub_hydra_public_api(user_index: 1) end it 'returns forbidden response' do diff --git a/spec/requests/projects/index_spec.rb b/spec/requests/projects/index_spec.rb index 1ef1d2b3c..b7e4ec3a7 100644 --- a/spec/requests/projects/index_spec.rb +++ b/spec/requests/projects/index_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Project index requests' do include PaginationLinksMock - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:project_keys) { %w[identifier project_type name user_id updated_at] } before do @@ -16,7 +16,7 @@ before do # create non user projects create_list(:project, 2) - stub_fetch_oauth_user + stub_hydra_public_api end it 'returns success response' do @@ -45,7 +45,7 @@ context 'when the projects index has pagination' do before do - stub_fetch_oauth_user + stub_hydra_public_api create_list(:project, 10, user_id: stubbed_user_id) end diff --git a/spec/requests/projects/remix_spec.rb b/spec/requests/projects/remix_spec.rb index 4da8cff30..8e194f355 100644 --- a/spec/requests/projects/remix_spec.rb +++ b/spec/requests/projects/remix_spec.rb @@ -17,10 +17,15 @@ end context 'when auth is correct' do - let(:headers) { { Authorization: 'dummy-token', Origin: 'editor.com' } } + let(:headers) do + { + Authorization: UserProfileMock::TOKEN, + Origin: 'editor.com' + } + end before do - stub_fetch_oauth_user + stub_hydra_public_api end describe '#show' do @@ -57,7 +62,7 @@ context 'when project cannot be saved' do before do - stub_fetch_oauth_user + stub_hydra_public_api error_response = OperationResponse.new error_response[:error] = 'Something went wrong' allow(Project::CreateRemix).to receive(:call).and_return(error_response) diff --git a/spec/requests/projects/show_spec.rb b/spec/requests/projects/show_spec.rb index 293b465bc..b08a168f2 100644 --- a/spec/requests/projects/show_spec.rb +++ b/spec/requests/projects/show_spec.rb @@ -18,10 +18,10 @@ let(:headers) { {} } context 'when user is logged in' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_fetch_oauth_user + stub_hydra_public_api end context 'when loading own project' do diff --git a/spec/requests/projects/update_spec.rb b/spec/requests/projects/update_spec.rb index e06fa88ca..63a541fd3 100644 --- a/spec/requests/projects/update_spec.rb +++ b/spec/requests/projects/update_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe 'Project update requests' do - let(:headers) { { Authorization: 'dummy-token' } } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } context 'when authed user is project creator' do let(:project) { create(:project, :with_default_component, user_id: stubbed_user_id, locale: nil) } @@ -26,7 +26,7 @@ end before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'returns success response' do @@ -81,7 +81,7 @@ let(:params) { { project: { components: [] } } } before do - stub_fetch_oauth_user + stub_hydra_public_api end it 'returns forbidden response' do @@ -94,7 +94,7 @@ let(:project) { create(:project) } before do - stub_fetch_oauth_user(user_index: nil) + stub_hydra_public_api(user_index: nil) end it 'returns unauthorized' do diff --git a/spec/support/user_profile_mock.rb b/spec/support/user_profile_mock.rb index 504b576e0..a2f4ebfd2 100644 --- a/spec/support/user_profile_mock.rb +++ b/spec/support/user_profile_mock.rb @@ -2,6 +2,7 @@ module UserProfileMock USERS = File.read('spec/fixtures/users.json') + TOKEN = 'fake-user-access-token' # Stubs that API that returns user profile data for a given list of UUIDs. def stub_userinfo_api @@ -17,7 +18,7 @@ def stub_userinfo_api end # Stubs the API that returns user profile data for the logged in user. - def stub_hydra_public_api(user_index: 0, token: 'access-token') + def stub_hydra_public_api(user_index: 0, token: TOKEN) stub_request(:get, "#{HydraPublicApiClient::API_URL}/userinfo") .with(headers: { Authorization: "Bearer #{token}" }) .to_return( @@ -27,12 +28,6 @@ def stub_hydra_public_api(user_index: 0, token: 'access-token') ) end - # Stubs the API *client* that returns user profile data for the logged in user. - def stub_fetch_oauth_user(user_index: 0) - attributes = stubbed_user_attributes(user_index:) - allow(HydraPublicApiClient).to receive(:fetch_oauth_user).and_return(attributes) - end - def stubbed_user_attributes(user_index: 0) JSON.parse(USERS)['users'][user_index] if user_index end @@ -46,6 +41,6 @@ def stubbed_user_index(user_id: '00000000-0000-0000-0000-000000000000') end def stubbed_user - User.from_omniauth(token: 'ignored') + User.from_omniauth(token: TOKEN) end end From a7767a50f29fcf4a11a6f07d723dc5ae884699a4 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 9 Feb 2024 15:30:56 +0000 Subject: [PATCH 022/124] Only allow school owners to create schools As part of the verification flow, the user should have accepted the responsibilities of a school owner by the time they come to create the school. We need make sure we assign the school-owner role to them at this point, which would also require the organisation to be created so that we can assign the organisation_id on the Assignment in profile. --- app/controllers/api/schools_controller.rb | 1 + app/models/ability.rb | 2 ++ spec/features/creating_a_school_spec.rb | 7 +++++++ spec/support/user_profile_mock.rb | 16 ++++++++++------ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 303b35dc0..0c8d25e5b 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -3,6 +3,7 @@ module Api class SchoolsController < ApiController before_action :authorize_user, only: %i[create] + load_and_authorize_resource def create school_hash = school_params.merge(owner_id: current_user.id) diff --git a/app/models/ability.rb b/app/models/ability.rb index 3d05e7815..1cc3105b4 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -11,5 +11,7 @@ def initialize(user) can %i[read create update destroy], Project, user_id: user.id can %i[read create update destroy], Component, project: { user_id: user.id } + + can %i[create], School if user.school_owner? end end diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index d4d5e5721..88eb8deb8 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -55,4 +55,11 @@ post '/api/schools' expect(response).to have_http_status(:unauthorized) end + + it 'responds 403 Forbidden when the user is not a school-owner' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post('/api/schools', headers:, params:) + expect(response).to have_http_status(:forbidden) + end end diff --git a/spec/support/user_profile_mock.rb b/spec/support/user_profile_mock.rb index a2f4ebfd2..822663567 100644 --- a/spec/support/user_profile_mock.rb +++ b/spec/support/user_profile_mock.rb @@ -9,8 +9,8 @@ def stub_userinfo_api stub_request(:get, "#{UserinfoApiClient::API_URL}/users") .with(headers: { Authorization: "Bearer #{UserinfoApiClient::API_KEY}" }) .to_return do |request| - user_ids = JSON.parse(request.body).fetch('https://github.com/userIds', []) - indexes = user_ids.map { |user_id| stubbed_user_index(user_id:) }.compact + uuids = JSON.parse(request.body).fetch('https://github.com/userIds', []) + indexes = uuids.map { |uuid| user_index_by_uuid(uuid) }.compact users = indexes.map { |user_index| stubbed_user_attributes(user_index:) } { body: { users: }.to_json, headers: { 'Content-Type' => 'application/json' } } @@ -36,11 +36,15 @@ def stubbed_user_id(user_index: 0) stubbed_user_attributes(user_index:)&.fetch('https://github.com/id') end - def stubbed_user_index(user_id: '00000000-0000-0000-0000-000000000000') - JSON.parse(USERS)['users'].find_index { |attributes| attributes['id'] == user_id } - end - def stubbed_user User.from_omniauth(token: TOKEN) end + + def user_index_by_uuid(uuid) + JSON.parse(USERS)['users'].find_index { |attr| attr['id'] == uuid } + end + + def user_index_by_role(name) + JSON.parse(USERS)['users'].find_index { |attr| attr['roles'].include?(name) } + end end From 1bbe4b0fd51e2bd386ee466e4ee2be2de56d8a3f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 9 Feb 2024 15:50:20 +0000 Subject: [PATCH 023/124] Add a school#reference field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In England, this will hold the URN of the school. We don’t know whether this will be available in all countries so allow null values but insist that it is unique if provided. --- app/controllers/api/schools_controller.rb | 1 + app/models/school.rb | 1 + db/migrate/20240201160923_create_schools.rb | 2 ++ db/schema.rb | 2 ++ spec/models/school_spec.rb | 16 ++++++++++++++++ 5 files changed, 22 insertions(+) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 0c8d25e5b..2db0405d3 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -22,6 +22,7 @@ def create def school_params params.require(:school).permit( :name, + :reference, :organisation_id, :address_line_1, # rubocop:disable Naming/VariableNumber :address_line_2, # rubocop:disable Naming/VariableNumber diff --git a/app/models/school.rb b/app/models/school.rb index 29d4126e5..97b726e68 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -6,6 +6,7 @@ class School < ApplicationRecord validates :organisation_id, presence: true, uniqueness: { case_sensitive: false } validates :owner_id, presence: true validates :name, presence: true + validates :reference, uniqueness: { case_sensitive: false, allow_nil: true } validates :address_line_1, presence: true # rubocop:disable Naming/VariableNumber validates :municipality, presence: true validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes } diff --git a/db/migrate/20240201160923_create_schools.rb b/db/migrate/20240201160923_create_schools.rb index e13df6a04..1c6a9e75b 100644 --- a/db/migrate/20240201160923_create_schools.rb +++ b/db/migrate/20240201160923_create_schools.rb @@ -4,6 +4,7 @@ def change t.uuid :organisation_id, null: false t.uuid :owner_id, null: false t.string :name, null: false + t.string :reference t.string :address_line_1, null: false t.string :address_line_2 @@ -17,5 +18,6 @@ def change end add_index :schools, :organisation_id, unique: true + add_index :schools, :reference, unique: true end end diff --git a/db/schema.rb b/db/schema.rb index 87b1e5294..44fb1ad66 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -163,6 +163,7 @@ t.uuid "organisation_id", null: false t.uuid "owner_id", null: false t.string "name", null: false + t.string "reference" t.string "address_line_1", null: false t.string "address_line_2" t.string "municipality", null: false @@ -173,6 +174,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["organisation_id"], name: "index_schools_on_organisation_id", unique: true + t.index ["reference"], name: "index_schools_on_reference", unique: true end create_table "words", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index ea1d9d199..a6fe7d1cf 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -68,6 +68,22 @@ expect(school).to be_invalid end + it 'does not require a reference' do + create(:school, reference: nil) + create(:school, reference: nil) + + school.reference = nil + expect(school).to be_valid + end + + it 'requires references to be unique if provided' do + school.reference = 'URN-123' + school.save! + + duplicate_school = build(:school, reference: 'urn-123') + expect(duplicate_school).to be_invalid + end + it 'requires an address_line_1' do school.address_line_1 = ' ' expect(school).to be_invalid From 4de1bfd5d33e7cb6b59e1f96493dfd7895bc8efc Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 15:18:22 +0000 Subject: [PATCH 024/124] Allow users to hold different roles in different organisations --- app/models/ability.rb | 4 ++- app/models/user.rb | 36 ++++++++++++++++++------- spec/factories/user.rb | 2 +- spec/features/creating_a_school_spec.rb | 9 ++++++- spec/fixtures/users.json | 15 ++++++++--- spec/models/user_spec.rb | 29 +++++++++++--------- 6 files changed, 66 insertions(+), 29 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index 1cc3105b4..1c382a125 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -12,6 +12,8 @@ def initialize(user) can %i[read create update destroy], Project, user_id: user.id can %i[read create update destroy], Component, project: { user_id: user.id } - can %i[create], School if user.school_owner? + user.organisation_ids.each do |organisation_id| + can(%i[create], School, organisation_id:) if user.school_owner?(organisation_id:) + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 150fea9f6..1212d0199 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,10 +11,10 @@ class User id name nickname + organisations picture postcode profile - roles ].freeze attr_accessor(*ATTRIBUTES) @@ -23,22 +23,25 @@ def attributes ATTRIBUTES.index_with { |_k| nil } end - def role?(role:) - return false if roles.nil? + def organisation_ids + organisations.keys + end - roles.to_s.split(',').map(&:strip).include? role.to_s + def role?(organisation_id:, role:) + roles = organisations[organisation_id.to_s] + roles.to_s.split(',').map(&:strip).include?(role.to_s) if roles end - def school_owner? - role?(role: 'school-owner') + def school_owner?(organisation_id:) + role?(organisation_id:, role: 'school-owner') end - def school_teacher? - role?(role: 'school-teacher') + def school_teacher?(organisation_id:) + role?(organisation_id:, role: 'school-teacher') end - def school_student? - role?(role: 'school-student') + def school_student?(organisation_id:) + role?(organisation_id:, role: 'school-student') end def ==(other) @@ -56,6 +59,9 @@ def self.from_userinfo(ids:) info = info.stringify_keys args = info.slice(*ATTRIBUTES) + # TODO: remove once the UserinfoApi returns the 'organisations' key. + temporarily_add_organisations_until_the_profile_app_is_updated(args) + new(args) end end @@ -70,6 +76,16 @@ def self.from_omniauth(token:) args = auth.slice(*ATTRIBUTES) args['id'] ||= auth['sub'] + # TODO: remove once the HydraPublicApi returns the 'organisations' key. + temporarily_add_organisations_until_the_profile_app_is_updated(args) + new(args) end + + def self.temporarily_add_organisations_until_the_profile_app_is_updated(hash) + return hash if hash.key?('organisations') + + # Use the same organisation ID as the one from users.json for now. + hash.merge('organisations', { '12345678-1234-1234-1234-123456789abc' => hash['roles'] }) + end end diff --git a/spec/factories/user.rb b/spec/factories/user.rb index b4b3bf787..f4cb76be3 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -4,8 +4,8 @@ factory :user do id { SecureRandom.uuid } name { Faker::Name.name } - roles { nil } email { Faker::Internet.email } + organisations { {} } skip_create end diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index 88eb8deb8..4f4e26ea3 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -10,7 +10,7 @@ { school: { name: 'Test School', - organisation_id: '00000000-00000000-00000000-00000000', + organisation_id: '12345678-1234-1234-1234-123456789abc', address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber municipality: 'Greater London', country_code: 'GB' @@ -62,4 +62,11 @@ post('/api/schools', headers:, params:) expect(response).to have_http_status(:forbidden) end + + it 'requires 403 Forbidden when the user is a school-owner for a different school' do + new_params = { school: params[:school].merge(organisation_id: '00000000-00000000-00000000-00000000') } + + post('/api/schools', headers:, params: new_params) + expect(response).to have_http_status(:forbidden) + end end diff --git a/spec/fixtures/users.json b/spec/fixtures/users.json index 8ebe3e6d5..3e1136270 100644 --- a/spec/fixtures/users.json +++ b/spec/fixtures/users.json @@ -16,7 +16,10 @@ "updatedAt": "2024-01-01T12:00:00.000Z", "discardedAt": null, "lastLoggedInAt": "2024-01-01T12:00:00.000Z", - "roles": "school-owner" + "roles": "school-owner", + "organisations": { + "12345678-1234-1234-1234-123456789abc": "school-owner" + } }, { "id": "11111111-1111-1111-1111-111111111111", @@ -34,7 +37,10 @@ "updatedAt": "2024-01-01T12:00:00.000Z", "discardedAt": null, "lastLoggedInAt": "2024-01-01T12:00:00.000Z", - "roles": "school-teacher" + "roles": "school-teacher", + "organisations": { + "12345678-1234-1234-1234-123456789abc": "school-teacher" + } }, { "id": "22222222-2222-2222-22222222222222222", @@ -52,7 +58,10 @@ "updatedAt": "2024-01-01T12:00:00.000Z", "discardedAt": null, "lastLoggedInAt": "2024-01-01T12:00:00.000Z", - "roles": "school-student" + "roles": "school-student", + "organisations": { + "12345678-1234-1234-1234-123456789abc": "school-student" + } } ] } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2e13e6925..504c9a5e6 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5,42 +5,45 @@ RSpec.describe User do subject { build(:user) } + let(:organisation_id) { '12345678-1234-1234-1234-123456789abc' } + it { is_expected.to respond_to(:id) } it { is_expected.to respond_to(:name) } it { is_expected.to respond_to(:email) } - it { is_expected.to respond_to(:roles) } + it { is_expected.to respond_to(:organisations) } + it { is_expected.to respond_to(:organisation_ids) } shared_examples 'role_check' do |role| - let(:roles) { nil } - let(:user) { build(:user, roles:) } + let(:organisations) { {} } + let(:user) { build(:user, organisations:) } it { is_expected.to be_falsey } context 'with a blank roles entry' do - let(:roles) { ' ' } + let(:organisations) { { organisation_id => ' ' } } it { is_expected.to be_falsey } end context 'with an unrelated role given' do - let(:roles) { 'foo' } + let(:organisations) { { organisation_id => 'foo' } } it { is_expected.to be_falsey } end context "with a #{role} role given" do - let(:roles) { role } + let(:organisations) { { organisation_id => role } } it { is_expected.to be_truthy } context 'with unrelated roles too' do - let(:roles) { "foo,bar,#{role},quux" } + let(:organisations) { { organisation_id => "foo,bar,#{role},quux" } } it { is_expected.to be_truthy } end context 'with weird extra whitespace in role' do - let(:roles) { " #{role} " } + let(:organisations) { { organisation_id => " #{role} " } } it { is_expected.to be_truthy } end @@ -70,8 +73,8 @@ expect(user.email).to eq 'school-owner@example.com' end - it 'returns a user with the correct roles' do - expect(user.roles).to eq 'school-owner' + it 'returns a user with the correct organisations' do + expect(user.organisations).to eq(organisation_id => 'school-owner') end context 'when BYPASS_AUTH is true' do @@ -93,19 +96,19 @@ end describe '#school_owner?' do - subject { user.school_owner? } + subject { user.school_owner?(organisation_id:) } include_examples 'role_check', 'school-owner' end describe '#school_teacher?' do - subject { user.school_teacher? } + subject { user.school_teacher?(organisation_id:) } include_examples 'role_check', 'school-teacher' end describe '#school_student?' do - subject { user.school_student? } + subject { user.school_student?(organisation_id:) } include_examples 'role_check', 'school-student' end From fe65edd80c699bff76b1407fffd3ea0b01de42f0 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 15:24:41 +0000 Subject: [PATCH 025/124] Rename Userinfo -> UserInfo --- app/models/user.rb | 4 ++-- lib/{userinfo_api_client.rb => user_info_api_client.rb} | 2 +- spec/support/user_profile_mock.rb | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename lib/{userinfo_api_client.rb => user_info_api_client.rb} (98%) diff --git a/app/models/user.rb b/app/models/user.rb index 1212d0199..464eeccda 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -55,11 +55,11 @@ def self.where(id:) def self.from_userinfo(ids:) user_ids = Array(ids) - UserinfoApiClient.fetch_by_ids(user_ids).map do |info| + UserInfoApiClient.fetch_by_ids(user_ids).map do |info| info = info.stringify_keys args = info.slice(*ATTRIBUTES) - # TODO: remove once the UserinfoApi returns the 'organisations' key. + # TODO: remove once the UserInfoApi returns the 'organisations' key. temporarily_add_organisations_until_the_profile_app_is_updated(args) new(args) diff --git a/lib/userinfo_api_client.rb b/lib/user_info_api_client.rb similarity index 98% rename from lib/userinfo_api_client.rb rename to lib/user_info_api_client.rb index a38759ca5..39d684881 100644 --- a/lib/userinfo_api_client.rb +++ b/lib/user_info_api_client.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserinfoApiClient +class UserInfoApiClient API_URL = ENV.fetch('https://github.com/USERINFO_API_URL', 'http://localhost:6000/') API_KEY = ENV.fetch('https://github.com/USERINFO_API_KEY', '1234') diff --git a/spec/support/user_profile_mock.rb b/spec/support/user_profile_mock.rb index 822663567..58fb1953a 100644 --- a/spec/support/user_profile_mock.rb +++ b/spec/support/user_profile_mock.rb @@ -6,8 +6,8 @@ module UserProfileMock # Stubs that API that returns user profile data for a given list of UUIDs. def stub_userinfo_api - stub_request(:get, "#{UserinfoApiClient::API_URL}/users") - .with(headers: { Authorization: "Bearer #{UserinfoApiClient::API_KEY}" }) + stub_request(:get, "#{UserInfoApiClient::API_URL}/users") + .with(headers: { Authorization: "Bearer #{UserInfoApiClient::API_KEY}" }) .to_return do |request| uuids = JSON.parse(request.body).fetch('https://github.com/userIds', []) indexes = uuids.map { |uuid| user_index_by_uuid(uuid) }.compact From adc0495baa0f8b7af5c4c4f1c5f8efcfbc1bab3a Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 16:22:42 +0000 Subject: [PATCH 026/124] Add a User#token field for convenience This avoids having to include Identifiable in controllers since we already have a user available via the #current_user method. --- app/models/user.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index 464eeccda..33a4803f1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,6 +15,7 @@ class User picture postcode profile + token ].freeze attr_accessor(*ATTRIBUTES) @@ -74,7 +75,9 @@ def self.from_omniauth(token:) auth = auth.stringify_keys args = auth.slice(*ATTRIBUTES) + args['id'] ||= auth['sub'] + args['token'] = token # TODO: remove once the HydraPublicApi returns the 'organisations' key. temporarily_add_organisations_until_the_profile_app_is_updated(args) From cd9ce91503905c9d33282fac562078db91b634ea Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 16:26:05 +0000 Subject: [PATCH 027/124] Create organisations via the ProfileApiClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Although this API doesn’t exist yet, call the client in the relevant parts of the code so that we would assign the organisation_id to the school that is being created. I think it makes sense for the creation of the organisation to happen after we’ve sense checked that the school is valid, to avoid creating unnecessary organisations in the profile app. We could create the organisations earlier in the flow and then enforce the presence of the ‘school-owner’ role via CanCanCan but I think it’s better to try and make this as transactional as possible. --- app/controllers/api/schools_controller.rb | 4 +-- app/models/ability.rb | 4 +-- lib/concepts/school/operations/create.rb | 19 ++++++++++--- lib/profile_api_client.rb | 17 ++++++++++++ spec/concepts/school/create_spec.rb | 34 +++++++++++++++-------- spec/features/creating_a_school_spec.rb | 25 ++--------------- spec/rails_helper.rb | 1 + spec/support/profile_api_mock.rb | 10 +++++++ 8 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 lib/profile_api_client.rb create mode 100644 spec/support/profile_api_mock.rb diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 2db0405d3..751b42e31 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -6,8 +6,7 @@ class SchoolsController < ApiController load_and_authorize_resource def create - school_hash = school_params.merge(owner_id: current_user.id) - result = School::Create.call(school_hash:) + result = School::Create.call(school_params:, current_user:) if result.success? @school = result[:school] @@ -23,7 +22,6 @@ def school_params params.require(:school).permit( :name, :reference, - :organisation_id, :address_line_1, # rubocop:disable Naming/VariableNumber :address_line_2, # rubocop:disable Naming/VariableNumber :municipality, diff --git a/app/models/ability.rb b/app/models/ability.rb index 1c382a125..6d449f50b 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -12,8 +12,6 @@ def initialize(user) can %i[read create update destroy], Project, user_id: user.id can %i[read create update destroy], Component, project: { user_id: user.id } - user.organisation_ids.each do |organisation_id| - can(%i[create], School, organisation_id:) if user.school_owner?(organisation_id:) - end + can %i[create], School end end diff --git a/lib/concepts/school/operations/create.rb b/lib/concepts/school/operations/create.rb index b76959c9b..72d29e6e7 100644 --- a/lib/concepts/school/operations/create.rb +++ b/lib/concepts/school/operations/create.rb @@ -3,9 +3,9 @@ class School class Create class << self - def call(school_hash:) + def call(school_params:, current_user:) response = OperationResponse.new - response[:school] = build_school(school_hash) + response[:school] = build_school(school_params, current_user) response[:school].save! response rescue StandardError => e @@ -17,8 +17,19 @@ def call(school_hash:) private - def build_school(school_hash) - School.new(school_hash) + def build_school(school_params, current_user) + school = School.new(school_params) + school.owner_id = current_user&.id + + # Assign a temporary UUID to check the validity of other fields. + school.organisation_id = SecureRandom.uuid + + if school.valid? + response = ProfileApiClient.create_organisation(token: current_user&.token) + school.organisation_id = response&.fetch(:id) + end + + school end end end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb new file mode 100644 index 000000000..e0194d3d0 --- /dev/null +++ b/lib/profile_api_client.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ProfileApiClient + class << self + # TODO: Replace with HTTP requests once the profile API has been built. + + # The API should enforce these constraints: + # - The user should have an email address + # - The user should not be under 13 + def create_organisation(token:) + return nil if token.blank? + + response = { 'id' => '12345678-1234-1234-1234-123456789abc' } + response.deep_symbolize_keys + end + end +end diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb index befe8d245..3f4a69232 100644 --- a/spec/concepts/school/create_spec.rb +++ b/spec/concepts/school/create_spec.rb @@ -3,10 +3,9 @@ require 'rails_helper' RSpec.describe School::Create, type: :unit do - let(:school_hash) do + let(:school_params) do { name: 'Test School', - organisation_id: '00000000-00000000-00000000-00000000', owner_id: '11111111-11111111-11111111-11111111', address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber municipality: 'Greater London', @@ -14,48 +13,59 @@ } end + let(:current_user) { build(:user) } + + before do + stub_profile_api_create_organisation + end + it 'creates a school' do - expect { described_class.call(school_hash:) }.to change(School, :count).by(1) + expect { described_class.call(school_params:, current_user:) }.to change(School, :count).by(1) end it 'returns the school in the operation response' do - response = described_class.call(school_hash:) + response = described_class.call(school_params:, current_user:) expect(response[:school]).to be_a(School) end it 'assigns the name' do - response = described_class.call(school_hash:) + response = described_class.call(school_params:, current_user:) expect(response[:school].name).to eq('Test School') end + it 'assigns the owner_id' do + response = described_class.call(school_params:, current_user:) + expect(response[:school].owner_id).to eq(current_user.id) + end + it 'assigns the organisation_id' do - response = described_class.call(school_hash:) - expect(response[:school].organisation_id).to eq('00000000-00000000-00000000-00000000') + response = described_class.call(school_params:, current_user:) + expect(response[:school].organisation_id).to eq(ProfileApiMock::ORGANISATION_ID) end context 'when creation fails' do - let(:school_hash) { {} } + let(:school_params) { {} } before do allow(Sentry).to receive(:capture_exception) end it 'does not create a school' do - expect { described_class.call(school_hash:) }.not_to change(School, :count) + expect { described_class.call(school_params:, current_user:) }.not_to change(School, :count) end it 'returns a failed operation response' do - response = described_class.call(school_hash:) + response = described_class.call(school_params:, current_user:) expect(response.failure?).to be(true) end it 'returns the error message in the operation response' do - response = described_class.call(school_hash:) + response = described_class.call(school_params:, current_user:) expect(response[:error]).to match(/Error creating school/) end it 'sent the exception to Sentry' do - described_class.call(school_hash:) + described_class.call(school_params:, current_user:) expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) end end diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index 4f4e26ea3..fcad1230c 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -3,14 +3,13 @@ require 'rails_helper' RSpec.describe 'Creating a school', type: :request do - let(:user_id) { stubbed_user_id } let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:user_id) { stubbed_user_id } let(:params) do { school: { name: 'Test School', - organisation_id: '12345678-1234-1234-1234-123456789abc', address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber municipality: 'Greater London', country_code: 'GB' @@ -20,6 +19,7 @@ before do stub_hydra_public_api + stub_profile_api_create_organisation end it 'responds 200 OK' do @@ -34,13 +34,6 @@ expect(data[:name]).to eq('Test School') end - it "assigns the current user as the school's owner" do - post('/api/schools', headers:, params:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data[:owner_id]).to eq(user_id) - end - it 'responds 400 Bad Request when params are missing' do post('/api/schools', headers:) expect(response).to have_http_status(:bad_request) @@ -55,18 +48,4 @@ post '/api/schools' expect(response).to have_http_status(:unauthorized) end - - it 'responds 403 Forbidden when the user is not a school-owner' do - stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) - - post('/api/schools', headers:, params:) - expect(response).to have_http_status(:forbidden) - end - - it 'requires 403 Forbidden when the user is a school-owner for a different school' do - new_params = { school: params[:school].merge(organisation_id: '00000000-00000000-00000000-00000000') } - - post('/api/schools', headers:, params: new_params) - expect(response).to have_http_status(:forbidden) - end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 290dba32d..b59265789 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -83,6 +83,7 @@ config.include GraphqlQueryHelpers, type: :graphql_query config.include PhraseIdentifierMock + config.include ProfileApiMock config.include UserProfileMock if Bullet.enable? diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb new file mode 100644 index 000000000..1ddad85f4 --- /dev/null +++ b/spec/support/profile_api_mock.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ProfileApiMock + ORGANISATION_ID = '12345678-1234-1234-1234-123456789abc' + + # TODO: Replace with a WebMock HTTP stub once the profile API has been built. + def stub_profile_api_create_organisation(organisation_id: ORGANISATION_ID) + allow(ProfileApiClient).to receive(:create_organisation).and_return(id: organisation_id) + end +end From 73e08cc153cd35b88b1da8c3ad8b8f5f5178352a Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 16:44:11 +0000 Subject: [PATCH 028/124] Add SchoolClass#teacher and #student methods --- app/models/school_class.rb | 8 +++++++ spec/fixtures/users.json | 2 +- spec/models/school_class_spec.rb | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/models/school_class.rb b/app/models/school_class.rb index 7bec2483e..6b30f69c3 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -6,4 +6,12 @@ class SchoolClass < ApplicationRecord validates :teacher_id, presence: true validates :name, presence: true + + def teacher + User.from_userinfo(ids: teacher_id).first + end + + def students + User.from_userinfo(ids: members.pluck(:student_id)) + end end diff --git a/spec/fixtures/users.json b/spec/fixtures/users.json index 3e1136270..d6016fe68 100644 --- a/spec/fixtures/users.json +++ b/spec/fixtures/users.json @@ -43,7 +43,7 @@ } }, { - "id": "22222222-2222-2222-22222222222222222", + "id": "22222222-2222-2222-2222-222222222222", "email": null, "username": "student123", "parentalEmail": null, diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index 9ff9f880a..0d7e5b686 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -49,4 +49,42 @@ expect(school_class).to be_invalid end end + + describe '#teacher' do + before do + stub_userinfo_api + end + + it 'returns a User instance for the teacher_id of the class' do + school_class = create(:school_class, teacher_id: '11111111-1111-1111-1111-111111111111') + expect(school_class.teacher.name).to eq('School Teacher') + end + + it 'returns nil if no profile account exists' do + school_class = create(:school_class, teacher_id: '99999999-9999-9999-9999-999999999999') + expect(school_class.teacher).to be_nil + end + end + + describe '#students' do + before do + stub_userinfo_api + end + + it 'returns User instances for members of the class' do + member = build(:class_member, student_id: '22222222-2222-2222-2222-222222222222') + school_class = create(:school_class, members: [member]) + + student = school_class.students.first + expect(student.name).to eq('School Student') + end + + it 'ignores members where no profile account exists' do + member = build(:class_member, student_id: '99999999-9999-9999-9999-999999999999') + school_class = create(:school_class, members: [member]) + + student = school_class.students.first + expect(student).to be_nil + end + end end From bd078d68525d313b5318879f2cce3ef66e5f87b5 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 16:48:28 +0000 Subject: [PATCH 029/124] Add a School#owner method --- app/models/school.rb | 4 ++++ spec/models/school_spec.rb | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/models/school.rb b/app/models/school.rb index 97b726e68..73a50f13b 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -10,4 +10,8 @@ class School < ApplicationRecord validates :address_line_1, presence: true # rubocop:disable Naming/VariableNumber validates :municipality, presence: true validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes } + + def owner + User.from_userinfo(ids: owner_id).first + end end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index a6fe7d1cf..1640d2d5f 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -104,4 +104,20 @@ expect(school).to be_invalid end end + + describe '#school' do + before do + stub_userinfo_api + end + + it 'returns a User instance for the owner_id of the school' do + school = create(:school, owner_id: '00000000-0000-0000-0000-000000000000') + expect(school.owner.name).to eq('School Owner') + end + + it 'returns nil if no profile account exists' do + school = create(:school, owner_id: '99999999-9999-9999-9999-999999999999') + expect(school.owner).to be_nil + end + end end From 068f4e6d62746024a1b69cbd91893dee5dec9206 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 18:02:02 +0000 Subject: [PATCH 030/124] =?UTF-8?q?Validate=20the=20presence=20of=20the=20?= =?UTF-8?q?=E2=80=98school-owner=E2=80=99=20role=20on=20school=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/school.rb | 17 +++++++++++++++++ lib/concepts/school/operations/create.rb | 5 +---- lib/user_info_api_client.rb | 2 +- spec/concepts/school/create_spec.rb | 2 +- spec/factories/school.rb | 4 ++-- spec/features/creating_a_school_spec.rb | 1 + spec/models/class_member_spec.rb | 4 ++++ spec/models/school_class_spec.rb | 12 ++++-------- spec/models/school_spec.rb | 21 ++++++++++++++------- spec/models/user_spec.rb | 2 +- spec/support/user_profile_mock.rb | 2 +- 11 files changed, 47 insertions(+), 25 deletions(-) diff --git a/app/models/school.rb b/app/models/school.rb index 73a50f13b..0f5be5816 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -10,8 +10,25 @@ class School < ApplicationRecord validates :address_line_1, presence: true # rubocop:disable Naming/VariableNumber validates :municipality, presence: true validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes } + validate :owner_has_the_school_owner_role_for_the_school def owner User.from_userinfo(ids: owner_id).first end + + def valid_except_for_organisation? + validate + errors.attribute_names.all? { |name| name == :organisation_id } + end + + private + + def owner_has_the_school_owner_role_for_the_school + return unless owner_id_changed? && organisation_id && errors.blank? + + user = owner + return unless user && !user.school_owner?(organisation_id:) + + errors.add(:owner, "'#{owner_id}' does not have the 'school-owner' role for organisation '#{organisation_id}'") + end end diff --git a/lib/concepts/school/operations/create.rb b/lib/concepts/school/operations/create.rb index 72d29e6e7..62d5790d2 100644 --- a/lib/concepts/school/operations/create.rb +++ b/lib/concepts/school/operations/create.rb @@ -21,10 +21,7 @@ def build_school(school_params, current_user) school = School.new(school_params) school.owner_id = current_user&.id - # Assign a temporary UUID to check the validity of other fields. - school.organisation_id = SecureRandom.uuid - - if school.valid? + if school.valid_except_for_organisation? response = ProfileApiClient.create_organisation(token: current_user&.token) school.organisation_id = response&.fetch(:id) end diff --git a/lib/user_info_api_client.rb b/lib/user_info_api_client.rb index 39d684881..c067e7e3a 100644 --- a/lib/user_info_api_client.rb +++ b/lib/user_info_api_client.rb @@ -36,7 +36,7 @@ def bypass_auth? end def transform_result(result) - { result: }.deep_transform_keys { |k| k.to_s.underscore.to_sym }.fetch(:result) + { result: }.transform_keys { |k| k.to_s.underscore.to_sym }.fetch(:result) end def conn diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb index 3f4a69232..e16f1c3ee 100644 --- a/spec/concepts/school/create_spec.rb +++ b/spec/concepts/school/create_spec.rb @@ -6,7 +6,6 @@ let(:school_params) do { name: 'Test School', - owner_id: '11111111-11111111-11111111-11111111', address_line_1: 'Address Line 1', # rubocop:disable Naming/VariableNumber municipality: 'Greater London', country_code: 'GB' @@ -16,6 +15,7 @@ let(:current_user) { build(:user) } before do + stub_user_info_api stub_profile_api_create_organisation end diff --git a/spec/factories/school.rb b/spec/factories/school.rb index a2fecce53..58cbd9670 100644 --- a/spec/factories/school.rb +++ b/spec/factories/school.rb @@ -2,8 +2,8 @@ FactoryBot.define do factory :school do - organisation_id { SecureRandom.uuid } - owner_id { SecureRandom.uuid } + organisation_id { '12345678-1234-1234-1234-123456789abc' } + owner_id { '00000000-0000-0000-0000-000000000000' } sequence(:name) { |n| "School #{n}" } address_line_1 { 'Address Line 1' } municipality { 'Greater London' } diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index fcad1230c..246fc43f6 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -19,6 +19,7 @@ before do stub_hydra_public_api + stub_user_info_api stub_profile_api_create_organisation end diff --git a/spec/models/class_member_spec.rb b/spec/models/class_member_spec.rb index 1d46b7613..d3c4487b1 100644 --- a/spec/models/class_member_spec.rb +++ b/spec/models/class_member_spec.rb @@ -3,6 +3,10 @@ require 'rails_helper' RSpec.describe ClassMember do + before do + stub_user_info_api + end + describe 'validations' do subject(:class_member) { build(:class_member) } diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index 0d7e5b686..ddeca503a 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -3,6 +3,10 @@ require 'rails_helper' RSpec.describe SchoolClass do + before do + stub_user_info_api + end + describe 'associations' do it 'has many members' do school_class = create(:school_class, members: [build(:class_member), build(:class_member)]) @@ -51,10 +55,6 @@ end describe '#teacher' do - before do - stub_userinfo_api - end - it 'returns a User instance for the teacher_id of the class' do school_class = create(:school_class, teacher_id: '11111111-1111-1111-1111-111111111111') expect(school_class.teacher.name).to eq('School Teacher') @@ -67,10 +67,6 @@ end describe '#students' do - before do - stub_userinfo_api - end - it 'returns User instances for members of the class' do member = build(:class_member, student_id: '22222222-2222-2222-2222-222222222222') school_class = create(:school_class, members: [member]) diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 1640d2d5f..2d5985866 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -3,6 +3,10 @@ require 'rails_helper' RSpec.describe School do + before do + stub_user_info_api + end + describe 'associations' do it 'has many classes' do school = create(:school, classes: [build(:school_class), build(:school_class)]) @@ -10,7 +14,7 @@ end context 'when a school is destroyed' do - let!(:school_class) { create(:school_class, members: [build(:class_member)]) } + let!(:school_class) { build(:school_class, members: [build(:class_member)]) } let!(:school) { create(:school, classes: [school_class]) } it 'also destroys school classes to avoid making them invalid' do @@ -54,6 +58,11 @@ expect(school).to be_invalid end + it 'requires an owner that has the school-owner role for the school' do + school.owner_id = '11111111-1111-1111-1111-111111111111' # school-teacher + expect(school).to be_invalid + end + it 'requires a unique organisation_id' do school.save! @@ -70,7 +79,9 @@ it 'does not require a reference' do create(:school, reference: nil) - create(:school, reference: nil) + + school.organisation_id = '99999999-9999-9999-9999-999999999999' # Satisfy the uniqueness validation. + school.owner_id = '99999999-9999-9999-9999-999999999999' # Satisfy the school-owner validation. school.reference = nil expect(school).to be_valid @@ -106,12 +117,8 @@ end describe '#school' do - before do - stub_userinfo_api - end - it 'returns a User instance for the owner_id of the school' do - school = create(:school, owner_id: '00000000-0000-0000-0000-000000000000') + school = create(:school) expect(school.owner.name).to eq('School Owner') end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 504c9a5e6..be26484c1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -117,7 +117,7 @@ subject(:user) { described_class.where(id: '00000000-0000-0000-0000-000000000000').first } before do - stub_userinfo_api + stub_user_info_api end it 'returns an instance of the described class' do diff --git a/spec/support/user_profile_mock.rb b/spec/support/user_profile_mock.rb index 58fb1953a..ca473a45c 100644 --- a/spec/support/user_profile_mock.rb +++ b/spec/support/user_profile_mock.rb @@ -5,7 +5,7 @@ module UserProfileMock TOKEN = 'fake-user-access-token' # Stubs that API that returns user profile data for a given list of UUIDs. - def stub_userinfo_api + def stub_user_info_api stub_request(:get, "#{UserInfoApiClient::API_URL}/users") .with(headers: { Authorization: "Bearer #{UserInfoApiClient::API_KEY}" }) .to_return do |request| From 09f9bc435e1549ebd2efe70d53605254c8cfbeb1 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 18:12:49 +0000 Subject: [PATCH 031/124] =?UTF-8?q?Validate=20the=20presence=20of=20the=20?= =?UTF-8?q?=E2=80=98school-teacher=E2=80=99=20role=20on=20school=5Fclass?= =?UTF-8?q?=20teachers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/school_class.rb | 15 +++++++++++++++ spec/models/school_class_spec.rb | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/app/models/school_class.rb b/app/models/school_class.rb index 6b30f69c3..740e893a1 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -6,6 +6,7 @@ class SchoolClass < ApplicationRecord validates :teacher_id, presence: true validates :name, presence: true + validate :teacher_has_the_school_teacher_role_for_the_school def teacher User.from_userinfo(ids: teacher_id).first @@ -14,4 +15,18 @@ def teacher def students User.from_userinfo(ids: members.pluck(:student_id)) end + + private + + def teacher_has_the_school_teacher_role_for_the_school + return unless teacher_id_changed? && errors.blank? + + user = teacher + organisation_id = school.organisation_id + + return unless user && !user.school_teacher?(organisation_id:) + + msg = "'#{teacher_id}' does not have the 'school-teacher' role for organisation '#{organisation_id}'" + errors.add(:teacher, msg) + end end diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index ddeca503a..30ad3a5a9 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -48,6 +48,11 @@ expect(school_class).to be_invalid end + it 'requires a teacher that has the school-teacher role for the school' do + school_class.teacher_id = '22222222-2222-2222-2222-222222222222' # school-student + expect(school_class).to be_invalid + end + it 'requires a name' do school_class.name = ' ' expect(school_class).to be_invalid From c851751216c9343aef4bc1ec1c7f205ba972e764 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 18:20:20 +0000 Subject: [PATCH 032/124] Delegate ClassMember#school to #school_class --- app/models/class_member.rb | 1 + spec/models/class_member_spec.rb | 12 ++++++++++++ spec/models/school_class_spec.rb | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/app/models/class_member.rb b/app/models/class_member.rb index cff7c6e62..de7d6e835 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -2,6 +2,7 @@ class ClassMember < ApplicationRecord belongs_to :school_class + delegate :school, to: :school_class validates :student_id, presence: true, uniqueness: { scope: :school_class_id, diff --git a/spec/models/class_member_spec.rb b/spec/models/class_member_spec.rb index d3c4487b1..a632bbb6f 100644 --- a/spec/models/class_member_spec.rb +++ b/spec/models/class_member_spec.rb @@ -7,6 +7,18 @@ stub_user_info_api end + describe 'associations' do + it 'belongs to a school_class' do + class_member = create(:class_member) + expect(class_member.school_class).to be_a(SchoolClass) + end + + it 'belongs to a school (via school_class)' do + class_member = create(:class_member) + expect(class_member.school).to be_a(School) + end + end + describe 'validations' do subject(:class_member) { build(:class_member) } diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index 30ad3a5a9..cf177bbe3 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -8,6 +8,11 @@ end describe 'associations' do + it 'belongs to a school' do + school_class = create(:school_class) + expect(school_class.school).to be_a(School) + end + it 'has many members' do school_class = create(:school_class, members: [build(:class_member), build(:class_member)]) expect(school_class.members.size).to eq(2) From 5fc2195383348060276cdc4ddb42ca93480b9982 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 10 Feb 2024 18:27:39 +0000 Subject: [PATCH 033/124] =?UTF-8?q?Validate=20the=20presence=20of=20the=20?= =?UTF-8?q?=E2=80=98school-student=E2=80=99=20role=20on=20class=5Fmember?= =?UTF-8?q?=20students?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/class_member.rb | 22 ++++++++++++++++++++++ spec/models/class_member_spec.rb | 5 +++++ spec/models/school_spec.rb | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/models/class_member.rb b/app/models/class_member.rb index de7d6e835..19c6630cd 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -8,4 +8,26 @@ class ClassMember < ApplicationRecord scope: :school_class_id, case_sensitive: false } + + validate :student_has_the_school_student_role_for_the_school + + private + + def student_has_the_school_student_role_for_the_school + return unless student_id_changed? && errors.blank? + + user = student + organisation_id = school.organisation_id + + return unless user && !user.school_student?(organisation_id:) + + msg = "'#{student_id}' does not have the 'school-student' role for organisation '#{organisation_id}'" + errors.add(:student, msg) + end + + # Intentionally make this private to avoid N API calls. + # Prefer using SchoolClass#students which makes 1 API call. + def student + User.from_userinfo(ids: student_id).first + end end diff --git a/spec/models/class_member_spec.rb b/spec/models/class_member_spec.rb index a632bbb6f..e0070f1a1 100644 --- a/spec/models/class_member_spec.rb +++ b/spec/models/class_member_spec.rb @@ -45,6 +45,11 @@ expect(class_member).to be_invalid end + it 'requires a student that has the school-student role for the school' do + class_member.student_id = '11111111-1111-1111-1111-111111111111' # school-teacher + expect(class_member).to be_invalid + end + it 'requires a unique student_id within the school_class' do class_member.save! duplicate = build(:class_member, student_id: class_member.student_id, school_class: class_member.school_class) diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 2d5985866..f512f668c 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -116,7 +116,7 @@ end end - describe '#school' do + describe '#owner' do it 'returns a User instance for the owner_id of the school' do school = create(:school) expect(school.owner.name).to eq('School Owner') From 577dfa1243d14a337a06f088a7ef15b929c6cfad Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 13:39:16 +0000 Subject: [PATCH 034/124] =?UTF-8?q?Respond=20=E2=80=98201=20Created?= =?UTF-8?q?=E2=80=99=20rather=20than=20=E2=80=98200=20OK=E2=80=99=20on=20S?= =?UTF-8?q?chools#create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/api/schools_controller.rb | 2 +- spec/features/creating_a_school_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 751b42e31..9f96f32b3 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -10,7 +10,7 @@ def create if result.success? @school = result[:school] - render :show, formats: [:json] + render :show, formats: [:json], status: :created else render json: { error: result[:error] }, status: :unprocessable_entity end diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index 246fc43f6..0d1b5df44 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -23,9 +23,9 @@ stub_profile_api_create_organisation end - it 'responds 200 OK' do + it 'responds 201 Created' do post('/api/schools', headers:, params:) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:created) end it 'responds with the school JSON' do From 3817b4dafee7d26b63a372e0efbe70127d080704 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 13:39:32 +0000 Subject: [PATCH 035/124] Add additional fields to schools JSON output --- app/views/api/schools/show.json.jbuilder | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/views/api/schools/show.json.jbuilder b/app/views/api/schools/show.json.jbuilder index db0c97c15..510059781 100644 --- a/app/views/api/schools/show.json.jbuilder +++ b/app/views/api/schools/show.json.jbuilder @@ -4,12 +4,15 @@ json.call( @school, :organisation_id, :owner_id, + :name, + :reference, :address_line_1, # rubocop:disable Naming/VariableNumber :address_line_2, # rubocop:disable Naming/VariableNumber :municipality, :administrative_area, :postal_code, :country_code, - :name, - :verified_at + :verified_at, + :created_at, + :updated_at ) From bf44ba2d5823e49d20c7eb04fa5e56c1b20e46b9 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 13:56:36 +0000 Subject: [PATCH 036/124] Add a Schools#update controller action --- app/controllers/api/schools_controller.rb | 14 ++++- app/models/ability.rb | 4 ++ config/routes.rb | 2 +- lib/concepts/school/operations/update.rb | 20 +++++++ spec/features/creating_a_school_spec.rb | 12 ++-- spec/features/updating_a_school_spec.rb | 68 +++++++++++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 lib/concepts/school/operations/update.rb create mode 100644 spec/features/updating_a_school_spec.rb diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 9f96f32b3..959112634 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -2,7 +2,7 @@ module Api class SchoolsController < ApiController - before_action :authorize_user, only: %i[create] + before_action :authorize_user load_and_authorize_resource def create @@ -16,6 +16,18 @@ def create end end + def update + school = School.find(params[:id]) + result = School::Update.call(school:, school_params:) + + if result.success? + @school = result[:school] + render :show, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def school_params diff --git a/app/models/ability.rb b/app/models/ability.rb index 6d449f50b..c31496793 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -13,5 +13,9 @@ def initialize(user) can %i[read create update destroy], Component, project: { user_id: user.id } can %i[create], School + + user.organisation_ids.each do |organisation_id| + can %i[update], School, organisation_id: organisation_id if user.school_owner?(organisation_id:) + end end end diff --git a/config/routes.rb b/config/routes.rb index 72bcdd055..74c99099d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,7 +17,7 @@ resource :project_errors, only: %i[create] - resources :schools, only: %i[create] + resources :schools, only: %i[create update] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/lib/concepts/school/operations/update.rb b/lib/concepts/school/operations/update.rb new file mode 100644 index 000000000..439f30d64 --- /dev/null +++ b/lib/concepts/school/operations/update.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class School + class Update + class << self + def call(school:, school_params:) + response = OperationResponse.new + response[:school] = school + response[:school].assign_attributes(school_params) + response[:school].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:school].errors.full_messages.join(',') + response[:error] = "Error updating school: #{errors}" + response + end + end + end +end diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/creating_a_school_spec.rb index 0d1b5df44..da5167af7 100644 --- a/spec/features/creating_a_school_spec.rb +++ b/spec/features/creating_a_school_spec.rb @@ -3,6 +3,12 @@ require 'rails_helper' RSpec.describe 'Creating a school', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + stub_profile_api_create_organisation + end + let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:user_id) { stubbed_user_id } @@ -17,12 +23,6 @@ } end - before do - stub_hydra_public_api - stub_user_info_api - stub_profile_api_create_organisation - end - it 'responds 201 Created' do post('/api/schools', headers:, params:) expect(response).to have_http_status(:created) diff --git a/spec/features/updating_a_school_spec.rb b/spec/features/updating_a_school_spec.rb new file mode 100644 index 000000000..67e7613ea --- /dev/null +++ b/spec/features/updating_a_school_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a school', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let!(:school) { create(:school) } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:user_id) { stubbed_user_id } + + let(:params) do + { + school: { + name: 'New Name' + } + } + end + + it 'responds 200 OK' do + put("/api/schools/#{school.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school JSON' do + put("/api/schools/#{school.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds 404 Not Found when no school exists' do + put('/api/schools/not-a-real-id', headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 400 Bad Request when params are missing' do + put("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/schools/#{school.id}", headers:, params: { school: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put "/api/schools/#{school.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is not a school-owner' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + put("/api/schools/#{school.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(organisation_id: '00000000-00000000-00000000-00000000') + + put("/api/schools/#{school.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end From 38fdedfcfcf02eeb4e20f2ac7b1612e909b1af32 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 14:16:16 +0000 Subject: [PATCH 037/124] Add a Schools#show controller action --- app/controllers/api/schools_controller.rb | 5 +++ app/models/ability.rb | 3 +- config/routes.rb | 2 +- spec/features/showing_a_school_spec.rb | 43 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 spec/features/showing_a_school_spec.rb diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 959112634..c3308ed5a 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -5,6 +5,11 @@ class SchoolsController < ApiController before_action :authorize_user load_and_authorize_resource + def show + @school = School.find(params[:id]) + render :show, formats: [:json], status: :ok + end + def create result = School::Create.call(school_params:, current_user:) diff --git a/app/models/ability.rb b/app/models/ability.rb index c31496793..1504a274f 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -15,7 +15,8 @@ def initialize(user) can %i[create], School user.organisation_ids.each do |organisation_id| - can %i[update], School, organisation_id: organisation_id if user.school_owner?(organisation_id:) + can(%i[show], School, organisation_id:) + can(%i[update], School, organisation_id:) if user.school_owner?(organisation_id:) end end end diff --git a/config/routes.rb b/config/routes.rb index 74c99099d..2c20b1ce6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,7 +17,7 @@ resource :project_errors, only: %i[create] - resources :schools, only: %i[create update] + resources :schools, only: %i[show create update] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/spec/features/showing_a_school_spec.rb b/spec/features/showing_a_school_spec.rb new file mode 100644 index 000000000..8f26b1ab0 --- /dev/null +++ b/spec/features/showing_a_school_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing a school', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let!(:school) { create(:school, name: 'Test School') } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:user_id) { stubbed_user_id } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school JSON' do + get("/api/schools/#{school.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School') + end + + it 'responds 404 Not Found when no school exists' do + get('/api/schools/not-a-real-id', headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user belongs to a different school' do + school.update!(organisation_id: '00000000-00000000-00000000-00000000') + + get("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end From 049ce947be58684b374e94c1629a4a63d8d42461 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 14:26:14 +0000 Subject: [PATCH 038/124] Add a Schools#index controller action --- app/controllers/api/schools_controller.rb | 5 +++ app/models/ability.rb | 2 +- app/views/api/schools/index.json.jbuilder | 20 +++++++++++ config/routes.rb | 2 +- spec/features/listing_schools_spec.rb | 42 +++++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 app/views/api/schools/index.json.jbuilder create mode 100644 spec/features/listing_schools_spec.rb diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index c3308ed5a..779598316 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -5,6 +5,11 @@ class SchoolsController < ApiController before_action :authorize_user load_and_authorize_resource + def index + @schools = School.where(organisation_id: current_user.organisation_ids) + render :index, formats: [:json], status: :ok + end + def show @school = School.find(params[:id]) render :show, formats: [:json], status: :ok diff --git a/app/models/ability.rb b/app/models/ability.rb index 1504a274f..180abbf5b 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -15,7 +15,7 @@ def initialize(user) can %i[create], School user.organisation_ids.each do |organisation_id| - can(%i[show], School, organisation_id:) + can(%i[read], School, organisation_id:) can(%i[update], School, organisation_id:) if user.school_owner?(organisation_id:) end end diff --git a/app/views/api/schools/index.json.jbuilder b/app/views/api/schools/index.json.jbuilder new file mode 100644 index 000000000..e87c4bb8e --- /dev/null +++ b/app/views/api/schools/index.json.jbuilder @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +json.array!(@schools) do |school| + json.call( + school, + :organisation_id, + :owner_id, + :name, + :reference, + :address_line_1, # rubocop:disable Naming/VariableNumber + :address_line_2, # rubocop:disable Naming/VariableNumber + :municipality, + :administrative_area, + :postal_code, + :country_code, + :verified_at, + :created_at, + :updated_at + ) +end diff --git a/config/routes.rb b/config/routes.rb index 2c20b1ce6..3f7211198 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,7 +17,7 @@ resource :project_errors, only: %i[create] - resources :schools, only: %i[show create update] + resources :schools, only: %i[index show create update] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/spec/features/listing_schools_spec.rb b/spec/features/listing_schools_spec.rb new file mode 100644 index 000000000..50b397d11 --- /dev/null +++ b/spec/features/listing_schools_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing schools that the current user belongs to', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + + create(:school, name: 'Test School') + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:user_id) { stubbed_user_id } + + it 'responds 200 OK' do + get('/api/schools', headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school JSON' do + get('/api/schools', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test School') + end + + it 'only includes schools the user belongs to' do + create(:school, organisation_id: '00000000-0000-0000-0000-000000000000') + create(:school, organisation_id: '11111111-1111-1111-1111-111111111111') + + get('/api/schools', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'responds 401 Unauthorized when no token is given' do + get '/api/schools' + expect(response).to have_http_status(:unauthorized) + end +end From 92c405f0289f431d8a5f559a93ff56b8acff62b2 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 14:34:23 +0000 Subject: [PATCH 039/124] Move features under spec/features/school/ --- spec/features/{ => school}/creating_a_school_spec.rb | 0 spec/features/{ => school}/listing_schools_spec.rb | 0 spec/features/{ => school}/showing_a_school_spec.rb | 0 spec/features/{ => school}/updating_a_school_spec.rb | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename spec/features/{ => school}/creating_a_school_spec.rb (100%) rename spec/features/{ => school}/listing_schools_spec.rb (100%) rename spec/features/{ => school}/showing_a_school_spec.rb (100%) rename spec/features/{ => school}/updating_a_school_spec.rb (100%) diff --git a/spec/features/creating_a_school_spec.rb b/spec/features/school/creating_a_school_spec.rb similarity index 100% rename from spec/features/creating_a_school_spec.rb rename to spec/features/school/creating_a_school_spec.rb diff --git a/spec/features/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb similarity index 100% rename from spec/features/listing_schools_spec.rb rename to spec/features/school/listing_schools_spec.rb diff --git a/spec/features/showing_a_school_spec.rb b/spec/features/school/showing_a_school_spec.rb similarity index 100% rename from spec/features/showing_a_school_spec.rb rename to spec/features/school/showing_a_school_spec.rb diff --git a/spec/features/updating_a_school_spec.rb b/spec/features/school/updating_a_school_spec.rb similarity index 100% rename from spec/features/updating_a_school_spec.rb rename to spec/features/school/updating_a_school_spec.rb From d630425766712b6d9ebd04db022825d8ca5a0f14 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 15:02:57 +0000 Subject: [PATCH 040/124] Fix a failing spec --- spec/features/school/listing_schools_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb index 50b397d11..00e644507 100644 --- a/spec/features/school/listing_schools_spec.rb +++ b/spec/features/school/listing_schools_spec.rb @@ -26,8 +26,8 @@ end it 'only includes schools the user belongs to' do - create(:school, organisation_id: '00000000-0000-0000-0000-000000000000') - create(:school, organisation_id: '11111111-1111-1111-1111-111111111111') + create(:school, organisation_id: '00000000-0000-0000-0000-000000000000', owner_id: '99999999-9999-9999-9999-999999999999') + create(:school, organisation_id: '11111111-1111-1111-1111-111111111111', owner_id: '99999999-9999-9999-9999-999999999999') get('/api/schools', headers:) data = JSON.parse(response.body, symbolize_names: true) From 26a90fb7484d9b9f53b0ebb6cb239db1405710ba Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 15:03:41 +0000 Subject: [PATCH 041/124] =?UTF-8?q?Prefer=20to=20use=20CanCanCan=E2=80=99s?= =?UTF-8?q?=20built-in=20#accessible=5Fby=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/api/schools_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 779598316..3614fec2c 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -6,7 +6,7 @@ class SchoolsController < ApiController load_and_authorize_resource def index - @schools = School.where(organisation_id: current_user.organisation_ids) + @schools = School.accessible_by(current_ability) render :index, formats: [:json], status: :ok end From 4d1dc18491f3672d87e3c7a4fb044ea285faf5c8 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 15:37:50 +0000 Subject: [PATCH 042/124] Fix confusing validation error messages --- app/models/school.rb | 2 +- app/models/school_class.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/school.rb b/app/models/school.rb index 0f5be5816..127bbf880 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -29,6 +29,6 @@ def owner_has_the_school_owner_role_for_the_school user = owner return unless user && !user.school_owner?(organisation_id:) - errors.add(:owner, "'#{owner_id}' does not have the 'school-owner' role for organisation '#{organisation_id}'") + errors.add(:user, "'#{owner_id}' does not have the 'school-owner' role for organisation '#{organisation_id}'") end end diff --git a/app/models/school_class.rb b/app/models/school_class.rb index 740e893a1..adb6a7e32 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -27,6 +27,6 @@ def teacher_has_the_school_teacher_role_for_the_school return unless user && !user.school_teacher?(organisation_id:) msg = "'#{teacher_id}' does not have the 'school-teacher' role for organisation '#{organisation_id}'" - errors.add(:teacher, msg) + errors.add(:user, msg) end end From 71056afb13fb76c70bcfcb0356f21194ff0bc678 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 16:08:38 +0000 Subject: [PATCH 043/124] Add a SchoolClasses#create controller action --- .../api/school_classes_controller.rb | 36 ++++++++++ app/models/ability.rb | 4 +- app/models/user.rb | 4 ++ .../api/school_classes/show.json.jbuilder | 11 +++ app/views/api/schools/index.json.jbuilder | 1 + app/views/api/schools/show.json.jbuilder | 1 + config/routes.rb | 4 +- .../school_class/operations/create.rb | 19 ++++++ spec/concepts/school_class/create_spec.rb | 68 +++++++++++++++++++ .../creating_a_school_class_spec.rb | 68 +++++++++++++++++++ 10 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/school_classes_controller.rb create mode 100644 app/views/api/school_classes/show.json.jbuilder create mode 100644 lib/concepts/school_class/operations/create.rb create mode 100644 spec/concepts/school_class/create_spec.rb create mode 100644 spec/features/school_class/creating_a_school_class_spec.rb diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb new file mode 100644 index 000000000..09e641ef7 --- /dev/null +++ b/app/controllers/api/school_classes_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Api + class SchoolClassesController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + load_and_authorize_resource :school_class, through: :school, through_association: :classes + + def create + result = SchoolClass::Create.call(school: @school, school_class_params:) + + if result.success? + @school_class = result[:school_class] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_class_params + if school_owner? + # The school owner must specify who the class teacher is. + params.require(:school_class).permit(:teacher_id, :name) + else + # A school teacher may only create classes they own. + params.require(:school_class).permit(:name).merge(teacher_id: current_user.id) + end + end + + def school_owner? + current_user.school_owner?(organisation_id: @school.organisation_id) + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 180abbf5b..02f19afa1 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -12,11 +12,13 @@ def initialize(user) can %i[read create update destroy], Project, user_id: user.id can %i[read create update destroy], Component, project: { user_id: user.id } - can %i[create], School + can %i[create], School # The user agrees to become a school-owner by creating a school. user.organisation_ids.each do |organisation_id| can(%i[read], School, organisation_id:) can(%i[update], School, organisation_id:) if user.school_owner?(organisation_id:) + + can(%i[create], SchoolClass, school: { organisation_id: }) if user.school_owner_or_teacher?(organisation_id:) end end end diff --git a/app/models/user.rb b/app/models/user.rb index 33a4803f1..1055d2cfb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -45,6 +45,10 @@ def school_student?(organisation_id:) role?(organisation_id:, role: 'school-student') end + def school_owner_or_teacher?(organisation_id:) + school_owner?(organisation_id:) || school_teacher?(organisation_id:) + end + def ==(other) id == other.id end diff --git a/app/views/api/school_classes/show.json.jbuilder b/app/views/api/school_classes/show.json.jbuilder new file mode 100644 index 000000000..531d30201 --- /dev/null +++ b/app/views/api/school_classes/show.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +json.call( + @school_class, + :id, + :school_id, + :teacher_id, + :name, + :created_at, + :updated_at +) diff --git a/app/views/api/schools/index.json.jbuilder b/app/views/api/schools/index.json.jbuilder index e87c4bb8e..eea1f3f34 100644 --- a/app/views/api/schools/index.json.jbuilder +++ b/app/views/api/schools/index.json.jbuilder @@ -3,6 +3,7 @@ json.array!(@schools) do |school| json.call( school, + :id, :organisation_id, :owner_id, :name, diff --git a/app/views/api/schools/show.json.jbuilder b/app/views/api/schools/show.json.jbuilder index 510059781..08492fc06 100644 --- a/app/views/api/schools/show.json.jbuilder +++ b/app/views/api/schools/show.json.jbuilder @@ -2,6 +2,7 @@ json.call( @school, + :id, :organisation_id, :owner_id, :name, diff --git a/config/routes.rb b/config/routes.rb index 3f7211198..7542ca708 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,7 +17,9 @@ resource :project_errors, only: %i[create] - resources :schools, only: %i[index show create update] + resources :schools, only: %i[index show create update] do + resources :classes, only: %i[create], controller: 'school_classes' + end end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/lib/concepts/school_class/operations/create.rb b/lib/concepts/school_class/operations/create.rb new file mode 100644 index 000000000..aca708f95 --- /dev/null +++ b/lib/concepts/school_class/operations/create.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class SchoolClass + class Create + class << self + def call(school:, school_class_params:) + response = OperationResponse.new + response[:school_class] = school.classes.build(school_class_params) + response[:school_class].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:school_class].errors.full_messages.join(',') + response[:error] = "Error creating school class: #{errors}" + response + end + end + end +end diff --git a/spec/concepts/school_class/create_spec.rb b/spec/concepts/school_class/create_spec.rb new file mode 100644 index 000000000..8a36c2fc0 --- /dev/null +++ b/spec/concepts/school_class/create_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass::Create, type: :unit do + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { stubbed_user_id(user_index: teacher_index) } + + let(:school_class_params) do + { name: 'Test School Class', teacher_id: } + end + + before do + stub_user_info_api + end + + it 'creates a school class' do + expect { described_class.call(school:, school_class_params:) }.to change(SchoolClass, :count).by(1) + end + + it 'returns the school class in the operation response' do + response = described_class.call(school:, school_class_params:) + expect(response[:school_class]).to be_a(SchoolClass) + end + + it 'assigns the school' do + response = described_class.call(school:, school_class_params:) + expect(response[:school_class].school).to eq(school) + end + + it 'assigns the name' do + response = described_class.call(school:, school_class_params:) + expect(response[:school_class].name).to eq('Test School Class') + end + + it 'assigns the teacher_id' do + response = described_class.call(school:, school_class_params:) + expect(response[:school_class].teacher_id).to eq(teacher_id) + end + + context 'when creation fails' do + let(:school_class_params) { {} } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a school class' do + expect { described_class.call(school:, school_class_params:) }.not_to change(SchoolClass, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_class_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_class_params:) + expect(response[:error]).to match(/Error creating school class/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_class_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_class/creating_a_school_class_spec.rb b/spec/features/school_class/creating_a_school_class_spec.rb new file mode 100644 index 000000000..dc8affedd --- /dev/null +++ b/spec/features/school_class/creating_a_school_class_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a school class', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { stubbed_user_id(user_index: teacher_index) } + + let(:params) do + { + school_class: { + name: 'Test School Class', + teacher_id: + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the school class JSON' do + post("/api/schools/#{school.id}/classes", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School Class') + end + + it 'sets the class teacher to the specified user for school-owner users' do + post("/api/schools/#{school.id}/classes", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_id]).to eq(teacher_id) + end + + it 'sets the class teacher to the current user for school-teacher users' do + stub_hydra_public_api(user_index: teacher_index) + new_params = { school_class: params[:school_class].merge(teacher_id: 'ignored') } + + post("/api/schools/#{school.id}/classes", headers:, params: new_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_id]).to eq(teacher_id) + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/classes", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/classes", headers:, params: { school_class: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post "/api/schools/#{school.id}/classes" + expect(response).to have_http_status(:unauthorized) + end +end From 3eb62351bacbd4b7c8a6430129a155c19a6b962f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 11 Feb 2024 16:14:27 +0000 Subject: [PATCH 044/124] Add unit tests for School::Update --- spec/concepts/school/update_spec.rb | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 spec/concepts/school/update_spec.rb diff --git a/spec/concepts/school/update_spec.rb b/spec/concepts/school/update_spec.rb new file mode 100644 index 000000000..152835401 --- /dev/null +++ b/spec/concepts/school/update_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School::Update, type: :unit do + let(:school) { create(:school, name: 'Test School Name') } + let(:school_params) { { name: 'New Name' } } + + before do + stub_user_info_api + end + + it 'updates the school' do + response = described_class.call(school:, school_params:) + expect(response[:school].name).to eq('New Name') + end + + it 'returns the school in the operation response' do + response = described_class.call(school:, school_params:) + expect(response[:school]).to be_a(School) + end + + context 'when updating fails' do + let(:school_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not update the school' do + response = described_class.call(school:, school_params:) + expect(response[:school].reload.name).to eq('Test School Name') + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_params:) + expect(response[:error]).to match(/Error updating school/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end From 6d24b70bcd9f4620e1bad249a1a8350aa5ecd5de Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 11:12:57 +0000 Subject: [PATCH 045/124] Fix foreign keys being integer IDs rather than UUIDs --- db/migrate/20240201165749_create_school_classes.rb | 2 +- db/migrate/20240201171700_create_class_members.rb | 2 +- db/schema.rb | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/db/migrate/20240201165749_create_school_classes.rb b/db/migrate/20240201165749_create_school_classes.rb index bf9e18504..11fed7e67 100644 --- a/db/migrate/20240201165749_create_school_classes.rb +++ b/db/migrate/20240201165749_create_school_classes.rb @@ -1,7 +1,7 @@ class CreateSchoolClasses < ActiveRecord::Migration[7.0] def change create_table :school_classes, id: :uuid do |t| - t.belongs_to :school, null: false + t.references :school, type: :uuid, foreign_key: true, index: true, null: false t.uuid :teacher_id, null: false t.string :name, null: false t.timestamps diff --git a/db/migrate/20240201171700_create_class_members.rb b/db/migrate/20240201171700_create_class_members.rb index ec72464d7..7cc021265 100644 --- a/db/migrate/20240201171700_create_class_members.rb +++ b/db/migrate/20240201171700_create_class_members.rb @@ -1,7 +1,7 @@ class CreateClassMembers < ActiveRecord::Migration[7.0] def change create_table :class_members, id: :uuid do |t| - t.belongs_to :school_class, null: false + t.references :school_class, type: :uuid, foreign_key: true, index: true, null: false t.uuid :student_id, null: false t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 44fb1ad66..5017d44be 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -45,7 +45,7 @@ end create_table "class_members", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.bigint "school_class_id", null: false + t.uuid "school_class_id", null: false t.uuid "student_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -150,7 +150,7 @@ end create_table "school_classes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.bigint "school_id", null: false + t.uuid "school_id", null: false t.uuid "teacher_id", null: false t.string "name", null: false t.datetime "created_at", null: false @@ -184,6 +184,8 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "class_members", "school_classes" add_foreign_key "components", "projects" add_foreign_key "project_errors", "projects" + add_foreign_key "school_classes", "schools" end From cffd052c1255ce897b12f683845e8b66df138d15 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 11:35:37 +0000 Subject: [PATCH 046/124] Add a SchoolClasses#update controller action --- .../api/school_classes_controller.rb | 18 +++++ app/models/ability.rb | 4 +- config/routes.rb | 2 +- .../school_class/operations/update.rb | 20 +++++ spec/concepts/school_class/update_spec.rb | 50 +++++++++++++ .../updating_a_school_class_spec.rb | 73 +++++++++++++++++++ 6 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 lib/concepts/school_class/operations/update.rb create mode 100644 spec/concepts/school_class/update_spec.rb create mode 100644 spec/features/school_class/updating_a_school_class_spec.rb diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 09e641ef7..dc7397a63 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -17,6 +17,20 @@ def create end end + def update + school_class = @school.classes.find(params[:id]) + raise CanCan::AccessDenied unless can_update?(school_class) + + result = SchoolClass::Update.call(school_class:, school_class_params:) + + if result.success? + @school_class = result[:school_class] + render :show, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def school_class_params @@ -32,5 +46,9 @@ def school_class_params def school_owner? current_user.school_owner?(organisation_id: @school.organisation_id) end + + def can_update?(school_class) + school_owner? || school_class.teacher_id == current_user.id + end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 02f19afa1..238f2ab50 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -18,7 +18,9 @@ def initialize(user) can(%i[read], School, organisation_id:) can(%i[update], School, organisation_id:) if user.school_owner?(organisation_id:) - can(%i[create], SchoolClass, school: { organisation_id: }) if user.school_owner_or_teacher?(organisation_id:) + if user.school_owner_or_teacher?(organisation_id:) + can(%i[create update], SchoolClass, school: { organisation_id: }) + end end end end diff --git a/config/routes.rb b/config/routes.rb index 7542ca708..e9cdd8fbd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,7 @@ resource :project_errors, only: %i[create] resources :schools, only: %i[index show create update] do - resources :classes, only: %i[create], controller: 'school_classes' + resources :classes, only: %i[create update], controller: 'school_classes' end end diff --git a/lib/concepts/school_class/operations/update.rb b/lib/concepts/school_class/operations/update.rb new file mode 100644 index 000000000..3ee94a0e5 --- /dev/null +++ b/lib/concepts/school_class/operations/update.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class SchoolClass + class Update + class << self + def call(school_class:, school_class_params:) + response = OperationResponse.new + response[:school_class] = school_class + response[:school_class].assign_attributes(school_class_params) + response[:school_class].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:school_class].errors.full_messages.join(',') + response[:error] = "Error updating school class: #{errors}" + response + end + end + end +end diff --git a/spec/concepts/school_class/update_spec.rb b/spec/concepts/school_class/update_spec.rb new file mode 100644 index 000000000..6706707e4 --- /dev/null +++ b/spec/concepts/school_class/update_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass::Update, type: :unit do + let(:school_class) { create(:school_class, name: 'Test School Class Name') } + let(:school_class_params) { { name: 'New Name' } } + + before do + stub_user_info_api + end + + it 'updates the school class' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:school_class].name).to eq('New Name') + end + + it 'returns the school class in the operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:school_class]).to be_a(SchoolClass) + end + + context 'when updating fails' do + let(:school_class_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not update the school class' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:school_class].reload.name).to eq('Test School Class Name') + end + + it 'returns a failed operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response[:error]).to match(/Error updating school/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, school_class_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb new file mode 100644 index 000000000..4d6a03ff3 --- /dev/null +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a school class', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class, name: 'Test School Class') } + let(:school) { school_class.school } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { stubbed_user_id(user_index: teacher_index) } + + let(:params) do + { + school_class: { + name: 'New Name' + } + } + end + + it 'responds 200 OK' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school class JSON' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds 400 Bad Request when params are missing' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params: { school_class: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is not a school-owner or school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + school_class.update!(teacher_id: '99999999-99999999-99999999-99999999') + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(organisation_id: '00000000-00000000-00000000-00000000') + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end From 9645d6feb6b6bd6686e79adcb43e6946abac1f93 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 11:47:58 +0000 Subject: [PATCH 047/124] Extract the class teacher #update check into ability.rb --- app/controllers/api/school_classes_controller.rb | 6 ------ app/models/ability.rb | 13 ++++++++++--- app/models/user.rb | 4 ---- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index dc7397a63..536b9b0c3 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -19,8 +19,6 @@ def create def update school_class = @school.classes.find(params[:id]) - raise CanCan::AccessDenied unless can_update?(school_class) - result = SchoolClass::Update.call(school_class:, school_class_params:) if result.success? @@ -46,9 +44,5 @@ def school_class_params def school_owner? current_user.school_owner?(organisation_id: @school.organisation_id) end - - def can_update?(school_class) - school_owner? || school_class.teacher_id == current_user.id - end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 238f2ab50..17b2eb8a9 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -3,6 +3,7 @@ class Ability include CanCan::Ability + # rubocop:disable Metrics/AbcSize def initialize(user) can :show, Project, user_id: nil can :show, Component, project: { user_id: nil } @@ -16,11 +17,17 @@ def initialize(user) user.organisation_ids.each do |organisation_id| can(%i[read], School, organisation_id:) - can(%i[update], School, organisation_id:) if user.school_owner?(organisation_id:) - if user.school_owner_or_teacher?(organisation_id:) - can(%i[create update], SchoolClass, school: { organisation_id: }) + if user.school_owner?(organisation_id:) + can(%i[update], School, organisation_id:) + can(%i[read create update], SchoolClass, school: { organisation_id: }) + end + + if user.school_teacher?(organisation_id:) + can(%i[create], SchoolClass, school: { organisation_id: }) + can(%i[update], SchoolClass, school: { organisation_id: }, teacher_id: user.id) end end end + # rubocop:enable Metrics/AbcSize end diff --git a/app/models/user.rb b/app/models/user.rb index 1055d2cfb..33a4803f1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -45,10 +45,6 @@ def school_student?(organisation_id:) role?(organisation_id:, role: 'school-student') end - def school_owner_or_teacher?(organisation_id:) - school_owner?(organisation_id:) || school_teacher?(organisation_id:) - end - def ==(other) id == other.id end From a9d07e3e98368bf9248756de1b3095f66cf3c020 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 12:25:21 +0000 Subject: [PATCH 048/124] Add a SchoolClasses#show controller action --- .../api/school_classes_controller.rb | 4 + app/controllers/api/schools_controller.rb | 1 - app/models/ability.rb | 6 +- config/routes.rb | 2 +- spec/factories/class_member.rb | 2 +- spec/factories/school.rb | 2 +- spec/factories/school_class.rb | 2 +- .../creating_a_school_class_spec.rb | 21 +++++ .../showing_a_school_class_spec.rb | 79 +++++++++++++++++++ .../updating_a_school_class_spec.rb | 15 +++- spec/models/school_class_spec.rb | 4 +- 11 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 spec/features/school_class/showing_a_school_class_spec.rb diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 536b9b0c3..988142be6 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -6,6 +6,10 @@ class SchoolClassesController < ApiController load_and_authorize_resource :school load_and_authorize_resource :school_class, through: :school, through_association: :classes + def show + render :show, formats: [:json], status: :ok + end + def create result = SchoolClass::Create.call(school: @school, school_class_params:) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 3614fec2c..f3d3589eb 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -11,7 +11,6 @@ def index end def show - @school = School.find(params[:id]) render :show, formats: [:json], status: :ok end diff --git a/app/models/ability.rb b/app/models/ability.rb index 17b2eb8a9..cf7ea7d5e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -25,7 +25,11 @@ def initialize(user) if user.school_teacher?(organisation_id:) can(%i[create], SchoolClass, school: { organisation_id: }) - can(%i[update], SchoolClass, school: { organisation_id: }, teacher_id: user.id) + can(%i[read update], SchoolClass, school: { organisation_id: }, teacher_id: user.id) + end + + if user.school_student?(organisation_id:) + can(%i[read], SchoolClass, school: { organisation_id: }, members: { student_id: user.id }) end end end diff --git a/config/routes.rb b/config/routes.rb index e9cdd8fbd..a30abedd8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,7 @@ resource :project_errors, only: %i[create] resources :schools, only: %i[index show create update] do - resources :classes, only: %i[create update], controller: 'school_classes' + resources :classes, only: %i[show create update], controller: 'school_classes' end end diff --git a/spec/factories/class_member.rb b/spec/factories/class_member.rb index c9d9aac30..2fd32fb4e 100644 --- a/spec/factories/class_member.rb +++ b/spec/factories/class_member.rb @@ -3,6 +3,6 @@ FactoryBot.define do factory :class_member do school_class - student_id { SecureRandom.uuid } + student_id { '22222222-2222-2222-2222-222222222222' } # Matches users.json. end end diff --git a/spec/factories/school.rb b/spec/factories/school.rb index 58cbd9670..2f077f633 100644 --- a/spec/factories/school.rb +++ b/spec/factories/school.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :school do organisation_id { '12345678-1234-1234-1234-123456789abc' } - owner_id { '00000000-0000-0000-0000-000000000000' } + owner_id { '00000000-0000-0000-0000-000000000000' } # Matches users.json. sequence(:name) { |n| "School #{n}" } address_line_1 { 'Address Line 1' } municipality { 'Greater London' } diff --git a/spec/factories/school_class.rb b/spec/factories/school_class.rb index 97e0e1604..83cea59c4 100644 --- a/spec/factories/school_class.rb +++ b/spec/factories/school_class.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :school_class do school - teacher_id { SecureRandom.uuid } + teacher_id { '11111111-1111-1111-1111-111111111111' } # Matches users.json. sequence(:name) { |n| "Class #{n}" } end end diff --git a/spec/features/school_class/creating_a_school_class_spec.rb b/spec/features/school_class/creating_a_school_class_spec.rb index dc8affedd..908afe0b1 100644 --- a/spec/features/school_class/creating_a_school_class_spec.rb +++ b/spec/features/school_class/creating_a_school_class_spec.rb @@ -27,6 +27,13 @@ expect(response).to have_http_status(:created) end + it 'responds 201 Created when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:created) + end + it 'responds with the school class JSON' do post("/api/schools/#{school.id}/classes", headers:, params:) data = JSON.parse(response.body, symbolize_names: true) @@ -65,4 +72,18 @@ post "/api/schools/#{school.id}/classes" expect(response).to have_http_status(:unauthorized) end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(organisation_id: '00000000-00000000-00000000-00000000') + + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post("/api/schools/#{school.id}/classes", headers:, params:) + expect(response).to have_http_status(:forbidden) + end end diff --git a/spec/features/school_class/showing_a_school_class_spec.rb b/spec/features/school_class/showing_a_school_class_spec.rb new file mode 100644 index 000000000..b8f5d6f43 --- /dev/null +++ b/spec/features/school_class/showing_a_school_class_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing a school class', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let!(:school_class) { create(:school_class, name: 'Test School Class') } + let(:school) { school_class.school } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:user_id) { stubbed_user_id } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is the class teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a student in the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + create(:class_member, school_class:) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school class JSON' do + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test School Class') + end + + it 'responds 404 Not Found when no school exists' do + get("/api/schools/not-a-real-id/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 404 Not Found when no school class exists' do + get("/api/schools/#{school.id}/classes/not-a-real-id", headers:) + expect(response).to have_http_status(:not_found) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/classes/#{school_class.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(organisation_id: '00000000-00000000-00000000-00000000') + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + school_class.update!(teacher_id: '99999999-99999999-99999999-99999999') + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not a school-student for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb index 4d6a03ff3..6f1ef1098 100644 --- a/spec/features/school_class/updating_a_school_class_spec.rb +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -27,6 +27,13 @@ expect(response).to have_http_status(:ok) end + it 'responds 200 OK when the user is the school-teacher for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + it 'responds with the school class JSON' do put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) data = JSON.parse(response.body, symbolize_names: true) @@ -49,8 +56,8 @@ expect(response).to have_http_status(:unauthorized) end - it 'responds 403 Forbidden when the user is not a school-owner or school-teacher' do - stub_hydra_public_api(user_index: user_index_by_role('school-student')) + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(organisation_id: '00000000-00000000-00000000-00000000') put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) expect(response).to have_http_status(:forbidden) @@ -64,8 +71,8 @@ expect(response).to have_http_status(:forbidden) end - it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school.update!(organisation_id: '00000000-00000000-00000000-00000000') + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) expect(response).to have_http_status(:forbidden) diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index cf177bbe3..eed0db8f7 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -14,8 +14,8 @@ end it 'has many members' do - school_class = create(:school_class, members: [build(:class_member), build(:class_member)]) - expect(school_class.members.size).to eq(2) + school_class = create(:school_class, members: [build(:class_member)]) + expect(school_class.members.size).to eq(1) end context 'when a school_class is destroyed' do From d6e4827e929d75b87ce2f14e3e1211c0894b61d6 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 12:38:06 +0000 Subject: [PATCH 049/124] Add a SchoolClasses#index controller action --- .../api/school_classes_controller.rb | 5 ++ .../api/school_classes/index.json.jbuilder | 13 +++++ config/routes.rb | 2 +- spec/features/school/listing_schools_spec.rb | 5 +- .../listing_school_classes_spec.rb | 54 +++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 app/views/api/school_classes/index.json.jbuilder create mode 100644 spec/features/school_class/listing_school_classes_spec.rb diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 988142be6..c256c1862 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -6,6 +6,11 @@ class SchoolClassesController < ApiController load_and_authorize_resource :school load_and_authorize_resource :school_class, through: :school, through_association: :classes + def index + @school_classes = SchoolClass.accessible_by(current_ability) + render :index, formats: [:json], status: :ok + end + def show render :show, formats: [:json], status: :ok end diff --git a/app/views/api/school_classes/index.json.jbuilder b/app/views/api/school_classes/index.json.jbuilder new file mode 100644 index 000000000..2b69f3757 --- /dev/null +++ b/app/views/api/school_classes/index.json.jbuilder @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +json.array!(@school_classes) do |school_class| + json.call( + school_class, + :id, + :school_id, + :teacher_id, + :name, + :created_at, + :updated_at + ) +end diff --git a/config/routes.rb b/config/routes.rb index a30abedd8..c31d71193 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,7 @@ resource :project_errors, only: %i[create] resources :schools, only: %i[index show create update] do - resources :classes, only: %i[show create update], controller: 'school_classes' + resources :classes, only: %i[index show create update], controller: 'school_classes' end end diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb index 00e644507..84660b96a 100644 --- a/spec/features/school/listing_schools_spec.rb +++ b/spec/features/school/listing_schools_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe 'Listing schools that the current user belongs to', type: :request do +RSpec.describe 'Listing schools', type: :request do before do stub_hydra_public_api stub_user_info_api @@ -18,7 +18,7 @@ expect(response).to have_http_status(:ok) end - it 'responds with the school JSON' do + it 'responds with the schools JSON' do get('/api/schools', headers:) data = JSON.parse(response.body, symbolize_names: true) @@ -27,7 +27,6 @@ it 'only includes schools the user belongs to' do create(:school, organisation_id: '00000000-0000-0000-0000-000000000000', owner_id: '99999999-9999-9999-9999-999999999999') - create(:school, organisation_id: '11111111-1111-1111-1111-111111111111', owner_id: '99999999-9999-9999-9999-999999999999') get('/api/schools', headers:) data = JSON.parse(response.body, symbolize_names: true) diff --git a/spec/features/school_class/listing_school_classes_spec.rb b/spec/features/school_class/listing_school_classes_spec.rb new file mode 100644 index 000000000..8d419a38c --- /dev/null +++ b/spec/features/school_class/listing_school_classes_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school classes', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + + create(:class_member, school_class:) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class, name: 'Test School Class') } + let(:school) { school_class.school } + let(:user_id) { stubbed_user_id } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/classes", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school classes JSON' do + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test School Class') + end + + it "does not include school classes that the school-teacher doesn't teach" do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + create(:school_class, school:, teacher_id: '99999999-9999-9999-9999-999999999999') + + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it "does not include school classes that the school-student isn't a member of" do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + create(:school_class, school:, teacher_id: '99999999-9999-9999-9999-999999999999') + + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/classes" + expect(response).to have_http_status(:unauthorized) + end +end From ad7e3e1a2f9badc11dcd321a0aa30b83a17d71eb Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 13:45:01 +0000 Subject: [PATCH 050/124] Make the organisation ID from the profile app the ID of the school MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I think it’s simpler to use the same ID for schools within our system rather than creating a separate UUID for the school. --- .../api/school_classes_controller.rb | 2 +- app/models/ability.rb | 12 ++++---- app/models/class_member.rb | 6 ++-- app/models/school.rb | 12 ++++---- app/models/school_class.rb | 6 ++-- app/views/api/schools/index.json.jbuilder | 1 - app/views/api/schools/show.json.jbuilder | 1 - db/migrate/20240201160923_create_schools.rb | 2 -- db/schema.rb | 2 -- lib/concepts/school/operations/create.rb | 4 +-- spec/concepts/school/create_spec.rb | 2 +- spec/factories/school.rb | 2 +- spec/features/school/listing_schools_spec.rb | 2 +- spec/features/school/showing_a_school_spec.rb | 2 +- .../features/school/updating_a_school_spec.rb | 2 +- .../creating_a_school_class_spec.rb | 2 +- .../listing_school_classes_spec.rb | 4 +-- .../showing_a_school_class_spec.rb | 5 ++-- .../updating_a_school_class_spec.rb | 5 ++-- spec/models/school_class_spec.rb | 4 +-- spec/models/school_spec.rb | 30 +++++++++---------- 21 files changed, 49 insertions(+), 59 deletions(-) diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index c256c1862..7b925c624 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -51,7 +51,7 @@ def school_class_params end def school_owner? - current_user.school_owner?(organisation_id: @school.organisation_id) + current_user.school_owner?(organisation_id: @school.id) end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index cf7ea7d5e..81fb28d2d 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -16,20 +16,20 @@ def initialize(user) can %i[create], School # The user agrees to become a school-owner by creating a school. user.organisation_ids.each do |organisation_id| - can(%i[read], School, organisation_id:) + can(%i[read], School, id: organisation_id) if user.school_owner?(organisation_id:) - can(%i[update], School, organisation_id:) - can(%i[read create update], SchoolClass, school: { organisation_id: }) + can(%i[update], School, id: organisation_id) + can(%i[read create update], SchoolClass, school: { id: organisation_id }) end if user.school_teacher?(organisation_id:) - can(%i[create], SchoolClass, school: { organisation_id: }) - can(%i[read update], SchoolClass, school: { organisation_id: }, teacher_id: user.id) + can(%i[create], SchoolClass, school: { id: organisation_id }) + can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) end if user.school_student?(organisation_id:) - can(%i[read], SchoolClass, school: { organisation_id: }, members: { student_id: user.id }) + can(%i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id }) end end end diff --git a/app/models/class_member.rb b/app/models/class_member.rb index 19c6630cd..ae5538fd6 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -17,11 +17,9 @@ def student_has_the_school_student_role_for_the_school return unless student_id_changed? && errors.blank? user = student - organisation_id = school.organisation_id + return unless user && !user.school_student?(organisation_id: school.id) - return unless user && !user.school_student?(organisation_id:) - - msg = "'#{student_id}' does not have the 'school-student' role for organisation '#{organisation_id}'" + msg = "'#{student_id}' does not have the 'school-student' role for organisation '#{school.id}'" errors.add(:student, msg) end diff --git a/app/models/school.rb b/app/models/school.rb index 127bbf880..84f2a3b6e 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -3,7 +3,7 @@ class School < ApplicationRecord has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy - validates :organisation_id, presence: true, uniqueness: { case_sensitive: false } + validates :id, presence: true, uniqueness: { case_sensitive: false } validates :owner_id, presence: true validates :name, presence: true validates :reference, uniqueness: { case_sensitive: false, allow_nil: true } @@ -16,19 +16,19 @@ def owner User.from_userinfo(ids: owner_id).first end - def valid_except_for_organisation? + def valid_except_for_id? validate - errors.attribute_names.all? { |name| name == :organisation_id } + errors.attribute_names.all? { |name| name == :id } end private def owner_has_the_school_owner_role_for_the_school - return unless owner_id_changed? && organisation_id && errors.blank? + return unless owner_id_changed? && id && errors.blank? user = owner - return unless user && !user.school_owner?(organisation_id:) + return unless user && !user.school_owner?(organisation_id: id) - errors.add(:user, "'#{owner_id}' does not have the 'school-owner' role for organisation '#{organisation_id}'") + errors.add(:user, "'#{owner_id}' does not have the 'school-owner' role for organisation '#{id}'") end end diff --git a/app/models/school_class.rb b/app/models/school_class.rb index adb6a7e32..e38f8cab3 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -22,11 +22,9 @@ def teacher_has_the_school_teacher_role_for_the_school return unless teacher_id_changed? && errors.blank? user = teacher - organisation_id = school.organisation_id + return unless user && !user.school_teacher?(organisation_id: school.id) - return unless user && !user.school_teacher?(organisation_id:) - - msg = "'#{teacher_id}' does not have the 'school-teacher' role for organisation '#{organisation_id}'" + msg = "'#{teacher_id}' does not have the 'school-teacher' role for organisation '#{school.id}'" errors.add(:user, msg) end end diff --git a/app/views/api/schools/index.json.jbuilder b/app/views/api/schools/index.json.jbuilder index eea1f3f34..b6464e8a7 100644 --- a/app/views/api/schools/index.json.jbuilder +++ b/app/views/api/schools/index.json.jbuilder @@ -4,7 +4,6 @@ json.array!(@schools) do |school| json.call( school, :id, - :organisation_id, :owner_id, :name, :reference, diff --git a/app/views/api/schools/show.json.jbuilder b/app/views/api/schools/show.json.jbuilder index 08492fc06..6c4a54338 100644 --- a/app/views/api/schools/show.json.jbuilder +++ b/app/views/api/schools/show.json.jbuilder @@ -3,7 +3,6 @@ json.call( @school, :id, - :organisation_id, :owner_id, :name, :reference, diff --git a/db/migrate/20240201160923_create_schools.rb b/db/migrate/20240201160923_create_schools.rb index 1c6a9e75b..93debac62 100644 --- a/db/migrate/20240201160923_create_schools.rb +++ b/db/migrate/20240201160923_create_schools.rb @@ -1,7 +1,6 @@ class CreateSchools < ActiveRecord::Migration[7.0] def change create_table :schools, id: :uuid do |t| - t.uuid :organisation_id, null: false t.uuid :owner_id, null: false t.string :name, null: false t.string :reference @@ -17,7 +16,6 @@ def change t.timestamps end - add_index :schools, :organisation_id, unique: true add_index :schools, :reference, unique: true end end diff --git a/db/schema.rb b/db/schema.rb index 5017d44be..c939f9e2b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -160,7 +160,6 @@ end create_table "schools", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "organisation_id", null: false t.uuid "owner_id", null: false t.string "name", null: false t.string "reference" @@ -173,7 +172,6 @@ t.datetime "verified_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["organisation_id"], name: "index_schools_on_organisation_id", unique: true t.index ["reference"], name: "index_schools_on_reference", unique: true end diff --git a/lib/concepts/school/operations/create.rb b/lib/concepts/school/operations/create.rb index 62d5790d2..efd11ceff 100644 --- a/lib/concepts/school/operations/create.rb +++ b/lib/concepts/school/operations/create.rb @@ -21,9 +21,9 @@ def build_school(school_params, current_user) school = School.new(school_params) school.owner_id = current_user&.id - if school.valid_except_for_organisation? + if school.valid_except_for_id? response = ProfileApiClient.create_organisation(token: current_user&.token) - school.organisation_id = response&.fetch(:id) + school.id = response&.fetch(:id) end school diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb index e16f1c3ee..acb4821cc 100644 --- a/spec/concepts/school/create_spec.rb +++ b/spec/concepts/school/create_spec.rb @@ -40,7 +40,7 @@ it 'assigns the organisation_id' do response = described_class.call(school_params:, current_user:) - expect(response[:school].organisation_id).to eq(ProfileApiMock::ORGANISATION_ID) + expect(response[:school].id).to eq(ProfileApiMock::ORGANISATION_ID) end context 'when creation fails' do diff --git a/spec/factories/school.rb b/spec/factories/school.rb index 2f077f633..f5de1fa6c 100644 --- a/spec/factories/school.rb +++ b/spec/factories/school.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :school do - organisation_id { '12345678-1234-1234-1234-123456789abc' } + id { '12345678-1234-1234-1234-123456789abc' } owner_id { '00000000-0000-0000-0000-000000000000' } # Matches users.json. sequence(:name) { |n| "School #{n}" } address_line_1 { 'Address Line 1' } diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb index 84660b96a..1f37e4a72 100644 --- a/spec/features/school/listing_schools_spec.rb +++ b/spec/features/school/listing_schools_spec.rb @@ -26,7 +26,7 @@ end it 'only includes schools the user belongs to' do - create(:school, organisation_id: '00000000-0000-0000-0000-000000000000', owner_id: '99999999-9999-9999-9999-999999999999') + create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) get('/api/schools', headers:) data = JSON.parse(response.body, symbolize_names: true) diff --git a/spec/features/school/showing_a_school_spec.rb b/spec/features/school/showing_a_school_spec.rb index 8f26b1ab0..39ee0ba1d 100644 --- a/spec/features/school/showing_a_school_spec.rb +++ b/spec/features/school/showing_a_school_spec.rb @@ -35,7 +35,7 @@ end it 'responds 403 Forbidden when the user belongs to a different school' do - school.update!(organisation_id: '00000000-00000000-00000000-00000000') + school.update!(id: SecureRandom.uuid) get("/api/schools/#{school.id}", headers:) expect(response).to have_http_status(:forbidden) diff --git a/spec/features/school/updating_a_school_spec.rb b/spec/features/school/updating_a_school_spec.rb index 67e7613ea..d53dc98ee 100644 --- a/spec/features/school/updating_a_school_spec.rb +++ b/spec/features/school/updating_a_school_spec.rb @@ -60,7 +60,7 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school.update!(organisation_id: '00000000-00000000-00000000-00000000') + school.update!(id: SecureRandom.uuid) put("/api/schools/#{school.id}", headers:, params:) expect(response).to have_http_status(:forbidden) diff --git a/spec/features/school_class/creating_a_school_class_spec.rb b/spec/features/school_class/creating_a_school_class_spec.rb index 908afe0b1..72c827fca 100644 --- a/spec/features/school_class/creating_a_school_class_spec.rb +++ b/spec/features/school_class/creating_a_school_class_spec.rb @@ -74,7 +74,7 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school.update!(organisation_id: '00000000-00000000-00000000-00000000') + school.update!(id: SecureRandom.uuid) post("/api/schools/#{school.id}/classes", headers:, params:) expect(response).to have_http_status(:forbidden) diff --git a/spec/features/school_class/listing_school_classes_spec.rb b/spec/features/school_class/listing_school_classes_spec.rb index 8d419a38c..baee1287a 100644 --- a/spec/features/school_class/listing_school_classes_spec.rb +++ b/spec/features/school_class/listing_school_classes_spec.rb @@ -29,7 +29,7 @@ it "does not include school classes that the school-teacher doesn't teach" do stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) - create(:school_class, school:, teacher_id: '99999999-9999-9999-9999-999999999999') + create(:school_class, school:, teacher_id: SecureRandom.uuid) get("/api/schools/#{school.id}/classes", headers:) data = JSON.parse(response.body, symbolize_names: true) @@ -39,7 +39,7 @@ it "does not include school classes that the school-student isn't a member of" do stub_hydra_public_api(user_index: user_index_by_role('school-student')) - create(:school_class, school:, teacher_id: '99999999-9999-9999-9999-999999999999') + create(:school_class, school:, teacher_id: SecureRandom.uuid) get("/api/schools/#{school.id}/classes", headers:) data = JSON.parse(response.body, symbolize_names: true) diff --git a/spec/features/school_class/showing_a_school_class_spec.rb b/spec/features/school_class/showing_a_school_class_spec.rb index b8f5d6f43..5b83fa7e6 100644 --- a/spec/features/school_class/showing_a_school_class_spec.rb +++ b/spec/features/school_class/showing_a_school_class_spec.rb @@ -56,7 +56,8 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school.update!(organisation_id: '00000000-00000000-00000000-00000000') + school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school_class.update!(school_id: school.id) get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) expect(response).to have_http_status(:forbidden) @@ -64,7 +65,7 @@ it 'responds 403 Forbidden when the user is not the school-teacher for the class' do stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) - school_class.update!(teacher_id: '99999999-99999999-99999999-99999999') + school_class.update!(teacher_id: SecureRandom.uuid) get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) expect(response).to have_http_status(:forbidden) diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb index 6f1ef1098..c568f6dcb 100644 --- a/spec/features/school_class/updating_a_school_class_spec.rb +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -57,7 +57,8 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school.update!(organisation_id: '00000000-00000000-00000000-00000000') + school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school_class.update!(school_id: school.id) put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) expect(response).to have_http_status(:forbidden) @@ -65,7 +66,7 @@ it 'responds 403 Forbidden when the user is not the school-teacher for the class' do stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) - school_class.update!(teacher_id: '99999999-99999999-99999999-99999999') + school_class.update!(teacher_id: SecureRandom.uuid) put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) expect(response).to have_http_status(:forbidden) diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index eed0db8f7..59da1deb7 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -71,7 +71,7 @@ end it 'returns nil if no profile account exists' do - school_class = create(:school_class, teacher_id: '99999999-9999-9999-9999-999999999999') + school_class = create(:school_class, teacher_id: SecureRandom.uuid) expect(school_class.teacher).to be_nil end end @@ -86,7 +86,7 @@ end it 'ignores members where no profile account exists' do - member = build(:class_member, student_id: '99999999-9999-9999-9999-999999999999') + member = build(:class_member, student_id: SecureRandom.uuid) school_class = create(:school_class, members: [member]) student = school_class.students.first diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index f512f668c..cfe14ccab 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -38,13 +38,20 @@ expect { school.save! }.not_to raise_error end - it 'requires an organisation_id' do - school.organisation_id = ' ' + # The school's ID must be set before create from the profile app's organisation ID. + # This avoids having two different IDs for a school which would be confusing. + it 'requires an id' do + school.id = ' ' expect(school).to be_invalid end - it 'requires a UUID organisation_id' do - school.organisation_id = 'invalid' + it 'requires a UUID id' do + school.id = 'invalid' + expect(school).to be_invalid + end + + it 'requires a unique id' do + create(:school) expect(school).to be_invalid end @@ -63,15 +70,6 @@ expect(school).to be_invalid end - it 'requires a unique organisation_id' do - school.save! - - duplicate_id = school.organisation_id.upcase - duplicate_school = build(:school, organisation_id: duplicate_id) - - expect(duplicate_school).to be_invalid - end - it 'requires a name' do school.name = ' ' expect(school).to be_invalid @@ -80,8 +78,8 @@ it 'does not require a reference' do create(:school, reference: nil) - school.organisation_id = '99999999-9999-9999-9999-999999999999' # Satisfy the uniqueness validation. - school.owner_id = '99999999-9999-9999-9999-999999999999' # Satisfy the school-owner validation. + school.id = SecureRandom.uuid # Satisfy the uniqueness validation. + school.owner_id = SecureRandom.uuid # Satisfy the school-owner validation. school.reference = nil expect(school).to be_valid @@ -123,7 +121,7 @@ end it 'returns nil if no profile account exists' do - school = create(:school, owner_id: '99999999-9999-9999-9999-999999999999') + school = create(:school, owner_id: SecureRandom.uuid) expect(school.owner).to be_nil end end From 136a42d6ccadca7b9a58565f9b8e325f0eccd0b4 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 14:04:52 +0000 Subject: [PATCH 051/124] Remove unused let(:user_id) in spec/features/ --- spec/features/school/creating_a_school_spec.rb | 1 - spec/features/school/listing_schools_spec.rb | 1 - spec/features/school/showing_a_school_spec.rb | 1 - spec/features/school/updating_a_school_spec.rb | 1 - spec/features/school_class/listing_school_classes_spec.rb | 1 - spec/features/school_class/showing_a_school_class_spec.rb | 1 - 6 files changed, 6 deletions(-) diff --git a/spec/features/school/creating_a_school_spec.rb b/spec/features/school/creating_a_school_spec.rb index da5167af7..a577b3ece 100644 --- a/spec/features/school/creating_a_school_spec.rb +++ b/spec/features/school/creating_a_school_spec.rb @@ -10,7 +10,6 @@ end let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:user_id) { stubbed_user_id } let(:params) do { diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb index 1f37e4a72..03ebf5bea 100644 --- a/spec/features/school/listing_schools_spec.rb +++ b/spec/features/school/listing_schools_spec.rb @@ -11,7 +11,6 @@ end let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:user_id) { stubbed_user_id } it 'responds 200 OK' do get('/api/schools', headers:) diff --git a/spec/features/school/showing_a_school_spec.rb b/spec/features/school/showing_a_school_spec.rb index 39ee0ba1d..0a9920f3e 100644 --- a/spec/features/school/showing_a_school_spec.rb +++ b/spec/features/school/showing_a_school_spec.rb @@ -10,7 +10,6 @@ let!(:school) { create(:school, name: 'Test School') } let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:user_id) { stubbed_user_id } it 'responds 200 OK' do get("/api/schools/#{school.id}", headers:) diff --git a/spec/features/school/updating_a_school_spec.rb b/spec/features/school/updating_a_school_spec.rb index d53dc98ee..babb472ee 100644 --- a/spec/features/school/updating_a_school_spec.rb +++ b/spec/features/school/updating_a_school_spec.rb @@ -10,7 +10,6 @@ let!(:school) { create(:school) } let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:user_id) { stubbed_user_id } let(:params) do { diff --git a/spec/features/school_class/listing_school_classes_spec.rb b/spec/features/school_class/listing_school_classes_spec.rb index baee1287a..e48576a32 100644 --- a/spec/features/school_class/listing_school_classes_spec.rb +++ b/spec/features/school_class/listing_school_classes_spec.rb @@ -13,7 +13,6 @@ let(:headers) { { Authorization: UserProfileMock::TOKEN } } let!(:school_class) { create(:school_class, name: 'Test School Class') } let(:school) { school_class.school } - let(:user_id) { stubbed_user_id } it 'responds 200 OK' do get("/api/schools/#{school.id}/classes", headers:) diff --git a/spec/features/school_class/showing_a_school_class_spec.rb b/spec/features/school_class/showing_a_school_class_spec.rb index 5b83fa7e6..afb6c33cc 100644 --- a/spec/features/school_class/showing_a_school_class_spec.rb +++ b/spec/features/school_class/showing_a_school_class_spec.rb @@ -11,7 +11,6 @@ let!(:school_class) { create(:school_class, name: 'Test School Class') } let(:school) { school_class.school } let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:user_id) { stubbed_user_id } it 'responds 200 OK' do get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) From c9eb4a8afbe7ea3c7f3a6548dfdfe14bbd41b81e Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 14:26:46 +0000 Subject: [PATCH 052/124] Use better names for methods in UserProfileMock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was confusing that some of the method names started with ‘stubbed_’ but were just looking up data from the users.json file and didn’t depend on a WebMock stub being in place. --- spec/concepts/school_class/create_spec.rb | 2 +- .../school_class/creating_a_school_class_spec.rb | 2 +- .../school_class/updating_a_school_class_spec.rb | 2 +- .../mutations/create_component_mutation_spec.rb | 2 +- .../mutations/delete_project_mutation_spec.rb | 6 +++++- .../mutations/remix_project_mutation_spec.rb | 4 ++-- .../mutations/update_component_mutation_spec.rb | 10 ++++------ .../mutations/update_project_mutation_spec.rb | 6 +++++- spec/graphql/queries/project_query_spec.rb | 2 +- spec/graphql/queries/projects_query_spec.rb | 6 +++--- spec/requests/projects/create_spec.rb | 2 +- spec/requests/projects/destroy_spec.rb | 2 +- spec/requests/projects/images_spec.rb | 2 +- spec/requests/projects/index_spec.rb | 6 +++--- spec/requests/projects/remix_spec.rb | 2 +- spec/requests/projects/show_spec.rb | 4 ++-- spec/requests/projects/update_spec.rb | 2 +- spec/support/user_profile_mock.rb | 16 ++++++++-------- 18 files changed, 42 insertions(+), 36 deletions(-) diff --git a/spec/concepts/school_class/create_spec.rb b/spec/concepts/school_class/create_spec.rb index 8a36c2fc0..25eb76028 100644 --- a/spec/concepts/school_class/create_spec.rb +++ b/spec/concepts/school_class/create_spec.rb @@ -5,7 +5,7 @@ RSpec.describe SchoolClass::Create, type: :unit do let(:school) { create(:school) } let(:teacher_index) { user_index_by_role('school-teacher') } - let(:teacher_id) { stubbed_user_id(user_index: teacher_index) } + let(:teacher_id) { user_id_by_index(teacher_index) } let(:school_class_params) do { name: 'Test School Class', teacher_id: } diff --git a/spec/features/school_class/creating_a_school_class_spec.rb b/spec/features/school_class/creating_a_school_class_spec.rb index 72c827fca..194d6a1f6 100644 --- a/spec/features/school_class/creating_a_school_class_spec.rb +++ b/spec/features/school_class/creating_a_school_class_spec.rb @@ -11,7 +11,7 @@ let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:school) { create(:school) } let(:teacher_index) { user_index_by_role('school-teacher') } - let(:teacher_id) { stubbed_user_id(user_index: teacher_index) } + let(:teacher_id) { user_id_by_index(teacher_index) } let(:params) do { diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb index c568f6dcb..0fc2a5ae2 100644 --- a/spec/features/school_class/updating_a_school_class_spec.rb +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -12,7 +12,7 @@ let!(:school_class) { create(:school_class, name: 'Test School Class') } let(:school) { school_class.school } let(:teacher_index) { user_index_by_role('school-teacher') } - let(:teacher_id) { stubbed_user_id(user_index: teacher_index) } + let(:teacher_id) { user_id_by_index(teacher_index) } let(:params) do { diff --git a/spec/graphql/mutations/create_component_mutation_spec.rb b/spec/graphql/mutations/create_component_mutation_spec.rb index caca695ce..e60316047 100644 --- a/spec/graphql/mutations/create_component_mutation_spec.rb +++ b/spec/graphql/mutations/create_component_mutation_spec.rb @@ -44,7 +44,7 @@ context 'when authenticated' do let(:current_user) { stubbed_user } - let(:project) { create(:project, user_id: stubbed_user_id) } + let(:project) { create(:project, user_id: stubbed_user.id) } before do stub_hydra_public_api diff --git a/spec/graphql/mutations/delete_project_mutation_spec.rb b/spec/graphql/mutations/delete_project_mutation_spec.rb index 1472139a7..5dbd8eec3 100644 --- a/spec/graphql/mutations/delete_project_mutation_spec.rb +++ b/spec/graphql/mutations/delete_project_mutation_spec.rb @@ -5,6 +5,10 @@ RSpec.describe 'mutation DeleteProject() { ... }' do subject(:result) { execute_query(query: mutation, variables:) } + before do + stub_hydra_public_api + end + let(:mutation) { 'mutation DeleteProject($project: DeleteProjectInput!) { deleteProject(input: $project) { id } }' } let(:project_id) { 'dummy-id' } let(:variables) { { project: { id: project_id } } } @@ -12,7 +16,7 @@ it { expect(mutation).to be_a_valid_graphql_query } context 'with an existing project' do - let!(:project) { create(:project, user_id: stubbed_user_id) } + let!(:project) { create(:project, user_id: stubbed_user.id) } let(:project_id) { project.to_gid_param } context 'when unauthenticated' do diff --git a/spec/graphql/mutations/remix_project_mutation_spec.rb b/spec/graphql/mutations/remix_project_mutation_spec.rb index ea66132e6..7859c88aa 100644 --- a/spec/graphql/mutations/remix_project_mutation_spec.rb +++ b/spec/graphql/mutations/remix_project_mutation_spec.rb @@ -16,14 +16,14 @@ ' end let(:current_user) { stubbed_user } - let(:project) { create(:project, :with_default_component, user_id: stubbed_user_id) } + let(:project) { create(:project, :with_default_component, user_id: stubbed_user.id) } let(:project_id) { project.to_gid_param } let(:variables) { { id: project_id } } let(:remix_origin) { 'editor.com' } before do - project stub_hydra_public_api + project end it { expect(mutation).to be_a_valid_graphql_query } diff --git a/spec/graphql/mutations/update_component_mutation_spec.rb b/spec/graphql/mutations/update_component_mutation_spec.rb index 4d981067b..fc3129b3f 100644 --- a/spec/graphql/mutations/update_component_mutation_spec.rb +++ b/spec/graphql/mutations/update_component_mutation_spec.rb @@ -22,16 +22,14 @@ it { expect(mutation).to be_a_valid_graphql_query } context 'with an existing component' do - let(:project) { create(:project, user_id: stubbed_user_id) } - let(:component) { create(:component, project:, name: 'bob', extension: 'html', content: 'new', default: true) } - let(:component_id) { component.to_gid_param } - before do - # Instantiate component - component stub_hydra_public_api end + let(:project) { create(:project, user_id: stubbed_user.id) } + let!(:component) { create(:component, project:, name: 'bob', extension: 'html', content: 'new', default: true) } + let(:component_id) { component.to_gid_param } + context 'when unauthenticated' do it 'does not update a component' do expect { result }.not_to change { component.reload.name } diff --git a/spec/graphql/mutations/update_project_mutation_spec.rb b/spec/graphql/mutations/update_project_mutation_spec.rb index e92adfb83..c3da5ef3e 100644 --- a/spec/graphql/mutations/update_project_mutation_spec.rb +++ b/spec/graphql/mutations/update_project_mutation_spec.rb @@ -5,6 +5,10 @@ RSpec.describe 'mutation UpdateProject() { ... }' do subject(:result) { execute_query(query: mutation, variables:) } + before do + stub_hydra_public_api + end + let(:mutation) { 'mutation UpdateProject($project: UpdateProjectInput!) { updateProject(input: $project) { project { id } } }' } let(:project_id) { 'dummy-id' } let(:variables) do @@ -20,7 +24,7 @@ it { expect(mutation).to be_a_valid_graphql_query } context 'with an existing project' do - let(:project) { create(:project, user_id: stubbed_user_id, project_type: :python) } + let(:project) { create(:project, user_id: stubbed_user.id, project_type: :python) } let(:project_id) { project.to_gid_param } before do diff --git a/spec/graphql/queries/project_query_spec.rb b/spec/graphql/queries/project_query_spec.rb index fbe2f5b17..494fdbb43 100644 --- a/spec/graphql/queries/project_query_spec.rb +++ b/spec/graphql/queries/project_query_spec.rb @@ -89,7 +89,7 @@ context 'when logged in' do let(:current_user) { stubbed_user } - let(:project) { create(:project, user_id: stubbed_user_id) } + let(:project) { create(:project, user_id: stubbed_user.id) } before do stub_hydra_public_api diff --git a/spec/graphql/queries/projects_query_spec.rb b/spec/graphql/queries/projects_query_spec.rb index a7962fd72..3f85217a6 100644 --- a/spec/graphql/queries/projects_query_spec.rb +++ b/spec/graphql/queries/projects_query_spec.rb @@ -46,7 +46,7 @@ context 'when fetching project when logged in' do let(:query) { 'query { projects { edges { node { id } } } }' } let(:current_user) { stubbed_user } - let(:project) { create(:project, user_id: stubbed_user_id) } + let(:project) { create(:project, user_id: stubbed_user.id) } before do stub_hydra_public_api @@ -83,8 +83,8 @@ context 'when fetching projects by user ID when logged in' do let(:query) { 'query ($userId: String) { projects(userId: $userId) { edges { node { id } } } }' } let(:current_user) { stubbed_user } - let(:variables) { { userId: stubbed_user_id } } - let(:project) { create(:project, user_id: stubbed_user_id) } + let(:variables) { { userId: stubbed_user.id } } + let(:project) { create(:project, user_id: stubbed_user.id) } before do stub_hydra_public_api diff --git a/spec/requests/projects/create_spec.rb b/spec/requests/projects/create_spec.rb index e2b4e395b..184e53d05 100644 --- a/spec/requests/projects/create_spec.rb +++ b/spec/requests/projects/create_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe 'Create project requests' do - let(:project) { create(:project, user_id: stubbed_user_id) } + let(:project) { create(:project, user_id: stubbed_user.id) } context 'when auth is correct' do let(:headers) { { Authorization: UserProfileMock::TOKEN } } diff --git a/spec/requests/projects/destroy_spec.rb b/spec/requests/projects/destroy_spec.rb index 236f728f2..084651547 100644 --- a/spec/requests/projects/destroy_spec.rb +++ b/spec/requests/projects/destroy_spec.rb @@ -4,7 +4,7 @@ RSpec.describe 'Project delete requests' do context 'when user is logged in' do - let!(:project) { create(:project, user_id: stubbed_user_id, locale: nil) } + let!(:project) { create(:project, user_id: user_id_by_index(0), locale: nil) } let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do diff --git a/spec/requests/projects/images_spec.rb b/spec/requests/projects/images_spec.rb index bec90499b..5fa2bafa1 100644 --- a/spec/requests/projects/images_spec.rb +++ b/spec/requests/projects/images_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe 'Images requests' do - let(:project) { create(:project, user_id: stubbed_user_id) } + let(:project) { create(:project, user_id: user_id_by_index(0)) } let(:image_filename) { 'test_image_1.png' } let(:params) { { images: [fixture_file_upload(image_filename, 'image/png')] } } let(:expected_json) do diff --git a/spec/requests/projects/index_spec.rb b/spec/requests/projects/index_spec.rb index b7e4ec3a7..7656a6142 100644 --- a/spec/requests/projects/index_spec.rb +++ b/spec/requests/projects/index_spec.rb @@ -9,7 +9,7 @@ let(:project_keys) { %w[identifier project_type name user_id updated_at] } before do - create_list(:project, 2, user_id: stubbed_user_id) + create_list(:project, 2, user_id: user_id_by_index(0)) end context 'when user is logged in' do @@ -33,7 +33,7 @@ it 'returns users projects' do get('/api/projects', headers:) returned = response.parsed_body - expect(returned.all? { |proj| proj['user_id'] == stubbed_user_id }).to be(true) + expect(returned.all? { |proj| proj['user_id'] == stubbed_user.id }).to be(true) end it 'returns all keys in response' do @@ -46,7 +46,7 @@ context 'when the projects index has pagination' do before do stub_hydra_public_api - create_list(:project, 10, user_id: stubbed_user_id) + create_list(:project, 10, user_id: stubbed_user.id) end it 'returns the default number of projects on the first page' do diff --git a/spec/requests/projects/remix_spec.rb b/spec/requests/projects/remix_spec.rb index 8e194f355..f7c6e3092 100644 --- a/spec/requests/projects/remix_spec.rb +++ b/spec/requests/projects/remix_spec.rb @@ -30,7 +30,7 @@ describe '#show' do before do - create(:project, remixed_from_id: original_project.id, user_id: stubbed_user_id) + create(:project, remixed_from_id: original_project.id, user_id: stubbed_user.id) end it 'returns success response' do diff --git a/spec/requests/projects/show_spec.rb b/spec/requests/projects/show_spec.rb index b08a168f2..6f4a4c67e 100644 --- a/spec/requests/projects/show_spec.rb +++ b/spec/requests/projects/show_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe 'Project show requests' do - let!(:project) { create(:project, user_id: stubbed_user_id, locale: nil) } + let!(:project) { create(:project, user_id: user_id_by_index(0), locale: nil) } let(:project_json) do { identifier: project.identifier, @@ -43,7 +43,7 @@ end context 'when loading another user\'s project' do - let!(:another_project) { create(:project, user_id: stubbed_user_id(user_index: 1), locale: nil) } + let!(:another_project) { create(:project, user_id: user_id_by_index(1), locale: nil) } let(:another_project_json) do { identifier: another_project.identifier, diff --git a/spec/requests/projects/update_spec.rb b/spec/requests/projects/update_spec.rb index 63a541fd3..638eb3e09 100644 --- a/spec/requests/projects/update_spec.rb +++ b/spec/requests/projects/update_spec.rb @@ -6,7 +6,7 @@ let(:headers) { { Authorization: UserProfileMock::TOKEN } } context 'when authed user is project creator' do - let(:project) { create(:project, :with_default_component, user_id: stubbed_user_id, locale: nil) } + let(:project) { create(:project, :with_default_component, user_id: user_id_by_index(0), locale: nil) } let!(:component) { create(:component, project:) } let(:default_component_params) do project.components.first.attributes.symbolize_keys.slice( diff --git a/spec/support/user_profile_mock.rb b/spec/support/user_profile_mock.rb index ca473a45c..6efcbe44f 100644 --- a/spec/support/user_profile_mock.rb +++ b/spec/support/user_profile_mock.rb @@ -11,7 +11,7 @@ def stub_user_info_api .to_return do |request| uuids = JSON.parse(request.body).fetch('https://github.com/userIds', []) indexes = uuids.map { |uuid| user_index_by_uuid(uuid) }.compact - users = indexes.map { |user_index| stubbed_user_attributes(user_index:) } + users = indexes.map { |user_index| user_attributes_by_index(user_index) } { body: { users: }.to_json, headers: { 'Content-Type' => 'application/json' } } end @@ -24,20 +24,20 @@ def stub_hydra_public_api(user_index: 0, token: TOKEN) .to_return( status: 200, headers: { content_type: 'application/json' }, - body: stubbed_user_attributes(user_index:).to_json + body: user_attributes_by_index(user_index).to_json ) end - def stubbed_user_attributes(user_index: 0) - JSON.parse(USERS)['users'][user_index] if user_index + def stubbed_user + User.from_omniauth(token: TOKEN) end - def stubbed_user_id(user_index: 0) - stubbed_user_attributes(user_index:)&.fetch('https://github.com/id') + def user_attributes_by_index(user_index = 0) + JSON.parse(USERS)['users'][user_index] if user_index end - def stubbed_user - User.from_omniauth(token: TOKEN) + def user_id_by_index(user_index) + user_attributes_by_index(user_index)&.fetch('https://github.com/id') end def user_index_by_uuid(uuid) From c69b7c98fd387190f476d0887abb48150f27659e Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 15:17:04 +0000 Subject: [PATCH 053/124] Add a ClassMembers#create controller action --- .../api/class_members_controller.rb | 27 +++++++ app/models/ability.rb | 2 + .../api/class_members/show.json.jbuilder | 10 +++ config/routes.rb | 4 +- .../class_member/operations/create.rb | 19 +++++ spec/concepts/class_member/create_spec.rb | 64 +++++++++++++++ .../creating_a_class_member_spec.rb | 81 +++++++++++++++++++ 7 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/class_members_controller.rb create mode 100644 app/views/api/class_members/show.json.jbuilder create mode 100644 lib/concepts/class_member/operations/create.rb create mode 100644 spec/concepts/class_member/create_spec.rb create mode 100644 spec/features/class_member/creating_a_class_member_spec.rb diff --git a/app/controllers/api/class_members_controller.rb b/app/controllers/api/class_members_controller.rb new file mode 100644 index 000000000..d023c3516 --- /dev/null +++ b/app/controllers/api/class_members_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Api + class ClassMembersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + load_and_authorize_resource :school_class, through: :school, through_association: :classes, id_param: :class_id + load_and_authorize_resource :class_member, through: :school_class, through_association: :members + + def create + result = ClassMember::Create.call(school_class: @school_class, class_member_params:) + + if result.success? + @class_member = result[:class_member] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def class_member_params + params.require(:class_member).permit(:student_id) + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 81fb28d2d..b22f7912e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -21,11 +21,13 @@ def initialize(user) if user.school_owner?(organisation_id:) can(%i[update], School, id: organisation_id) can(%i[read create update], SchoolClass, school: { id: organisation_id }) + can(%i[create], ClassMember, school_class: { school: { id: organisation_id } }) end if user.school_teacher?(organisation_id:) can(%i[create], SchoolClass, school: { id: organisation_id }) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) + can(%i[create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) end if user.school_student?(organisation_id:) diff --git a/app/views/api/class_members/show.json.jbuilder b/app/views/api/class_members/show.json.jbuilder new file mode 100644 index 000000000..5134a0d58 --- /dev/null +++ b/app/views/api/class_members/show.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.call( + @class_member, + :id, + :school_class_id, + :student_id, + :created_at, + :updated_at +) diff --git a/config/routes.rb b/config/routes.rb index c31d71193..59e2aceab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,9 @@ resource :project_errors, only: %i[create] resources :schools, only: %i[index show create update] do - resources :classes, only: %i[index show create update], controller: 'school_classes' + resources :classes, only: %i[index show create update], controller: 'school_classes' do + resources :members, only: %i[create], controller: 'class_members' + end end end diff --git a/lib/concepts/class_member/operations/create.rb b/lib/concepts/class_member/operations/create.rb new file mode 100644 index 000000000..0ba192597 --- /dev/null +++ b/lib/concepts/class_member/operations/create.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ClassMember + class Create + class << self + def call(school_class:, class_member_params:) + response = OperationResponse.new + response[:class_member] = school_class.members.build(class_member_params) + response[:class_member].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:class_member].errors.full_messages.join(',') + response[:error] = "Error creating class member: #{errors}" + response + end + end + end +end diff --git a/spec/concepts/class_member/create_spec.rb b/spec/concepts/class_member/create_spec.rb new file mode 100644 index 000000000..13485fcdb --- /dev/null +++ b/spec/concepts/class_member/create_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassMember::Create, type: :unit do + before do + stub_user_info_api + end + + let!(:school_class) { create(:school_class) } + let(:school) { school_class.school } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + let(:class_member_params) do + { student_id: } + end + + it 'creates a school class' do + expect { described_class.call(school_class:, class_member_params:) }.to change(ClassMember, :count).by(1) + end + + it 'returns the class member in the operation response' do + response = described_class.call(school_class:, class_member_params:) + expect(response[:class_member]).to be_a(ClassMember) + end + + it 'assigns the school_class' do + response = described_class.call(school_class:, class_member_params:) + expect(response[:class_member].school_class).to eq(school_class) + end + + it 'assigns the student_id' do + response = described_class.call(school_class:, class_member_params:) + expect(response[:class_member].student_id).to eq(student_id) + end + + context 'when creation fails' do + let(:class_member_params) { {} } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a class member' do + expect { described_class.call(school_class:, class_member_params:) }.not_to change(ClassMember, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(school_class:, class_member_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_class:, class_member_params:) + expect(response[:error]).to match(/Error creating class member/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, class_member_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb new file mode 100644 index 000000000..11b7b376e --- /dev/null +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a class member', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class) } + let(:school) { school_class.school } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + let(:params) do + { + class_member: { + student_id: + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the class member JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:student_id]).to eq(student_id) + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: { class_member: { student_id: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + school_class.update!(teacher_id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end From f1e8ed79d28ca293f09446f4c8b42143f5059519 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 16:59:24 +0000 Subject: [PATCH 054/124] Move school_class#students to ClassMember.students This should be more versatile since it can be called on any scope of class members, rather than just the class members for a school class. --- app/models/class_member.rb | 12 +++++------- app/models/school_class.rb | 4 ---- spec/models/class_member_spec.rb | 23 +++++++++++++++++++++++ spec/models/school_class_spec.rb | 18 ------------------ 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/app/models/class_member.rb b/app/models/class_member.rb index ae5538fd6..e64a4114e 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -11,21 +11,19 @@ class ClassMember < ApplicationRecord validate :student_has_the_school_student_role_for_the_school + def self.students + User.from_userinfo(ids: pluck(:student_id)) + end + private def student_has_the_school_student_role_for_the_school return unless student_id_changed? && errors.blank? - user = student + user = User.from_userinfo(ids: student_id).first return unless user && !user.school_student?(organisation_id: school.id) msg = "'#{student_id}' does not have the 'school-student' role for organisation '#{school.id}'" errors.add(:student, msg) end - - # Intentionally make this private to avoid N API calls. - # Prefer using SchoolClass#students which makes 1 API call. - def student - User.from_userinfo(ids: student_id).first - end end diff --git a/app/models/school_class.rb b/app/models/school_class.rb index e38f8cab3..1ecd690a0 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -12,10 +12,6 @@ def teacher User.from_userinfo(ids: teacher_id).first end - def students - User.from_userinfo(ids: members.pluck(:student_id)) - end - private def teacher_has_the_school_teacher_role_for_the_school diff --git a/spec/models/class_member_spec.rb b/spec/models/class_member_spec.rb index e0070f1a1..ae0b3740c 100644 --- a/spec/models/class_member_spec.rb +++ b/spec/models/class_member_spec.rb @@ -56,4 +56,27 @@ expect(duplicate).to be_invalid end end + + describe '.students' do + it 'returns User instances for the current scope' do + class_member = create(:class_member) + + student = ClassMember.all.students.first + expect(student.name).to eq('School Student') + end + + it 'ignores members where no profile account exists' do + class_member = create(:class_member, student_id: SecureRandom.uuid) + + student = ClassMember.all.students.first + expect(student).to be_nil + end + + it 'ignores members not included in the current scope' do + class_member = create(:class_member) + + student = ClassMember.none.students.first + expect(student).to be_nil + end + end end diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index 59da1deb7..d2f6758ce 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -75,22 +75,4 @@ expect(school_class.teacher).to be_nil end end - - describe '#students' do - it 'returns User instances for members of the class' do - member = build(:class_member, student_id: '22222222-2222-2222-2222-222222222222') - school_class = create(:school_class, members: [member]) - - student = school_class.students.first - expect(student.name).to eq('School Student') - end - - it 'ignores members where no profile account exists' do - member = build(:class_member, student_id: SecureRandom.uuid) - school_class = create(:school_class, members: [member]) - - student = school_class.students.first - expect(student).to be_nil - end - end end From 49cdfdf8f065338151f3a4d61e3834a96cde3ecc Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 17:19:45 +0000 Subject: [PATCH 055/124] Add a ClassMember.with_students method --- app/models/class_member.rb | 5 +++++ lib/user_info_api_client.rb | 2 +- spec/models/class_member_spec.rb | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/models/class_member.rb b/app/models/class_member.rb index e64a4114e..5bbf3b861 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -15,6 +15,11 @@ def self.students User.from_userinfo(ids: pluck(:student_id)) end + def self.with_students + users = students.map { |user| [user.id, user] }.to_h + all.map { |member| [member, users[member.student_id]] } + end + private def student_has_the_school_student_role_for_the_school diff --git a/lib/user_info_api_client.rb b/lib/user_info_api_client.rb index c067e7e3a..29e10bcb0 100644 --- a/lib/user_info_api_client.rb +++ b/lib/user_info_api_client.rb @@ -17,7 +17,7 @@ def fetch_by_email(user_email) end def fetch_by_ids(user_ids) - return if user_ids.blank? + return [] if user_ids.blank? return stubbed_by_ids(user_ids) if bypass_auth? response = conn.get do |r| diff --git a/spec/models/class_member_spec.rb b/spec/models/class_member_spec.rb index ae0b3740c..3721db13d 100644 --- a/spec/models/class_member_spec.rb +++ b/spec/models/class_member_spec.rb @@ -79,4 +79,29 @@ expect(student).to be_nil end end + + describe '.with_students' do + it 'returns an array of class members paired with their User instance' do + class_member = create(:class_member) + + pair = ClassMember.all.with_students.first + student = ClassMember.all.students.first + + expect(pair).to eq([class_member, student]) + end + + it 'returns nil values for members where no profile account exists' do + class_member = create(:class_member, student_id: SecureRandom.uuid) + + pair = ClassMember.all.with_students.first + expect(pair).to eq([class_member, nil]) + end + + it 'ignores members not included in the current scope' do + class_member = create(:class_member) + + pair = ClassMember.none.with_students.first + expect(pair).to eq(nil) + end + end end From 7616a9a4582fba5e2132b0b0131b378bd0b32432 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 17:29:36 +0000 Subject: [PATCH 056/124] Add a ClassMember#with_student method --- app/models/class_member.rb | 8 +++++-- spec/models/class_member_spec.rb | 40 +++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/app/models/class_member.rb b/app/models/class_member.rb index 5bbf3b861..de1efa074 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -16,16 +16,20 @@ def self.students end def self.with_students - users = students.map { |user| [user.id, user] }.to_h + users = students.index_by(&:id) all.map { |member| [member, users[member.student_id]] } end + def with_student + [self, User.from_userinfo(ids: student_id).first] + end + private def student_has_the_school_student_role_for_the_school return unless student_id_changed? && errors.blank? - user = User.from_userinfo(ids: student_id).first + _, user = with_student return unless user && !user.school_student?(organisation_id: school.id) msg = "'#{student_id}' does not have the 'school-student' role for organisation '#{school.id}'" diff --git a/spec/models/class_member_spec.rb b/spec/models/class_member_spec.rb index 3721db13d..eeb6cdb31 100644 --- a/spec/models/class_member_spec.rb +++ b/spec/models/class_member_spec.rb @@ -59,23 +59,23 @@ describe '.students' do it 'returns User instances for the current scope' do - class_member = create(:class_member) + create(:class_member) - student = ClassMember.all.students.first + student = described_class.all.students.first expect(student.name).to eq('School Student') end it 'ignores members where no profile account exists' do - class_member = create(:class_member, student_id: SecureRandom.uuid) + create(:class_member, student_id: SecureRandom.uuid) - student = ClassMember.all.students.first + student = described_class.all.students.first expect(student).to be_nil end it 'ignores members not included in the current scope' do - class_member = create(:class_member) + create(:class_member) - student = ClassMember.none.students.first + student = described_class.none.students.first expect(student).to be_nil end end @@ -84,8 +84,8 @@ it 'returns an array of class members paired with their User instance' do class_member = create(:class_member) - pair = ClassMember.all.with_students.first - student = ClassMember.all.students.first + pair = described_class.all.with_students.first + student = described_class.all.students.first expect(pair).to eq([class_member, student]) end @@ -93,15 +93,33 @@ it 'returns nil values for members where no profile account exists' do class_member = create(:class_member, student_id: SecureRandom.uuid) - pair = ClassMember.all.with_students.first + pair = described_class.all.with_students.first expect(pair).to eq([class_member, nil]) end it 'ignores members not included in the current scope' do + create(:class_member) + + pair = described_class.none.with_students.first + expect(pair).to be_nil + end + end + + describe '#with_student' do + it 'returns the class member paired with their User instance' do class_member = create(:class_member) - pair = ClassMember.none.with_students.first - expect(pair).to eq(nil) + pair = class_member.with_student + student = described_class.all.students.first + + expect(pair).to eq([class_member, student]) + end + + it 'returns a nil value if the member has no profile account' do + class_member = create(:class_member, student_id: SecureRandom.uuid) + + pair = class_member.with_student + expect(pair).to eq([class_member, nil]) end end end From 6ce49120ccdb1b400904c6258afe4d887a6a46ea Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 17:53:13 +0000 Subject: [PATCH 057/124] Add a ClassMembers#index controller action --- .../api/class_members_controller.rb | 7 +- .../api/school_classes_controller.rb | 2 +- app/models/ability.rb | 4 +- .../api/class_members/index.json.jbuilder | 21 +++++ .../api/class_members/show.json.jbuilder | 11 ++- config/routes.rb | 2 +- .../creating_a_class_member_spec.rb | 16 ++++ .../listing_class_members_spec.rb | 83 +++++++++++++++++++ .../listing_school_classes_spec.rb | 8 ++ 9 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 app/views/api/class_members/index.json.jbuilder create mode 100644 spec/features/class_member/listing_class_members_spec.rb diff --git a/app/controllers/api/class_members_controller.rb b/app/controllers/api/class_members_controller.rb index d023c3516..96530c3c6 100644 --- a/app/controllers/api/class_members_controller.rb +++ b/app/controllers/api/class_members_controller.rb @@ -7,11 +7,16 @@ class ClassMembersController < ApiController load_and_authorize_resource :school_class, through: :school, through_association: :classes, id_param: :class_id load_and_authorize_resource :class_member, through: :school_class, through_association: :members + def index + @class_members_with_students = @school_class.members.accessible_by(current_ability).with_students + render :index, formats: [:json], status: :ok + end + def create result = ClassMember::Create.call(school_class: @school_class, class_member_params:) if result.success? - @class_member = result[:class_member] + @class_member_with_student = result[:class_member].with_student render :show, formats: [:json], status: :created else render json: { error: result[:error] }, status: :unprocessable_entity diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 7b925c624..19add3c34 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -7,7 +7,7 @@ class SchoolClassesController < ApiController load_and_authorize_resource :school_class, through: :school, through_association: :classes def index - @school_classes = SchoolClass.accessible_by(current_ability) + @school_classes = @school.classes.accessible_by(current_ability) render :index, formats: [:json], status: :ok end diff --git a/app/models/ability.rb b/app/models/ability.rb index b22f7912e..2f4e78baf 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -21,13 +21,13 @@ def initialize(user) if user.school_owner?(organisation_id:) can(%i[update], School, id: organisation_id) can(%i[read create update], SchoolClass, school: { id: organisation_id }) - can(%i[create], ClassMember, school_class: { school: { id: organisation_id } }) + can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) end if user.school_teacher?(organisation_id:) can(%i[create], SchoolClass, school: { id: organisation_id }) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) - can(%i[create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) + can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) end if user.school_student?(organisation_id:) diff --git a/app/views/api/class_members/index.json.jbuilder b/app/views/api/class_members/index.json.jbuilder new file mode 100644 index 000000000..bef997a45 --- /dev/null +++ b/app/views/api/class_members/index.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +json.array!(@class_members_with_students) do |class_member, student| + json.call( + class_member, + :id, + :school_class_id, + :student_id, + :created_at, + :updated_at + ) + + if student + json.call( + student, + :name, + :nickname, + :picture + ) + end +end diff --git a/app/views/api/class_members/show.json.jbuilder b/app/views/api/class_members/show.json.jbuilder index 5134a0d58..0bb41b7be 100644 --- a/app/views/api/class_members/show.json.jbuilder +++ b/app/views/api/class_members/show.json.jbuilder @@ -1,10 +1,19 @@ # frozen_string_literal: true json.call( - @class_member, + @class_member_with_student[0], :id, :school_class_id, :student_id, :created_at, :updated_at ) + +if @class_member_with_student[1] + json.call( + @class_member_with_student[1], + :name, + :nickname, + :picture + ) +end diff --git a/config/routes.rb b/config/routes.rb index 59e2aceab..88a94ab1f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,7 +19,7 @@ resources :schools, only: %i[index show create update] do resources :classes, only: %i[index show create update], controller: 'school_classes' do - resources :members, only: %i[create], controller: 'class_members' + resources :members, only: %i[index create], controller: 'class_members' end end end diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb index 11b7b376e..011784121 100644 --- a/spec/features/class_member/creating_a_class_member_spec.rb +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -41,6 +41,22 @@ expect(data[:student_id]).to eq(student_id) end + it 'responds with the student JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('School Student') + end + + it "doesn't include student attributes in the JSON if the user profile doesn't exist" do + student_id = SecureRandom.uuid + + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: { class_member: { student_id: } }) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data).not_to have_key(:name) + end + it 'responds 400 Bad Request when params are missing' do post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) expect(response).to have_http_status(:bad_request) diff --git a/spec/features/class_member/listing_class_members_spec.rb b/spec/features/class_member/listing_class_members_spec.rb new file mode 100644 index 000000000..22fea1106 --- /dev/null +++ b/spec/features/class_member/listing_class_members_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing class members', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:class_member) { create(:class_member) } + let(:school_class) { class_member.school_class } + let(:school) { school_class.school } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the class members JSON' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:student_id]).to eq(student_id) + end + + it 'responds with the students JSON' do + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('School Student') + end + + it "does not include student attributes in the JSON if the user profile doesn't exist" do + class_member.update!(student_id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first).not_to have_key(:name) + end + + it 'does not include class members that belong to a different class' do + different_class = create(:school_class, school:) + create(:class_member, school_class: different_class, student_id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/classes/#{school_class.id}/members" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + school_class.update!(teacher_id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_class/listing_school_classes_spec.rb b/spec/features/school_class/listing_school_classes_spec.rb index e48576a32..733326422 100644 --- a/spec/features/school_class/listing_school_classes_spec.rb +++ b/spec/features/school_class/listing_school_classes_spec.rb @@ -50,4 +50,12 @@ get "/api/schools/#{school.id}/classes" expect(response).to have_http_status(:unauthorized) end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) + expect(response).to have_http_status(:forbidden) + end end From d2bc85a8697c598f0bd390660b380a751bd3538e Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 17:57:08 +0000 Subject: [PATCH 058/124] Replace SchoolClass#teacher with .teachers, .with_teachers and #with_teacher --- app/models/class_member.rb | 2 +- app/models/school_class.rb | 15 ++++++-- spec/models/school_class_spec.rb | 66 +++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/app/models/class_member.rb b/app/models/class_member.rb index de1efa074..4f7abbf0f 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -17,7 +17,7 @@ def self.students def self.with_students users = students.index_by(&:id) - all.map { |member| [member, users[member.student_id]] } + all.map { |instance| [instance, users[instance.student_id]] } end def with_student diff --git a/app/models/school_class.rb b/app/models/school_class.rb index 1ecd690a0..581dd60b2 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -8,8 +8,17 @@ class SchoolClass < ApplicationRecord validates :name, presence: true validate :teacher_has_the_school_teacher_role_for_the_school - def teacher - User.from_userinfo(ids: teacher_id).first + def self.teachers + User.from_userinfo(ids: pluck(:teacher_id)) + end + + def self.with_teachers + users = teachers.index_by(&:id) + all.map { |instance| [instance, users[instance.teacher_id]] } + end + + def with_teacher + [self, User.from_userinfo(ids: teacher_id).first] end private @@ -17,7 +26,7 @@ def teacher def teacher_has_the_school_teacher_role_for_the_school return unless teacher_id_changed? && errors.blank? - user = teacher + _, user = with_teacher return unless user && !user.school_teacher?(organisation_id: school.id) msg = "'#{teacher_id}' does not have the 'school-teacher' role for organisation '#{school.id}'" diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index d2f6758ce..35f6cc37d 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -64,15 +64,69 @@ end end - describe '#teacher' do - it 'returns a User instance for the teacher_id of the class' do - school_class = create(:school_class, teacher_id: '11111111-1111-1111-1111-111111111111') - expect(school_class.teacher.name).to eq('School Teacher') + describe '.teachers' do + it 'returns User instances for the current scope' do + create(:school_class) + + teacher = described_class.all.teachers.first + expect(teacher.name).to eq('School Teacher') + end + + it 'ignores members where no profile account exists' do + create(:school_class, teacher_id: SecureRandom.uuid) + + teacher = described_class.all.teachers.first + expect(teacher).to be_nil + end + + it 'ignores members not included in the current scope' do + create(:school_class) + + teacher = described_class.none.teachers.first + expect(teacher).to be_nil end + end + + describe '.with_teachers' do + it 'returns an array of class members paired with their User instance' do + school_class = create(:school_class) + + pair = described_class.all.with_teachers.first + teacher = described_class.all.teachers.first - it 'returns nil if no profile account exists' do + expect(pair).to eq([school_class, teacher]) + end + + it 'returns nil values for members where no profile account exists' do school_class = create(:school_class, teacher_id: SecureRandom.uuid) - expect(school_class.teacher).to be_nil + + pair = described_class.all.with_teachers.first + expect(pair).to eq([school_class, nil]) + end + + it 'ignores members not included in the current scope' do + create(:school_class) + + pair = described_class.none.with_teachers.first + expect(pair).to be_nil + end + end + + describe '#with_teacher' do + it 'returns the class member paired with their User instance' do + school_class = create(:school_class) + + pair = school_class.with_teacher + teacher = described_class.all.teachers.first + + expect(pair).to eq([school_class, teacher]) + end + + it 'returns a nil value if the member has no profile account' do + school_class = create(:school_class, teacher_id: SecureRandom.uuid) + + pair = school_class.with_teacher + expect(pair).to eq([school_class, nil]) end end end From 673332c39654cbd4dd0a440372b3fc5d8f134e08 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 18:10:15 +0000 Subject: [PATCH 059/124] =?UTF-8?q?Prefix=20student=20attributes=20with=20?= =?UTF-8?q?=E2=80=98student=5F=E2=80=99=20in=20the=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/api/class_members/index.json.jbuilder | 11 +++-------- app/views/api/class_members/show.json.jbuilder | 15 ++++++--------- .../class_member/creating_a_class_member_spec.rb | 6 +++--- .../class_member/listing_class_members_spec.rb | 6 +++--- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/app/views/api/class_members/index.json.jbuilder b/app/views/api/class_members/index.json.jbuilder index bef997a45..fdea7ee57 100644 --- a/app/views/api/class_members/index.json.jbuilder +++ b/app/views/api/class_members/index.json.jbuilder @@ -10,12 +10,7 @@ json.array!(@class_members_with_students) do |class_member, student| :updated_at ) - if student - json.call( - student, - :name, - :nickname, - :picture - ) - end + json.student_name(student&.name) + json.student_nickname(student&.nickname) + json.student_picture(student&.picture) end diff --git a/app/views/api/class_members/show.json.jbuilder b/app/views/api/class_members/show.json.jbuilder index 0bb41b7be..9ddaa128d 100644 --- a/app/views/api/class_members/show.json.jbuilder +++ b/app/views/api/class_members/show.json.jbuilder @@ -1,7 +1,9 @@ # frozen_string_literal: true +class_member, student = @class_member_with_student + json.call( - @class_member_with_student[0], + class_member, :id, :school_class_id, :student_id, @@ -9,11 +11,6 @@ json.call( :updated_at ) -if @class_member_with_student[1] - json.call( - @class_member_with_student[1], - :name, - :nickname, - :picture - ) -end +json.student_name(student&.name) +json.student_nickname(student&.nickname) +json.student_picture(student&.picture) diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb index 011784121..4e46340fe 100644 --- a/spec/features/class_member/creating_a_class_member_spec.rb +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -45,16 +45,16 @@ post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) data = JSON.parse(response.body, symbolize_names: true) - expect(data[:name]).to eq('School Student') + expect(data[:student_name]).to eq('School Student') end - it "doesn't include student attributes in the JSON if the user profile doesn't exist" do + it "responds with nil attributes for the student if their user profile doesn't exist" do student_id = SecureRandom.uuid post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: { class_member: { student_id: } }) data = JSON.parse(response.body, symbolize_names: true) - expect(data).not_to have_key(:name) + expect(data[:student_name]).to be(nil) end it 'responds 400 Bad Request when params are missing' do diff --git a/spec/features/class_member/listing_class_members_spec.rb b/spec/features/class_member/listing_class_members_spec.rb index 22fea1106..3b89eb3ac 100644 --- a/spec/features/class_member/listing_class_members_spec.rb +++ b/spec/features/class_member/listing_class_members_spec.rb @@ -31,16 +31,16 @@ get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) data = JSON.parse(response.body, symbolize_names: true) - expect(data.first[:name]).to eq('School Student') + expect(data.first[:student_name]).to eq('School Student') end - it "does not include student attributes in the JSON if the user profile doesn't exist" do + it "responds with nil attributes for students if the user profile doesn't exist" do class_member.update!(student_id: SecureRandom.uuid) get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) data = JSON.parse(response.body, symbolize_names: true) - expect(data.first).not_to have_key(:name) + expect(data.first[:student_name]).to be(nil) end it 'does not include class members that belong to a different class' do From 9e350de5e530f5a1edecf18f5272f787eeb26abd Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 12 Feb 2024 18:19:33 +0000 Subject: [PATCH 060/124] Include teacher_* in the JSON responses for SchoolClassesController --- app/controllers/api/school_classes_controller.rb | 7 ++++--- app/views/api/school_classes/index.json.jbuilder | 6 +++++- app/views/api/school_classes/show.json.jbuilder | 8 +++++++- .../class_member/creating_a_class_member_spec.rb | 2 +- .../class_member/listing_class_members_spec.rb | 2 +- .../school_class/creating_a_school_class_spec.rb | 16 ++++++++++++++++ .../school_class/listing_school_classes_spec.rb | 16 ++++++++++++++++ .../school_class/showing_a_school_class_spec.rb | 16 ++++++++++++++++ .../school_class/updating_a_school_class_spec.rb | 16 ++++++++++++++++ 9 files changed, 82 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 19add3c34..f2eb042e5 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -7,11 +7,12 @@ class SchoolClassesController < ApiController load_and_authorize_resource :school_class, through: :school, through_association: :classes def index - @school_classes = @school.classes.accessible_by(current_ability) + @school_classes_with_teachers = @school.classes.accessible_by(current_ability).with_teachers render :index, formats: [:json], status: :ok end def show + @school_class_with_teacher = @school_class.with_teacher render :show, formats: [:json], status: :ok end @@ -19,7 +20,7 @@ def create result = SchoolClass::Create.call(school: @school, school_class_params:) if result.success? - @school_class = result[:school_class] + @school_class_with_teacher = result[:school_class].with_teacher render :show, formats: [:json], status: :created else render json: { error: result[:error] }, status: :unprocessable_entity @@ -31,7 +32,7 @@ def update result = SchoolClass::Update.call(school_class:, school_class_params:) if result.success? - @school_class = result[:school_class] + @school_class_with_teacher = result[:school_class].with_teacher render :show, formats: [:json], status: :ok else render json: { error: result[:error] }, status: :unprocessable_entity diff --git a/app/views/api/school_classes/index.json.jbuilder b/app/views/api/school_classes/index.json.jbuilder index 2b69f3757..c351f08cf 100644 --- a/app/views/api/school_classes/index.json.jbuilder +++ b/app/views/api/school_classes/index.json.jbuilder @@ -1,6 +1,6 @@ # frozen_string_literal: true -json.array!(@school_classes) do |school_class| +json.array!(@school_classes_with_teachers) do |school_class, teacher| json.call( school_class, :id, @@ -10,4 +10,8 @@ json.array!(@school_classes) do |school_class| :created_at, :updated_at ) + + json.teacher_name(teacher&.name) + json.teacher_nickname(teacher&.nickname) + json.teacher_picture(teacher&.picture) end diff --git a/app/views/api/school_classes/show.json.jbuilder b/app/views/api/school_classes/show.json.jbuilder index 531d30201..688aead5c 100644 --- a/app/views/api/school_classes/show.json.jbuilder +++ b/app/views/api/school_classes/show.json.jbuilder @@ -1,7 +1,9 @@ # frozen_string_literal: true +school_class, teacher = @school_class_with_teacher + json.call( - @school_class, + school_class, :id, :school_id, :teacher_id, @@ -9,3 +11,7 @@ json.call( :created_at, :updated_at ) + +json.teacher_name(teacher&.name) +json.teacher_nickname(teacher&.nickname) +json.teacher_picture(teacher&.picture) diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb index 4e46340fe..45414b4d2 100644 --- a/spec/features/class_member/creating_a_class_member_spec.rb +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -54,7 +54,7 @@ post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: { class_member: { student_id: } }) data = JSON.parse(response.body, symbolize_names: true) - expect(data[:student_name]).to be(nil) + expect(data[:student_name]).to be_nil end it 'responds 400 Bad Request when params are missing' do diff --git a/spec/features/class_member/listing_class_members_spec.rb b/spec/features/class_member/listing_class_members_spec.rb index 3b89eb3ac..dfde5f748 100644 --- a/spec/features/class_member/listing_class_members_spec.rb +++ b/spec/features/class_member/listing_class_members_spec.rb @@ -40,7 +40,7 @@ get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) data = JSON.parse(response.body, symbolize_names: true) - expect(data.first[:student_name]).to be(nil) + expect(data.first[:student_name]).to be_nil end it 'does not include class members that belong to a different class' do diff --git a/spec/features/school_class/creating_a_school_class_spec.rb b/spec/features/school_class/creating_a_school_class_spec.rb index 194d6a1f6..3366e4ec2 100644 --- a/spec/features/school_class/creating_a_school_class_spec.rb +++ b/spec/features/school_class/creating_a_school_class_spec.rb @@ -41,6 +41,22 @@ expect(data[:name]).to eq('Test School Class') end + it 'responds with the teacher JSON' do + post("/api/schools/#{school.id}/classes", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to eq('School Teacher') + end + + it "responds with nil attributes for the teacher if their user profile doesn't exist" do + teacher_id = SecureRandom.uuid + + post("/api/schools/#{school.id}/classes", headers:, params: { school_class: { teacher_id: } }) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to be_nil + end + it 'sets the class teacher to the specified user for school-owner users' do post("/api/schools/#{school.id}/classes", headers:, params:) data = JSON.parse(response.body, symbolize_names: true) diff --git a/spec/features/school_class/listing_school_classes_spec.rb b/spec/features/school_class/listing_school_classes_spec.rb index 733326422..719381aa6 100644 --- a/spec/features/school_class/listing_school_classes_spec.rb +++ b/spec/features/school_class/listing_school_classes_spec.rb @@ -26,6 +26,22 @@ expect(data.first[:name]).to eq('Test School Class') end + it 'responds with the teachers JSON' do + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:teacher_name]).to eq('School Teacher') + end + + it "responds with nil attributes for teachers if the user profile doesn't exist" do + school_class.update!(teacher_id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/classes", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:teacher_name]).to be_nil + end + it "does not include school classes that the school-teacher doesn't teach" do stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) create(:school_class, school:, teacher_id: SecureRandom.uuid) diff --git a/spec/features/school_class/showing_a_school_class_spec.rb b/spec/features/school_class/showing_a_school_class_spec.rb index afb6c33cc..e25bb36f5 100644 --- a/spec/features/school_class/showing_a_school_class_spec.rb +++ b/spec/features/school_class/showing_a_school_class_spec.rb @@ -39,6 +39,22 @@ expect(data[:name]).to eq('Test School Class') end + it 'responds with the teacher JSON' do + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to eq('School Teacher') + end + + it "responds with nil attributes for the teacher if their user profile doesn't exist" do + school_class.update!(teacher_id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to be_nil + end + it 'responds 404 Not Found when no school exists' do get("/api/schools/not-a-real-id/classes/#{school_class.id}", headers:) expect(response).to have_http_status(:not_found) diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb index 0fc2a5ae2..7ce9cc1c6 100644 --- a/spec/features/school_class/updating_a_school_class_spec.rb +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -41,6 +41,22 @@ expect(data[:name]).to eq('New Name') end + it 'responds with the teacher JSON' do + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to eq('School Teacher') + end + + it "responds with nil attributes for the teacher if their user profile doesn't exist" do + teacher_id = SecureRandom.uuid + + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params: { school_class: { teacher_id: } }) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teacher_name]).to be_nil + end + it 'responds 400 Bad Request when params are missing' do put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) expect(response).to have_http_status(:bad_request) From e71c30bb95d4e78967208895b17add02b6fd62d7 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 13 Feb 2024 14:05:37 +0000 Subject: [PATCH 061/124] Remove the School#owner_id field Schools can have multiple owners so we will need to make API calls to the profile API to get a list of owners rather than storing one owner directly on the school. --- app/controllers/api/schools_controller.rb | 2 +- app/models/school.rb | 17 ---------- app/views/api/schools/index.json.jbuilder | 1 - app/views/api/schools/show.json.jbuilder | 1 - db/migrate/20240201160923_create_schools.rb | 1 - db/schema.rb | 1 - lib/concepts/school/operations/create.rb | 9 +++--- spec/concepts/school/create_spec.rb | 23 ++++++------- spec/factories/school.rb | 1 - .../creating_a_class_member_spec.rb | 2 +- .../listing_class_members_spec.rb | 2 +- spec/features/school/listing_schools_spec.rb | 2 +- .../listing_school_classes_spec.rb | 2 +- .../showing_a_school_class_spec.rb | 2 +- .../updating_a_school_class_spec.rb | 2 +- spec/models/school_spec.rb | 32 +------------------ 16 files changed, 21 insertions(+), 79 deletions(-) diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index f3d3589eb..4ce562c8e 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -15,7 +15,7 @@ def show end def create - result = School::Create.call(school_params:, current_user:) + result = School::Create.call(school_params:, token: current_user&.token) if result.success? @school = result[:school] diff --git a/app/models/school.rb b/app/models/school.rb index 84f2a3b6e..d976d3432 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -4,31 +4,14 @@ class School < ApplicationRecord has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy validates :id, presence: true, uniqueness: { case_sensitive: false } - validates :owner_id, presence: true validates :name, presence: true validates :reference, uniqueness: { case_sensitive: false, allow_nil: true } validates :address_line_1, presence: true # rubocop:disable Naming/VariableNumber validates :municipality, presence: true validates :country_code, presence: true, inclusion: { in: ISO3166::Country.codes } - validate :owner_has_the_school_owner_role_for_the_school - - def owner - User.from_userinfo(ids: owner_id).first - end def valid_except_for_id? validate errors.attribute_names.all? { |name| name == :id } end - - private - - def owner_has_the_school_owner_role_for_the_school - return unless owner_id_changed? && id && errors.blank? - - user = owner - return unless user && !user.school_owner?(organisation_id: id) - - errors.add(:user, "'#{owner_id}' does not have the 'school-owner' role for organisation '#{id}'") - end end diff --git a/app/views/api/schools/index.json.jbuilder b/app/views/api/schools/index.json.jbuilder index b6464e8a7..99c3582e8 100644 --- a/app/views/api/schools/index.json.jbuilder +++ b/app/views/api/schools/index.json.jbuilder @@ -4,7 +4,6 @@ json.array!(@schools) do |school| json.call( school, :id, - :owner_id, :name, :reference, :address_line_1, # rubocop:disable Naming/VariableNumber diff --git a/app/views/api/schools/show.json.jbuilder b/app/views/api/schools/show.json.jbuilder index 6c4a54338..a1437d4e8 100644 --- a/app/views/api/schools/show.json.jbuilder +++ b/app/views/api/schools/show.json.jbuilder @@ -3,7 +3,6 @@ json.call( @school, :id, - :owner_id, :name, :reference, :address_line_1, # rubocop:disable Naming/VariableNumber diff --git a/db/migrate/20240201160923_create_schools.rb b/db/migrate/20240201160923_create_schools.rb index 93debac62..0a6476122 100644 --- a/db/migrate/20240201160923_create_schools.rb +++ b/db/migrate/20240201160923_create_schools.rb @@ -1,7 +1,6 @@ class CreateSchools < ActiveRecord::Migration[7.0] def change create_table :schools, id: :uuid do |t| - t.uuid :owner_id, null: false t.string :name, null: false t.string :reference diff --git a/db/schema.rb b/db/schema.rb index c939f9e2b..739dce6d3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -160,7 +160,6 @@ end create_table "schools", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "owner_id", null: false t.string "name", null: false t.string "reference" t.string "address_line_1", null: false diff --git a/lib/concepts/school/operations/create.rb b/lib/concepts/school/operations/create.rb index efd11ceff..95f43a3ad 100644 --- a/lib/concepts/school/operations/create.rb +++ b/lib/concepts/school/operations/create.rb @@ -3,9 +3,9 @@ class School class Create class << self - def call(school_params:, current_user:) + def call(school_params:, token:) response = OperationResponse.new - response[:school] = build_school(school_params, current_user) + response[:school] = build_school(school_params, token) response[:school].save! response rescue StandardError => e @@ -17,12 +17,11 @@ def call(school_params:, current_user:) private - def build_school(school_params, current_user) + def build_school(school_params, token) school = School.new(school_params) - school.owner_id = current_user&.id if school.valid_except_for_id? - response = ProfileApiClient.create_organisation(token: current_user&.token) + response = ProfileApiClient.create_organisation(token:) school.id = response&.fetch(:id) end diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb index acb4821cc..f960f862e 100644 --- a/spec/concepts/school/create_spec.rb +++ b/spec/concepts/school/create_spec.rb @@ -12,7 +12,7 @@ } end - let(:current_user) { build(:user) } + let(:token) { UserProfileMock::TOKEN } before do stub_user_info_api @@ -20,26 +20,21 @@ end it 'creates a school' do - expect { described_class.call(school_params:, current_user:) }.to change(School, :count).by(1) + expect { described_class.call(school_params:, token:) }.to change(School, :count).by(1) end it 'returns the school in the operation response' do - response = described_class.call(school_params:, current_user:) + response = described_class.call(school_params:, token:) expect(response[:school]).to be_a(School) end it 'assigns the name' do - response = described_class.call(school_params:, current_user:) + response = described_class.call(school_params:, token:) expect(response[:school].name).to eq('Test School') end - it 'assigns the owner_id' do - response = described_class.call(school_params:, current_user:) - expect(response[:school].owner_id).to eq(current_user.id) - end - it 'assigns the organisation_id' do - response = described_class.call(school_params:, current_user:) + response = described_class.call(school_params:, token:) expect(response[:school].id).to eq(ProfileApiMock::ORGANISATION_ID) end @@ -51,21 +46,21 @@ end it 'does not create a school' do - expect { described_class.call(school_params:, current_user:) }.not_to change(School, :count) + expect { described_class.call(school_params:, token:) }.not_to change(School, :count) end it 'returns a failed operation response' do - response = described_class.call(school_params:, current_user:) + response = described_class.call(school_params:, token:) expect(response.failure?).to be(true) end it 'returns the error message in the operation response' do - response = described_class.call(school_params:, current_user:) + response = described_class.call(school_params:, token:) expect(response[:error]).to match(/Error creating school/) end it 'sent the exception to Sentry' do - described_class.call(school_params:, current_user:) + described_class.call(school_params:, token:) expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) end end diff --git a/spec/factories/school.rb b/spec/factories/school.rb index f5de1fa6c..c3c8bc6c0 100644 --- a/spec/factories/school.rb +++ b/spec/factories/school.rb @@ -3,7 +3,6 @@ FactoryBot.define do factory :school do id { '12345678-1234-1234-1234-123456789abc' } - owner_id { '00000000-0000-0000-0000-000000000000' } # Matches users.json. sequence(:name) { |n| "School #{n}" } address_line_1 { 'Address Line 1' } municipality { 'Greater London' } diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb index 45414b4d2..4f9a71ec5 100644 --- a/spec/features/class_member/creating_a_class_member_spec.rb +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -73,7 +73,7 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school = create(:school, id: SecureRandom.uuid) school_class.update!(school_id: school.id) post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params:) diff --git a/spec/features/class_member/listing_class_members_spec.rb b/spec/features/class_member/listing_class_members_spec.rb index dfde5f748..1866e5d1a 100644 --- a/spec/features/class_member/listing_class_members_spec.rb +++ b/spec/features/class_member/listing_class_members_spec.rb @@ -59,7 +59,7 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school = create(:school, id: SecureRandom.uuid) school_class.update!(school_id: school.id) get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb index 03ebf5bea..652acdc28 100644 --- a/spec/features/school/listing_schools_spec.rb +++ b/spec/features/school/listing_schools_spec.rb @@ -25,7 +25,7 @@ end it 'only includes schools the user belongs to' do - create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + create(:school, id: SecureRandom.uuid) get('/api/schools', headers:) data = JSON.parse(response.body, symbolize_names: true) diff --git a/spec/features/school_class/listing_school_classes_spec.rb b/spec/features/school_class/listing_school_classes_spec.rb index 719381aa6..2b490679d 100644 --- a/spec/features/school_class/listing_school_classes_spec.rb +++ b/spec/features/school_class/listing_school_classes_spec.rb @@ -68,7 +68,7 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school = create(:school, id: SecureRandom.uuid) school_class.update!(school_id: school.id) get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:) diff --git a/spec/features/school_class/showing_a_school_class_spec.rb b/spec/features/school_class/showing_a_school_class_spec.rb index e25bb36f5..c6e950250 100644 --- a/spec/features/school_class/showing_a_school_class_spec.rb +++ b/spec/features/school_class/showing_a_school_class_spec.rb @@ -71,7 +71,7 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school = create(:school, id: SecureRandom.uuid) school_class.update!(school_id: school.id) get("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb index 7ce9cc1c6..c3522b2dc 100644 --- a/spec/features/school_class/updating_a_school_class_spec.rb +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -73,7 +73,7 @@ end it 'responds 403 Forbidden when the user is a school-owner for a different school' do - school = create(:school, id: SecureRandom.uuid, owner_id: SecureRandom.uuid) + school = create(:school, id: SecureRandom.uuid) school_class.update!(school_id: school.id) put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params:) diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index cfe14ccab..984a01064 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -55,31 +55,13 @@ expect(school).to be_invalid end - it 'requires an owner_id' do - school.owner_id = ' ' - expect(school).to be_invalid - end - - it 'requires a UUID owner_id' do - school.owner_id = 'invalid' - expect(school).to be_invalid - end - - it 'requires an owner that has the school-owner role for the school' do - school.owner_id = '11111111-1111-1111-1111-111111111111' # school-teacher - expect(school).to be_invalid - end - it 'requires a name' do school.name = ' ' expect(school).to be_invalid end it 'does not require a reference' do - create(:school, reference: nil) - - school.id = SecureRandom.uuid # Satisfy the uniqueness validation. - school.owner_id = SecureRandom.uuid # Satisfy the school-owner validation. + create(:school, id: SecureRandom.uuid, reference: nil) school.reference = nil expect(school).to be_valid @@ -113,16 +95,4 @@ expect(school).to be_invalid end end - - describe '#owner' do - it 'returns a User instance for the owner_id of the school' do - school = create(:school) - expect(school.owner.name).to eq('School Owner') - end - - it 'returns nil if no profile account exists' do - school = create(:school, owner_id: SecureRandom.uuid) - expect(school.owner).to be_nil - end - end end From 96fc88952c530e6a5854667cbbbfc7a1d01c97bd Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 13 Feb 2024 17:03:08 +0000 Subject: [PATCH 062/124] Add a SchoolOwners#create controller action --- .../api/school_owners_controller.rb | 26 +++++++ app/models/ability.rb | 1 + .../api/school_owners/show.json.jbuilder | 10 +++ config/routes.rb | 2 + lib/concepts/school_owner/invite.rb | 29 +++++++ lib/profile_api_client.rb | 24 ++++++ spec/concepts/school_owner/invite_spec.rb | 60 +++++++++++++++ .../inviting_a_school_owner_spec.rb | 75 +++++++++++++++++++ spec/support/profile_api_mock.rb | 7 +- 9 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/school_owners_controller.rb create mode 100644 app/views/api/school_owners/show.json.jbuilder create mode 100644 lib/concepts/school_owner/invite.rb create mode 100644 spec/concepts/school_owner/invite_spec.rb create mode 100644 spec/features/school_owner/inviting_a_school_owner_spec.rb diff --git a/app/controllers/api/school_owners_controller.rb b/app/controllers/api/school_owners_controller.rb new file mode 100644 index 000000000..3c38b60f8 --- /dev/null +++ b/app/controllers/api/school_owners_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + class SchoolOwnersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :school_owner, class: false + + def create + result = SchoolOwner::Invite.call(school: @school, school_owner_params:, token: current_user.token) + + if result.success? + @school_owner = result[:school_owner] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_owner_params + params.require(:school_owner).permit(:email_address) + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 2f4e78baf..e2fdc9a5b 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -22,6 +22,7 @@ def initialize(user) can(%i[update], School, id: organisation_id) can(%i[read create update], SchoolClass, school: { id: organisation_id }) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) + can(%i[create], :school_owner) end if user.school_teacher?(organisation_id:) diff --git a/app/views/api/school_owners/show.json.jbuilder b/app/views/api/school_owners/show.json.jbuilder new file mode 100644 index 000000000..7cce72897 --- /dev/null +++ b/app/views/api/school_owners/show.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.call( + @school_owner, + :id, + :email, + :name, + :nickname, + :picture +) diff --git a/config/routes.rb b/config/routes.rb index 88a94ab1f..30975c8c2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,8 @@ resources :classes, only: %i[index show create update], controller: 'school_classes' do resources :members, only: %i[index create], controller: 'class_members' end + + resources :owners, only: %i[create], controller: 'school_owners' end end diff --git a/lib/concepts/school_owner/invite.rb b/lib/concepts/school_owner/invite.rb new file mode 100644 index 000000000..fe9f511f4 --- /dev/null +++ b/lib/concepts/school_owner/invite.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module SchoolOwner + class Invite + class << self + def call(school:, school_owner_params:, token:) + response = OperationResponse.new + response[:school_owner] = invite_owner(school, school_owner_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error inviting school owner: #{e}" + response + end + + private + + def invite_owner(school, school_owner_params, token) + email_address = school_owner_params.fetch(:email_address) + organisation_id = school.id + + response = ProfileApiClient.invite_school_owner(token:, email_address:, organisation_id:) + user_id = response.fetch(:id) + + User.from_userinfo(ids: user_id).first + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index e0194d3d0..5af0fea42 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -7,11 +7,35 @@ class << self # The API should enforce these constraints: # - The user should have an email address # - The user should not be under 13 + # - The user must have a verified email + # + # The API should respond: + # - 422 Unprocessable if the constraints are not met def create_organisation(token:) return nil if token.blank? response = { 'id' => '12345678-1234-1234-1234-123456789abc' } response.deep_symbolize_keys end + + # The API should enforce these constraints: + # - The token has the school-owner role for the given organisation ID + # - The user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 404 Not Found if the user doesn't exist + # - 422 Unprocessable if the constraints are not met + # + # rubocop:disable Lint/UnusedMethodArgument + def invite_school_owner(token:, email_address:, organisation_id:) + return nil if token.blank? + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Invite propagates the error in the response. + response = { 'id' => '99999999-9999-9999-9999-999999999999' } + response.deep_symbolize_keys + end + # rubocop:enable Lint/UnusedMethodArgument end end diff --git a/spec/concepts/school_owner/invite_spec.rb b/spec/concepts/school_owner/invite_spec.rb new file mode 100644 index 000000000..6fc2f2f8e --- /dev/null +++ b/spec/concepts/school_owner/invite_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolOwner::Invite, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let(:school_owner_params) do + { email_address: 'school-teacher@example.com' } + end + + before do + stub_profile_api_invite_school_owner(user_id: teacher_id) + stub_user_info_api + end + + it 'makes a profile API call' do + described_class.call(school:, school_owner_params:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:invite_school_owner) + .with(token:, email_address: 'school-teacher@example.com', organisation_id: school.id) + end + + it 'returns the school owner in the operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response[:school_owner]).to be_a(User) + end + + context 'when creation fails' do + let(:school_owner_params) { {} } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, school_owner_params:, token:) + expect(ProfileApiClient).not_to have_received(:invite_school_owner) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response[:error]).to match(/key not found: :email_address/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_owner_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb new file mode 100644 index 000000000..47ece56f8 --- /dev/null +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Inviting an owner', type: :request do + before do + stub_hydra_public_api + stub_profile_api_invite_school_owner(user_id: teacher_id) + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let(:params) do + { + school_owner: { + # In this case, add the school-owner role to a school-teacher. + email_address: 'school-teacher@example.com' + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the invited owner JSON' do + post("/api/schools/#{school.id}/owners", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('School Teacher') + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + pending 'TODO: validate email address' + + post("/api/schools/#{school.id}/owners", headers:, params: { school_owner: { email_address: 'invalid' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/owners", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post("/api/schools/#{school.id}/owners", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 1ddad85f4..df4ba4a6b 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -3,8 +3,13 @@ module ProfileApiMock ORGANISATION_ID = '12345678-1234-1234-1234-123456789abc' - # TODO: Replace with a WebMock HTTP stub once the profile API has been built. + # TODO: Replace with WebMock HTTP stubs once the profile API has been built. + def stub_profile_api_create_organisation(organisation_id: ORGANISATION_ID) allow(ProfileApiClient).to receive(:create_organisation).and_return(id: organisation_id) end + + def stub_profile_api_invite_school_owner(user_id:) + allow(ProfileApiClient).to receive(:invite_school_owner).and_return(id: user_id) + end end From b421a7b7499fc790448e9fcbc45436a8dc289479 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 12:43:13 +0000 Subject: [PATCH 063/124] Validate email address when inviting school owners --- Gemfile | 1 + Gemfile.lock | 3 +++ lib/concepts/school_owner/invite.rb | 4 +++- spec/concepts/school_owner/invite_spec.rb | 6 ++++-- spec/features/school_owner/inviting_a_school_owner_spec.rb | 2 -- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index b99a1b5b2..1afcb5b25 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem 'aws-sdk-s3', require: false gem 'bootsnap', require: false gem 'cancancan', '~> 3.3' gem 'countries' +gem 'email_validator' gem 'faraday' gem 'github_webhook', '~> 1.4' gem 'globalid' diff --git a/Gemfile.lock b/Gemfile.lock index ab1edd839..435c5aaf0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,8 @@ GEM dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) + email_validator (2.2.4) + activemodel erubi (1.12.0) et-orbi (1.2.7) tzinfo @@ -356,6 +358,7 @@ DEPENDENCIES climate_control countries dotenv-rails + email_validator factory_bot_rails faker faraday diff --git a/lib/concepts/school_owner/invite.rb b/lib/concepts/school_owner/invite.rb index fe9f511f4..0e0df5a63 100644 --- a/lib/concepts/school_owner/invite.rb +++ b/lib/concepts/school_owner/invite.rb @@ -16,8 +16,10 @@ def call(school:, school_owner_params:, token:) private def invite_owner(school, school_owner_params, token) - email_address = school_owner_params.fetch(:email_address) organisation_id = school.id + email_address = school_owner_params.fetch(:email_address) + + raise ArgumentError, "email address '#{email_address}' is invalid" unless EmailValidator.valid?(email_address) response = ProfileApiClient.invite_school_owner(token:, email_address:, organisation_id:) user_id = response.fetch(:id) diff --git a/spec/concepts/school_owner/invite_spec.rb b/spec/concepts/school_owner/invite_spec.rb index 6fc2f2f8e..b2759c87e 100644 --- a/spec/concepts/school_owner/invite_spec.rb +++ b/spec/concepts/school_owner/invite_spec.rb @@ -31,7 +31,9 @@ end context 'when creation fails' do - let(:school_owner_params) { {} } + let(:school_owner_params) do + { email_address: 'invalid' } + end before do allow(Sentry).to receive(:capture_exception) @@ -49,7 +51,7 @@ it 'returns the error message in the operation response' do response = described_class.call(school:, school_owner_params:, token:) - expect(response[:error]).to match(/key not found: :email_address/) + expect(response[:error]).to match(/email address 'invalid' is invalid/) end it 'sent the exception to Sentry' do diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb index 47ece56f8..130e362c8 100644 --- a/spec/features/school_owner/inviting_a_school_owner_spec.rb +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -41,8 +41,6 @@ end it 'responds 422 Unprocessable Entity when params are invalid' do - pending 'TODO: validate email address' - post("/api/schools/#{school.id}/owners", headers:, params: { school_owner: { email_address: 'invalid' } }) expect(response).to have_http_status(:unprocessable_entity) end From b1f2d8f51106781c1ddcd1a352d2c930c32d21fe Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 13:02:58 +0000 Subject: [PATCH 064/124] Add a SchoolTeachers#create controller action --- .../api/school_teachers_controller.rb | 26 +++++++ app/models/ability.rb | 1 + .../api/school_teachers/show.json.jbuilder | 10 +++ config/routes.rb | 1 + lib/concepts/school_teacher/invite.rb | 31 ++++++++ lib/profile_api_client.rb | 26 ++++++- spec/concepts/school_owner/invite_spec.rb | 10 +-- spec/concepts/school_teacher/invite_spec.rb | 62 ++++++++++++++++ .../inviting_a_school_owner_spec.rb | 11 ++- .../inviting_a_school_teacher_spec.rb | 72 +++++++++++++++++++ spec/support/profile_api_mock.rb | 4 ++ 11 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 app/controllers/api/school_teachers_controller.rb create mode 100644 app/views/api/school_teachers/show.json.jbuilder create mode 100644 lib/concepts/school_teacher/invite.rb create mode 100644 spec/concepts/school_teacher/invite_spec.rb create mode 100644 spec/features/school_teacher/inviting_a_school_teacher_spec.rb diff --git a/app/controllers/api/school_teachers_controller.rb b/app/controllers/api/school_teachers_controller.rb new file mode 100644 index 000000000..1c8211024 --- /dev/null +++ b/app/controllers/api/school_teachers_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + class SchoolTeachersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :school_teacher, class: false + + def create + result = SchoolTeacher::Invite.call(school: @school, school_teacher_params:, token: current_user.token) + + if result.success? + @school_teacher = result[:school_teacher] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_teacher_params + params.require(:school_teacher).permit(:email_address) + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index e2fdc9a5b..2746d89a8 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -23,6 +23,7 @@ def initialize(user) can(%i[read create update], SchoolClass, school: { id: organisation_id }) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[create], :school_owner) + can(%i[create], :school_teacher) end if user.school_teacher?(organisation_id:) diff --git a/app/views/api/school_teachers/show.json.jbuilder b/app/views/api/school_teachers/show.json.jbuilder new file mode 100644 index 000000000..9713ee25b --- /dev/null +++ b/app/views/api/school_teachers/show.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.call( + @school_teacher, + :id, + :email, + :name, + :nickname, + :picture +) diff --git a/config/routes.rb b/config/routes.rb index 30975c8c2..6e3696a24 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,7 @@ end resources :owners, only: %i[create], controller: 'school_owners' + resources :teachers, only: %i[create], controller: 'school_teachers' end end diff --git a/lib/concepts/school_teacher/invite.rb b/lib/concepts/school_teacher/invite.rb new file mode 100644 index 000000000..d23a32c68 --- /dev/null +++ b/lib/concepts/school_teacher/invite.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module SchoolTeacher + class Invite + class << self + def call(school:, school_teacher_params:, token:) + response = OperationResponse.new + response[:school_teacher] = invite_teacher(school, school_teacher_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error inviting school teacher: #{e}" + response + end + + private + + def invite_teacher(school, school_teacher_params, token) + organisation_id = school.id + email_address = school_teacher_params.fetch(:email_address) + + raise ArgumentError, "email address '#{email_address}' is invalid" unless EmailValidator.valid?(email_address) + + response = ProfileApiClient.invite_school_teacher(token:, email_address:, organisation_id:) + user_id = response.fetch(:id) + + User.from_userinfo(ids: user_id).first + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 5af0fea42..9c4cfbc58 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -26,16 +26,36 @@ def create_organisation(token:) # The API should respond: # - 404 Not Found if the user doesn't exist # - 422 Unprocessable if the constraints are not met - # - # rubocop:disable Lint/UnusedMethodArgument def invite_school_owner(token:, email_address:, organisation_id:) return nil if token.blank? + _ = email_address + _ = organisation_id + # TODO: We should make Faraday raise a Ruby error for a non-2xx status # code so that SchoolOwner::Invite propagates the error in the response. response = { 'id' => '99999999-9999-9999-9999-999999999999' } response.deep_symbolize_keys end - # rubocop:enable Lint/UnusedMethodArgument + + # The API should enforce these constraints: + # - The token has the school-owner role for the given organisation ID + # - The user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 404 Not Found if the user doesn't exist + # - 422 Unprocessable if the constraints are not met + def invite_school_teacher(token:, email_address:, organisation_id:) + return nil if token.blank? + + _ = email_address + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolTeacher::Invite propagates the error in the response. + response = { 'id' => '99999999-9999-9999-9999-999999999999' } + response.deep_symbolize_keys + end end end diff --git a/spec/concepts/school_owner/invite_spec.rb b/spec/concepts/school_owner/invite_spec.rb index b2759c87e..0c4f7fda0 100644 --- a/spec/concepts/school_owner/invite_spec.rb +++ b/spec/concepts/school_owner/invite_spec.rb @@ -5,15 +5,15 @@ RSpec.describe SchoolOwner::Invite, type: :unit do let(:token) { UserProfileMock::TOKEN } let(:school) { create(:school) } - let(:teacher_index) { user_index_by_role('school-teacher') } - let(:teacher_id) { user_id_by_index(teacher_index) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } let(:school_owner_params) do - { email_address: 'school-teacher@example.com' } + { email_address: 'owner-to-invite@example.com' } end before do - stub_profile_api_invite_school_owner(user_id: teacher_id) + stub_profile_api_invite_school_owner(user_id: owner_id) stub_user_info_api end @@ -22,7 +22,7 @@ # TODO: Replace with WebMock assertion once the profile API has been built. expect(ProfileApiClient).to have_received(:invite_school_owner) - .with(token:, email_address: 'school-teacher@example.com', organisation_id: school.id) + .with(token:, email_address: 'owner-to-invite@example.com', organisation_id: school.id) end it 'returns the school owner in the operation response' do diff --git a/spec/concepts/school_teacher/invite_spec.rb b/spec/concepts/school_teacher/invite_spec.rb new file mode 100644 index 000000000..2d36c3b76 --- /dev/null +++ b/spec/concepts/school_teacher/invite_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolTeacher::Invite, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let(:school_teacher_params) do + { email_address: 'teacher-to-invite@example.com' } + end + + before do + stub_profile_api_invite_school_teacher(user_id: teacher_id) + stub_user_info_api + end + + it 'makes a profile API call' do + described_class.call(school:, school_teacher_params:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:invite_school_teacher) + .with(token:, email_address: 'teacher-to-invite@example.com', organisation_id: school.id) + end + + it 'returns the school teacher in the operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response[:school_teacher]).to be_a(User) + end + + context 'when creation fails' do + let(:school_teacher_params) do + { email_address: 'invalid' } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, school_teacher_params:, token:) + expect(ProfileApiClient).not_to have_received(:invite_school_teacher) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response[:error]).to match(/email address 'invalid' is invalid/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_teacher_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb index 130e362c8..4efec96ed 100644 --- a/spec/features/school_owner/inviting_a_school_owner_spec.rb +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -5,20 +5,19 @@ RSpec.describe 'Inviting an owner', type: :request do before do stub_hydra_public_api - stub_profile_api_invite_school_owner(user_id: teacher_id) + stub_profile_api_invite_school_owner(user_id: owner_id) stub_user_info_api end let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:school) { create(:school) } - let(:teacher_index) { user_index_by_role('school-teacher') } - let(:teacher_id) { user_id_by_index(teacher_index) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } let(:params) do { school_owner: { - # In this case, add the school-owner role to a school-teacher. - email_address: 'school-teacher@example.com' + email_address: 'owner-to-invite@example.com' } } end @@ -32,7 +31,7 @@ post("/api/schools/#{school.id}/owners", headers:, params:) data = JSON.parse(response.body, symbolize_names: true) - expect(data[:name]).to eq('School Teacher') + expect(data[:name]).to eq('School Owner') end it 'responds 400 Bad Request when params are missing' do diff --git a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb new file mode 100644 index 000000000..b29008ffd --- /dev/null +++ b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Inviting a teacher', type: :request do + before do + stub_hydra_public_api + stub_profile_api_invite_school_teacher(user_id: teacher_id) + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let(:params) do + { + school_teacher: { + email_address: 'teacher-to-invite@example.com' + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the invited teacher JSON' do + post("/api/schools/#{school.id}/teachers", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('School Teacher') + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/teachers", headers:, params: { school_teacher: { email_address: 'invalid' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/teachers", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post("/api/schools/#{school.id}/teachers", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index df4ba4a6b..984f0bc7d 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -12,4 +12,8 @@ def stub_profile_api_create_organisation(organisation_id: ORGANISATION_ID) def stub_profile_api_invite_school_owner(user_id:) allow(ProfileApiClient).to receive(:invite_school_owner).and_return(id: user_id) end + + def stub_profile_api_invite_school_teacher(user_id:) + allow(ProfileApiClient).to receive(:invite_school_teacher).and_return(id: user_id) + end end From 1ee60f7e43ef01f6f73f0c41bd2986f00cce8b25 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 13:44:12 +0000 Subject: [PATCH 065/124] Fix organisations not being merged correctly into User attributes --- app/models/user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index 33a4803f1..0c55874d3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,6 +89,6 @@ def self.temporarily_add_organisations_until_the_profile_app_is_updated(hash) return hash if hash.key?('organisations') # Use the same organisation ID as the one from users.json for now. - hash.merge('organisations', { '12345678-1234-1234-1234-123456789abc' => hash['roles'] }) + hash.merge('organisations' => { '12345678-1234-1234-1234-123456789abc' => hash['roles'] }) end end From 06022fbcab8a01b27ec5627d6e54c645dbcc710d Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 13:51:20 +0000 Subject: [PATCH 066/124] Add a SchoolStudents#create controller action --- .../api/school_students_controller.rb | 26 +++++++ app/models/ability.rb | 2 + app/models/user.rb | 1 + .../api/school_students/show.json.jbuilder | 10 +++ config/routes.rb | 1 + lib/concepts/school_student/create.rb | 35 +++++++++ lib/profile_api_client.rb | 26 ++++++- spec/concepts/school_student/create_spec.rb | 70 ++++++++++++++++++ .../inviting_a_school_owner_spec.rb | 2 +- .../creating_a_school_student_spec.rb | 74 +++++++++++++++++++ .../inviting_a_school_teacher_spec.rb | 2 +- spec/support/profile_api_mock.rb | 4 + 12 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/school_students_controller.rb create mode 100644 app/views/api/school_students/show.json.jbuilder create mode 100644 lib/concepts/school_student/create.rb create mode 100644 spec/concepts/school_student/create_spec.rb create mode 100644 spec/features/school_student/creating_a_school_student_spec.rb diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb new file mode 100644 index 000000000..7aa65027c --- /dev/null +++ b/app/controllers/api/school_students_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + class SchoolStudentsController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :school_student, class: false + + def create + result = SchoolStudent::Create.call(school: @school, school_student_params:, token: current_user.token) + + if result.success? + @school_student = result[:school_student] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def school_student_params + params.require(:school_student).permit(:username, :password, :name) + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 2746d89a8..68cef43a4 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -24,12 +24,14 @@ def initialize(user) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[create], :school_owner) can(%i[create], :school_teacher) + can(%i[create], :school_student) end if user.school_teacher?(organisation_id:) can(%i[create], SchoolClass, school: { id: organisation_id }) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) + can(%i[create], :school_student) end if user.school_student?(organisation_id:) diff --git a/app/models/user.rb b/app/models/user.rb index 0c55874d3..e34b94839 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,7 @@ class User postcode profile token + username ].freeze attr_accessor(*ATTRIBUTES) diff --git a/app/views/api/school_students/show.json.jbuilder b/app/views/api/school_students/show.json.jbuilder new file mode 100644 index 000000000..02a6cc93c --- /dev/null +++ b/app/views/api/school_students/show.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.call( + @school_student, + :id, + :username, + :name, + :nickname, + :picture +) diff --git a/config/routes.rb b/config/routes.rb index 6e3696a24..d3a66f795 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,7 @@ resources :owners, only: %i[create], controller: 'school_owners' resources :teachers, only: %i[create], controller: 'school_teachers' + resources :students, only: %i[create], controller: 'school_students' end end diff --git a/lib/concepts/school_student/create.rb b/lib/concepts/school_student/create.rb new file mode 100644 index 000000000..50af120d9 --- /dev/null +++ b/lib/concepts/school_student/create.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module SchoolStudent + class Create + class << self + def call(school:, school_student_params:, token:) + response = OperationResponse.new + response[:school_student] = create_student(school, school_student_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error creating school student: #{e}" + response + end + + private + + def create_student(school, school_student_params, token) + organisation_id = school.id + username = school_student_params.fetch(:username) + password = school_student_params.fetch(:password) + name = school_student_params.fetch(:name) + + raise ArgumentError, "username '#{username}' is invalid" if username.blank? + raise ArgumentError, "password '#{password}' is invalid" if password.size < 8 + raise ArgumentError, "name '#{name}' is invalid" if name.blank? + + response = ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id:) + user_id = response.fetch(:id) + + User.from_userinfo(ids: user_id).first + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 9c4cfbc58..4708e39ae 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -20,7 +20,7 @@ def create_organisation(token:) # The API should enforce these constraints: # - The token has the school-owner role for the given organisation ID - # - The user should not be under 13 + # - The token user or given user should not be under 13 # - The email must be verified # # The API should respond: @@ -40,7 +40,7 @@ def invite_school_owner(token:, email_address:, organisation_id:) # The API should enforce these constraints: # - The token has the school-owner role for the given organisation ID - # - The user should not be under 13 + # - The token user or given user should not be under 13 # - The email must be verified # # The API should respond: @@ -57,5 +57,27 @@ def invite_school_teacher(token:, email_address:, organisation_id:) response = { 'id' => '99999999-9999-9999-9999-999999999999' } response.deep_symbolize_keys end + + # The API should enforce these constraints: + # - The token has the school-owner or school-teacher role for the given organisation ID + # - The token user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 404 Not Found if the user doesn't exist + # - 422 Unprocessable if the constraints are not met + def create_school_student(token:, username:, password:, name:, organisation_id:) + return nil if token.blank? + + _ = username + _ = password + _ = name + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolStudent::Create propagates the error in the response. + response = { 'id' => '99999999-9999-9999-9999-999999999999' } + response.deep_symbolize_keys + end end end diff --git a/spec/concepts/school_student/create_spec.rb b/spec/concepts/school_student/create_spec.rb new file mode 100644 index 000000000..e20dd9cd9 --- /dev/null +++ b/spec/concepts/school_student/create_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::Create, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + let(:school_student_params) do + { + username: 'student-to-create', + password: 'at-least-8-characters', + name: 'School Student' + } + end + + before do + stub_profile_api_create_school_student(user_id: student_id) + stub_user_info_api + end + + it 'makes a profile API call' do + described_class.call(school:, school_student_params:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:create_school_student) + .with(token:, username: 'student-to-create', password: 'at-least-8-characters', name: 'School Student', organisation_id: school.id) + end + + it 'returns the school student in the operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:school_student]).to be_a(User) + end + + context 'when creation fails' do + let(:school_student_params) do + { + username: ' ', + password: 'at-least-8-characters', + name: 'School Student' + } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, school_student_params:, token:) + expect(ProfileApiClient).not_to have_received(:create_school_student) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:error]).to match(/username ' ' is invalid/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_student_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb index 4efec96ed..4f6ceb45d 100644 --- a/spec/features/school_owner/inviting_a_school_owner_spec.rb +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe 'Inviting an owner', type: :request do +RSpec.describe 'Inviting a school owner', type: :request do before do stub_hydra_public_api stub_profile_api_invite_school_owner(user_id: owner_id) diff --git a/spec/features/school_student/creating_a_school_student_spec.rb b/spec/features/school_student/creating_a_school_student_spec.rb new file mode 100644 index 000000000..43a40bfc1 --- /dev/null +++ b/spec/features/school_student/creating_a_school_student_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a school student', type: :request do + before do + stub_hydra_public_api + stub_profile_api_create_school_student(user_id: student_id) + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + let(:params) do + { + school_student: { + username: 'student123', + password: 'at-least-8-characters', + name: 'School Student' + } + } + end + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the created student JSON' do + post("/api/schools/#{school.id}/students", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('School Student') + end + + it 'responds 400 Bad Request when params are missing' do + post("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/students", headers:, params: { school_student: { username: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/students", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post("/api/schools/#{school.id}/students", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb index b29008ffd..07c6426f8 100644 --- a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb +++ b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe 'Inviting a teacher', type: :request do +RSpec.describe 'Inviting a school teacher', type: :request do before do stub_hydra_public_api stub_profile_api_invite_school_teacher(user_id: teacher_id) diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 984f0bc7d..776febeb1 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -16,4 +16,8 @@ def stub_profile_api_invite_school_owner(user_id:) def stub_profile_api_invite_school_teacher(user_id:) allow(ProfileApiClient).to receive(:invite_school_teacher).and_return(id: user_id) end + + def stub_profile_api_create_school_student(user_id:) + allow(ProfileApiClient).to receive(:create_school_student).and_return(id: user_id) + end end From 704a0f0880d8347b0a6b3d899cf736a0b3b1c9db Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 15:09:27 +0000 Subject: [PATCH 067/124] Add a SchoolOwners#destroy controller action --- .../api/school_owners_controller.rb | 8 ++++ app/models/ability.rb | 2 +- config/routes.rb | 2 +- lib/concepts/school_owner/remove.rb | 23 +++++++++ lib/profile_api_client.rb | 20 ++++++++ spec/concepts/school_owner/remove_spec.rb | 44 +++++++++++++++++ .../removing_a_school_owner_spec.rb | 47 +++++++++++++++++++ spec/support/profile_api_mock.rb | 4 ++ 8 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 lib/concepts/school_owner/remove.rb create mode 100644 spec/concepts/school_owner/remove_spec.rb create mode 100644 spec/features/school_owner/removing_a_school_owner_spec.rb diff --git a/app/controllers/api/school_owners_controller.rb b/app/controllers/api/school_owners_controller.rb index 3c38b60f8..8aaf0893e 100644 --- a/app/controllers/api/school_owners_controller.rb +++ b/app/controllers/api/school_owners_controller.rb @@ -17,6 +17,14 @@ def create end end + def destroy + result = SchoolOwner::Remove.call(school: @school, owner_id: params[:id], token: current_user.token) + + unless result.success? + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def school_owner_params diff --git a/app/models/ability.rb b/app/models/ability.rb index 68cef43a4..77d796c4f 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -22,7 +22,7 @@ def initialize(user) can(%i[update], School, id: organisation_id) can(%i[read create update], SchoolClass, school: { id: organisation_id }) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) - can(%i[create], :school_owner) + can(%i[create destroy], :school_owner) can(%i[create], :school_teacher) can(%i[create], :school_student) end diff --git a/config/routes.rb b/config/routes.rb index d3a66f795..af4f6e65d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,7 +22,7 @@ resources :members, only: %i[index create], controller: 'class_members' end - resources :owners, only: %i[create], controller: 'school_owners' + resources :owners, only: %i[create destroy], controller: 'school_owners' resources :teachers, only: %i[create], controller: 'school_teachers' resources :students, only: %i[create], controller: 'school_students' end diff --git a/lib/concepts/school_owner/remove.rb b/lib/concepts/school_owner/remove.rb new file mode 100644 index 000000000..94d3d6d22 --- /dev/null +++ b/lib/concepts/school_owner/remove.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SchoolOwner + class Remove + class << self + def call(school:, owner_id:, token:) + response = OperationResponse.new + remove_owner(school, owner_id, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error removing school owner: #{e}" + response + end + + private + + def remove_owner(school, owner_id, token) + ProfileApiClient.remove_school_owner(token:, owner_id:, organisation_id: school.id) + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 4708e39ae..8f59e61a7 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -79,5 +79,25 @@ def create_school_student(token:, username:, password:, name:, organisation_id:) response = { 'id' => '99999999-9999-9999-9999-999999999999' } response.deep_symbolize_keys end + + # The API should enforce these constraints: + # - The token has the school-owner role for the given organisation ID + # - The token user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 404 Not Found if the user doesn't exist + # - 422 Unprocessable if the constraints are not met + def remove_school_owner(token:, owner_id:, organisation_id:) + return nil if token.blank? + + _ = owner_id + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Remove propagates the error in the response. + response = {} + response.deep_symbolize_keys + end end end diff --git a/spec/concepts/school_owner/remove_spec.rb b/spec/concepts/school_owner/remove_spec.rb new file mode 100644 index 000000000..09ce08809 --- /dev/null +++ b/spec/concepts/school_owner/remove_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolOwner::Remove, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + before do + stub_profile_api_remove_school_owner(user_id: owner_id) + end + + it 'makes a profile API call' do + described_class.call(school:, owner_id:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:remove_school_owner) + .with(token:, owner_id:, organisation_id: school.id) + end + + context 'when removal fails' do + before do + allow(ProfileApiClient).to receive(:remove_school_owner).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, owner_id:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, owner_id:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, owner_id:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_owner/removing_a_school_owner_spec.rb b/spec/features/school_owner/removing_a_school_owner_spec.rb new file mode 100644 index 000000000..104d92862 --- /dev/null +++ b/spec/features/school_owner/removing_a_school_owner_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Removing a school owner', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + stub_profile_api_remove_school_owner(user_id: owner_id) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/owners/#{owner_id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/owners/#{owner_id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/owners/#{owner_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + delete("/api/schools/#{school.id}/owners/#{owner_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + delete("/api/schools/#{school.id}/owners/#{owner_id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 776febeb1..9ec2a6d31 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -20,4 +20,8 @@ def stub_profile_api_invite_school_teacher(user_id:) def stub_profile_api_create_school_student(user_id:) allow(ProfileApiClient).to receive(:create_school_student).and_return(id: user_id) end + + def stub_profile_api_remove_school_owner(user_id:) + allow(ProfileApiClient).to receive(:remove_school_owner).and_return({}) + end end From a549f1cf44056e33a53caf6400fe25c5a5873967 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 15:23:20 +0000 Subject: [PATCH 068/124] Respond with empty bodies for some controller actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously we were responding with the user object when inviting an owner, inviting a teacher or removing an owner. This isn’t necessary and avoids a call to the UserInfoApi. --- app/controllers/api/school_owners_controller.rb | 7 ++++--- app/controllers/api/school_teachers_controller.rb | 3 +-- app/views/api/school_owners/show.json.jbuilder | 10 ---------- app/views/api/school_teachers/show.json.jbuilder | 10 ---------- lib/concepts/school_owner/invite.rb | 8 ++------ lib/concepts/school_teacher/invite.rb | 8 ++------ lib/profile_api_client.rb | 4 ++-- spec/concepts/school_owner/invite_spec.rb | 8 +------- spec/concepts/school_owner/remove_spec.rb | 2 +- spec/concepts/school_teacher/invite_spec.rb | 8 +------- .../school_owner/inviting_a_school_owner_spec.rb | 9 +-------- .../school_owner/removing_a_school_owner_spec.rb | 2 +- .../school_teacher/inviting_a_school_teacher_spec.rb | 9 +-------- spec/support/profile_api_mock.rb | 10 +++++----- 14 files changed, 22 insertions(+), 76 deletions(-) delete mode 100644 app/views/api/school_owners/show.json.jbuilder delete mode 100644 app/views/api/school_teachers/show.json.jbuilder diff --git a/app/controllers/api/school_owners_controller.rb b/app/controllers/api/school_owners_controller.rb index 8aaf0893e..a482e342f 100644 --- a/app/controllers/api/school_owners_controller.rb +++ b/app/controllers/api/school_owners_controller.rb @@ -10,8 +10,7 @@ def create result = SchoolOwner::Invite.call(school: @school, school_owner_params:, token: current_user.token) if result.success? - @school_owner = result[:school_owner] - render :show, formats: [:json], status: :created + head :created else render json: { error: result[:error] }, status: :unprocessable_entity end @@ -20,7 +19,9 @@ def create def destroy result = SchoolOwner::Remove.call(school: @school, owner_id: params[:id], token: current_user.token) - unless result.success? + if result.success? + head :no_content + else render json: { error: result[:error] }, status: :unprocessable_entity end end diff --git a/app/controllers/api/school_teachers_controller.rb b/app/controllers/api/school_teachers_controller.rb index 1c8211024..ce7735582 100644 --- a/app/controllers/api/school_teachers_controller.rb +++ b/app/controllers/api/school_teachers_controller.rb @@ -10,8 +10,7 @@ def create result = SchoolTeacher::Invite.call(school: @school, school_teacher_params:, token: current_user.token) if result.success? - @school_teacher = result[:school_teacher] - render :show, formats: [:json], status: :created + head :created else render json: { error: result[:error] }, status: :unprocessable_entity end diff --git a/app/views/api/school_owners/show.json.jbuilder b/app/views/api/school_owners/show.json.jbuilder deleted file mode 100644 index 7cce72897..000000000 --- a/app/views/api/school_owners/show.json.jbuilder +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -json.call( - @school_owner, - :id, - :email, - :name, - :nickname, - :picture -) diff --git a/app/views/api/school_teachers/show.json.jbuilder b/app/views/api/school_teachers/show.json.jbuilder deleted file mode 100644 index 9713ee25b..000000000 --- a/app/views/api/school_teachers/show.json.jbuilder +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -json.call( - @school_teacher, - :id, - :email, - :name, - :nickname, - :picture -) diff --git a/lib/concepts/school_owner/invite.rb b/lib/concepts/school_owner/invite.rb index 0e0df5a63..e50352322 100644 --- a/lib/concepts/school_owner/invite.rb +++ b/lib/concepts/school_owner/invite.rb @@ -5,7 +5,7 @@ class Invite class << self def call(school:, school_owner_params:, token:) response = OperationResponse.new - response[:school_owner] = invite_owner(school, school_owner_params, token) + invite_owner(school, school_owner_params, token) response rescue StandardError => e Sentry.capture_exception(e) @@ -16,15 +16,11 @@ def call(school:, school_owner_params:, token:) private def invite_owner(school, school_owner_params, token) - organisation_id = school.id email_address = school_owner_params.fetch(:email_address) raise ArgumentError, "email address '#{email_address}' is invalid" unless EmailValidator.valid?(email_address) - response = ProfileApiClient.invite_school_owner(token:, email_address:, organisation_id:) - user_id = response.fetch(:id) - - User.from_userinfo(ids: user_id).first + ProfileApiClient.invite_school_owner(token:, email_address:, organisation_id: school.id) end end end diff --git a/lib/concepts/school_teacher/invite.rb b/lib/concepts/school_teacher/invite.rb index d23a32c68..8da9b218e 100644 --- a/lib/concepts/school_teacher/invite.rb +++ b/lib/concepts/school_teacher/invite.rb @@ -5,7 +5,7 @@ class Invite class << self def call(school:, school_teacher_params:, token:) response = OperationResponse.new - response[:school_teacher] = invite_teacher(school, school_teacher_params, token) + invite_teacher(school, school_teacher_params, token) response rescue StandardError => e Sentry.capture_exception(e) @@ -16,15 +16,11 @@ def call(school:, school_teacher_params:, token:) private def invite_teacher(school, school_teacher_params, token) - organisation_id = school.id email_address = school_teacher_params.fetch(:email_address) raise ArgumentError, "email address '#{email_address}' is invalid" unless EmailValidator.valid?(email_address) - response = ProfileApiClient.invite_school_teacher(token:, email_address:, organisation_id:) - user_id = response.fetch(:id) - - User.from_userinfo(ids: user_id).first + ProfileApiClient.invite_school_teacher(token:, email_address:, organisation_id: school.id) end end end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 8f59e61a7..23bdcc22f 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -34,7 +34,7 @@ def invite_school_owner(token:, email_address:, organisation_id:) # TODO: We should make Faraday raise a Ruby error for a non-2xx status # code so that SchoolOwner::Invite propagates the error in the response. - response = { 'id' => '99999999-9999-9999-9999-999999999999' } + response = {} response.deep_symbolize_keys end @@ -54,7 +54,7 @@ def invite_school_teacher(token:, email_address:, organisation_id:) # TODO: We should make Faraday raise a Ruby error for a non-2xx status # code so that SchoolTeacher::Invite propagates the error in the response. - response = { 'id' => '99999999-9999-9999-9999-999999999999' } + response = {} response.deep_symbolize_keys end diff --git a/spec/concepts/school_owner/invite_spec.rb b/spec/concepts/school_owner/invite_spec.rb index 0c4f7fda0..2f4cbae91 100644 --- a/spec/concepts/school_owner/invite_spec.rb +++ b/spec/concepts/school_owner/invite_spec.rb @@ -13,8 +13,7 @@ end before do - stub_profile_api_invite_school_owner(user_id: owner_id) - stub_user_info_api + stub_profile_api_invite_school_owner end it 'makes a profile API call' do @@ -25,11 +24,6 @@ .with(token:, email_address: 'owner-to-invite@example.com', organisation_id: school.id) end - it 'returns the school owner in the operation response' do - response = described_class.call(school:, school_owner_params:, token:) - expect(response[:school_owner]).to be_a(User) - end - context 'when creation fails' do let(:school_owner_params) do { email_address: 'invalid' } diff --git a/spec/concepts/school_owner/remove_spec.rb b/spec/concepts/school_owner/remove_spec.rb index 09ce08809..bde74a8fd 100644 --- a/spec/concepts/school_owner/remove_spec.rb +++ b/spec/concepts/school_owner/remove_spec.rb @@ -9,7 +9,7 @@ let(:owner_id) { user_id_by_index(owner_index) } before do - stub_profile_api_remove_school_owner(user_id: owner_id) + stub_profile_api_remove_school_owner end it 'makes a profile API call' do diff --git a/spec/concepts/school_teacher/invite_spec.rb b/spec/concepts/school_teacher/invite_spec.rb index 2d36c3b76..3cc8da290 100644 --- a/spec/concepts/school_teacher/invite_spec.rb +++ b/spec/concepts/school_teacher/invite_spec.rb @@ -13,8 +13,7 @@ end before do - stub_profile_api_invite_school_teacher(user_id: teacher_id) - stub_user_info_api + stub_profile_api_invite_school_teacher end it 'makes a profile API call' do @@ -25,11 +24,6 @@ .with(token:, email_address: 'teacher-to-invite@example.com', organisation_id: school.id) end - it 'returns the school teacher in the operation response' do - response = described_class.call(school:, school_teacher_params:, token:) - expect(response[:school_teacher]).to be_a(User) - end - context 'when creation fails' do let(:school_teacher_params) do { email_address: 'invalid' } diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb index 4f6ceb45d..0d3015e84 100644 --- a/spec/features/school_owner/inviting_a_school_owner_spec.rb +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Inviting a school owner', type: :request do before do stub_hydra_public_api - stub_profile_api_invite_school_owner(user_id: owner_id) + stub_profile_api_invite_school_owner stub_user_info_api end @@ -27,13 +27,6 @@ expect(response).to have_http_status(:created) end - it 'responds with the invited owner JSON' do - post("/api/schools/#{school.id}/owners", headers:, params:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data[:name]).to eq('School Owner') - end - it 'responds 400 Bad Request when params are missing' do post("/api/schools/#{school.id}/owners", headers:) expect(response).to have_http_status(:bad_request) diff --git a/spec/features/school_owner/removing_a_school_owner_spec.rb b/spec/features/school_owner/removing_a_school_owner_spec.rb index 104d92862..60406ccb6 100644 --- a/spec/features/school_owner/removing_a_school_owner_spec.rb +++ b/spec/features/school_owner/removing_a_school_owner_spec.rb @@ -6,7 +6,7 @@ before do stub_hydra_public_api stub_user_info_api - stub_profile_api_remove_school_owner(user_id: owner_id) + stub_profile_api_remove_school_owner end let(:headers) { { Authorization: UserProfileMock::TOKEN } } diff --git a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb index 07c6426f8..046cb6aab 100644 --- a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb +++ b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Inviting a school teacher', type: :request do before do stub_hydra_public_api - stub_profile_api_invite_school_teacher(user_id: teacher_id) + stub_profile_api_invite_school_teacher stub_user_info_api end @@ -27,13 +27,6 @@ expect(response).to have_http_status(:created) end - it 'responds with the invited teacher JSON' do - post("/api/schools/#{school.id}/teachers", headers:, params:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data[:name]).to eq('School Teacher') - end - it 'responds 400 Bad Request when params are missing' do post("/api/schools/#{school.id}/teachers", headers:) expect(response).to have_http_status(:bad_request) diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 9ec2a6d31..fa5e9c0e3 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -9,19 +9,19 @@ def stub_profile_api_create_organisation(organisation_id: ORGANISATION_ID) allow(ProfileApiClient).to receive(:create_organisation).and_return(id: organisation_id) end - def stub_profile_api_invite_school_owner(user_id:) - allow(ProfileApiClient).to receive(:invite_school_owner).and_return(id: user_id) + def stub_profile_api_invite_school_owner + allow(ProfileApiClient).to receive(:invite_school_owner).and_return({}) end - def stub_profile_api_invite_school_teacher(user_id:) - allow(ProfileApiClient).to receive(:invite_school_teacher).and_return(id: user_id) + def stub_profile_api_invite_school_teacher + allow(ProfileApiClient).to receive(:invite_school_teacher).and_return({}) end def stub_profile_api_create_school_student(user_id:) allow(ProfileApiClient).to receive(:create_school_student).and_return(id: user_id) end - def stub_profile_api_remove_school_owner(user_id:) + def stub_profile_api_remove_school_owner allow(ProfileApiClient).to receive(:remove_school_owner).and_return({}) end end From 670b0a85631b883d9a1229aac7939634de4f9d73 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 15:31:24 +0000 Subject: [PATCH 069/124] Add a SchoolTeachers#destroy controller action --- .../api/school_teachers_controller.rb | 10 ++++ app/models/ability.rb | 2 +- config/routes.rb | 2 +- lib/concepts/school_teacher/remove.rb | 23 +++++++++ lib/profile_api_client.rb | 20 ++++++++ spec/concepts/school_teacher/remove_spec.rb | 44 +++++++++++++++++ .../removing_a_school_teacher_spec.rb | 47 +++++++++++++++++++ spec/support/profile_api_mock.rb | 10 ++-- 8 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 lib/concepts/school_teacher/remove.rb create mode 100644 spec/concepts/school_teacher/remove_spec.rb create mode 100644 spec/features/school_teacher/removing_a_school_teacher_spec.rb diff --git a/app/controllers/api/school_teachers_controller.rb b/app/controllers/api/school_teachers_controller.rb index ce7735582..f61a573b2 100644 --- a/app/controllers/api/school_teachers_controller.rb +++ b/app/controllers/api/school_teachers_controller.rb @@ -16,6 +16,16 @@ def create end end + def destroy + result = SchoolTeacher::Remove.call(school: @school, teacher_id: params[:id], token: current_user.token) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def school_teacher_params diff --git a/app/models/ability.rb b/app/models/ability.rb index 77d796c4f..04d0c2f3b 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -23,7 +23,7 @@ def initialize(user) can(%i[read create update], SchoolClass, school: { id: organisation_id }) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[create destroy], :school_owner) - can(%i[create], :school_teacher) + can(%i[create destroy], :school_teacher) can(%i[create], :school_student) end diff --git a/config/routes.rb b/config/routes.rb index af4f6e65d..189d84b51 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,7 +23,7 @@ end resources :owners, only: %i[create destroy], controller: 'school_owners' - resources :teachers, only: %i[create], controller: 'school_teachers' + resources :teachers, only: %i[create destroy], controller: 'school_teachers' resources :students, only: %i[create], controller: 'school_students' end end diff --git a/lib/concepts/school_teacher/remove.rb b/lib/concepts/school_teacher/remove.rb new file mode 100644 index 000000000..50561b4e3 --- /dev/null +++ b/lib/concepts/school_teacher/remove.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SchoolTeacher + class Remove + class << self + def call(school:, teacher_id:, token:) + response = OperationResponse.new + remove_teacher(school, teacher_id, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error removing school teacher: #{e}" + response + end + + private + + def remove_teacher(school, teacher_id, token) + ProfileApiClient.remove_school_teacher(token:, teacher_id:, organisation_id: school.id) + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 23bdcc22f..3dd2ad88d 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -99,5 +99,25 @@ def remove_school_owner(token:, owner_id:, organisation_id:) response = {} response.deep_symbolize_keys end + + # The API should enforce these constraints: + # - The token has the school-owner role for the given organisation ID + # - The token user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 404 Not Found if the user doesn't exist + # - 422 Unprocessable if the constraints are not met + def remove_school_teacher(token:, teacher_id:, organisation_id:) + return nil if token.blank? + + _ = teacher_id + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Remove propagates the error in the response. + response = {} + response.deep_symbolize_keys + end end end diff --git a/spec/concepts/school_teacher/remove_spec.rb b/spec/concepts/school_teacher/remove_spec.rb new file mode 100644 index 000000000..288991fef --- /dev/null +++ b/spec/concepts/school_teacher/remove_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolTeacher::Remove, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + before do + stub_profile_api_remove_school_teacher + end + + it 'makes a profile API call' do + described_class.call(school:, teacher_id:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:remove_school_teacher) + .with(token:, teacher_id:, organisation_id: school.id) + end + + context 'when removal fails' do + before do + allow(ProfileApiClient).to receive(:remove_school_teacher).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, teacher_id:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, teacher_id:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, teacher_id:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_teacher/removing_a_school_teacher_spec.rb b/spec/features/school_teacher/removing_a_school_teacher_spec.rb new file mode 100644 index 000000000..8b931fe69 --- /dev/null +++ b/spec/features/school_teacher/removing_a_school_teacher_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Removing a school teacher', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + stub_profile_api_remove_school_teacher + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/teachers/#{teacher_id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-teacher for a different school' do + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index fa5e9c0e3..b820f191a 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -10,11 +10,11 @@ def stub_profile_api_create_organisation(organisation_id: ORGANISATION_ID) end def stub_profile_api_invite_school_owner - allow(ProfileApiClient).to receive(:invite_school_owner).and_return({}) + allow(ProfileApiClient).to receive(:invite_school_owner) end def stub_profile_api_invite_school_teacher - allow(ProfileApiClient).to receive(:invite_school_teacher).and_return({}) + allow(ProfileApiClient).to receive(:invite_school_teacher) end def stub_profile_api_create_school_student(user_id:) @@ -22,6 +22,10 @@ def stub_profile_api_create_school_student(user_id:) end def stub_profile_api_remove_school_owner - allow(ProfileApiClient).to receive(:remove_school_owner).and_return({}) + allow(ProfileApiClient).to receive(:remove_school_owner) + end + + def stub_profile_api_remove_school_teacher + allow(ProfileApiClient).to receive(:remove_school_teacher) end end From a1951abd6143b57e6f250b1693cb8be0498bba55 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 16:18:05 +0000 Subject: [PATCH 070/124] Add a SchoolStudents#destroy controller action --- .../api/school_students_controller.rb | 10 ++++ app/models/ability.rb | 2 +- config/routes.rb | 2 +- lib/concepts/school_student/delete.rb | 23 ++++++++++ lib/profile_api_client.rb | 22 +++++++++ spec/concepts/school_student/delete_spec.rb | 44 ++++++++++++++++++ .../deleting_a_school_student_spec.rb | 46 +++++++++++++++++++ .../removing_a_school_teacher_spec.rb | 2 +- spec/support/profile_api_mock.rb | 4 ++ 9 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 lib/concepts/school_student/delete.rb create mode 100644 spec/concepts/school_student/delete_spec.rb create mode 100644 spec/features/school_student/deleting_a_school_student_spec.rb diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb index 7aa65027c..bd90479ec 100644 --- a/app/controllers/api/school_students_controller.rb +++ b/app/controllers/api/school_students_controller.rb @@ -17,6 +17,16 @@ def create end end + def destroy + result = SchoolStudent::Delete.call(school: @school, student_id: params[:id], token: current_user.token) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def school_student_params diff --git a/app/models/ability.rb b/app/models/ability.rb index 04d0c2f3b..d610535f7 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -24,7 +24,7 @@ def initialize(user) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[create destroy], :school_owner) can(%i[create destroy], :school_teacher) - can(%i[create], :school_student) + can(%i[create destroy], :school_student) end if user.school_teacher?(organisation_id:) diff --git a/config/routes.rb b/config/routes.rb index 189d84b51..4bd721674 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,7 @@ resources :owners, only: %i[create destroy], controller: 'school_owners' resources :teachers, only: %i[create destroy], controller: 'school_teachers' - resources :students, only: %i[create], controller: 'school_students' + resources :students, only: %i[create destroy], controller: 'school_students' end end diff --git a/lib/concepts/school_student/delete.rb b/lib/concepts/school_student/delete.rb new file mode 100644 index 000000000..ff8ad6ffa --- /dev/null +++ b/lib/concepts/school_student/delete.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SchoolStudent + class Delete + class << self + def call(school:, student_id:, token:) + response = OperationResponse.new + delete_student(school, student_id, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting school student: #{e}" + response + end + + private + + def delete_student(school, student_id, token) + ProfileApiClient.delete_school_student(token:, student_id:, organisation_id: school.id) + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 3dd2ad88d..6376f13c5 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -14,6 +14,8 @@ class << self def create_organisation(token:) return nil if token.blank? + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that School::Create propagates the error in the response. response = { 'id' => '12345678-1234-1234-1234-123456789abc' } response.deep_symbolize_keys end @@ -119,5 +121,25 @@ def remove_school_teacher(token:, teacher_id:, organisation_id:) response = {} response.deep_symbolize_keys end + + # The API should enforce these constraints: + # - The token has the school-owner role for the given organisation ID + # - The token user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 404 Not Found if the user doesn't exist + # - 422 Unprocessable if the constraints are not met + def delete_school_student(token:, student_id:, organisation_id:) + return nil if token.blank? + + _ = student_id + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Remove propagates the error in the response. + response = {} + response.deep_symbolize_keys + end end end diff --git a/spec/concepts/school_student/delete_spec.rb b/spec/concepts/school_student/delete_spec.rb new file mode 100644 index 000000000..122834f7a --- /dev/null +++ b/spec/concepts/school_student/delete_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::Delete, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + before do + stub_profile_api_delete_school_student + end + + it 'makes a profile API call' do + described_class.call(school:, student_id:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:delete_school_student) + .with(token:, student_id:, organisation_id: school.id) + end + + context 'when removal fails' do + before do + allow(ProfileApiClient).to receive(:delete_school_student).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, student_id:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, student_id:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, student_id:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_student/deleting_a_school_student_spec.rb b/spec/features/school_student/deleting_a_school_student_spec.rb new file mode 100644 index 000000000..1e8712066 --- /dev/null +++ b/spec/features/school_student/deleting_a_school_student_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a school student', type: :request do + before do + stub_hydra_public_api + stub_profile_api_delete_school_student + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/students/#{student_id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + delete("/api/schools/#{school.id}/students/#{student_id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_teacher/removing_a_school_teacher_spec.rb b/spec/features/school_teacher/removing_a_school_teacher_spec.rb index 8b931fe69..cb230a1c5 100644 --- a/spec/features/school_teacher/removing_a_school_teacher_spec.rb +++ b/spec/features/school_teacher/removing_a_school_teacher_spec.rb @@ -24,7 +24,7 @@ expect(response).to have_http_status(:unauthorized) end - it 'responds 403 Forbidden when the user is a school-teacher for a different school' do + it 'responds 403 Forbidden when the user is a school-owner for a different school' do school.update!(id: SecureRandom.uuid) delete("/api/schools/#{school.id}/teachers/#{teacher_id}", headers:) diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index b820f191a..8718febd1 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -28,4 +28,8 @@ def stub_profile_api_remove_school_owner def stub_profile_api_remove_school_teacher allow(ProfileApiClient).to receive(:remove_school_teacher) end + + def stub_profile_api_delete_school_student + allow(ProfileApiClient).to receive(:delete_school_student) + end end From 2e73224ea6a86528b43c09b9e1037cc537a394cb Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 16:32:17 +0000 Subject: [PATCH 071/124] Remove some unnecessary UserInfoApi stubs in spec/features/ --- spec/features/school/creating_a_school_spec.rb | 1 - spec/features/school/listing_schools_spec.rb | 1 - spec/features/school/showing_a_school_spec.rb | 1 - spec/features/school/updating_a_school_spec.rb | 1 - spec/features/school_owner/inviting_a_school_owner_spec.rb | 1 - spec/features/school_owner/removing_a_school_owner_spec.rb | 1 - spec/features/school_teacher/inviting_a_school_teacher_spec.rb | 1 - spec/features/school_teacher/removing_a_school_teacher_spec.rb | 1 - 8 files changed, 8 deletions(-) diff --git a/spec/features/school/creating_a_school_spec.rb b/spec/features/school/creating_a_school_spec.rb index a577b3ece..01ac9a318 100644 --- a/spec/features/school/creating_a_school_spec.rb +++ b/spec/features/school/creating_a_school_spec.rb @@ -5,7 +5,6 @@ RSpec.describe 'Creating a school', type: :request do before do stub_hydra_public_api - stub_user_info_api stub_profile_api_create_organisation end diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb index 652acdc28..3fb5ea2aa 100644 --- a/spec/features/school/listing_schools_spec.rb +++ b/spec/features/school/listing_schools_spec.rb @@ -5,7 +5,6 @@ RSpec.describe 'Listing schools', type: :request do before do stub_hydra_public_api - stub_user_info_api create(:school, name: 'Test School') end diff --git a/spec/features/school/showing_a_school_spec.rb b/spec/features/school/showing_a_school_spec.rb index 0a9920f3e..7bf8dd7d5 100644 --- a/spec/features/school/showing_a_school_spec.rb +++ b/spec/features/school/showing_a_school_spec.rb @@ -5,7 +5,6 @@ RSpec.describe 'Showing a school', type: :request do before do stub_hydra_public_api - stub_user_info_api end let!(:school) { create(:school, name: 'Test School') } diff --git a/spec/features/school/updating_a_school_spec.rb b/spec/features/school/updating_a_school_spec.rb index babb472ee..4c81bcf89 100644 --- a/spec/features/school/updating_a_school_spec.rb +++ b/spec/features/school/updating_a_school_spec.rb @@ -5,7 +5,6 @@ RSpec.describe 'Updating a school', type: :request do before do stub_hydra_public_api - stub_user_info_api end let!(:school) { create(:school) } diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb index 0d3015e84..cde074fbe 100644 --- a/spec/features/school_owner/inviting_a_school_owner_spec.rb +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -6,7 +6,6 @@ before do stub_hydra_public_api stub_profile_api_invite_school_owner - stub_user_info_api end let(:headers) { { Authorization: UserProfileMock::TOKEN } } diff --git a/spec/features/school_owner/removing_a_school_owner_spec.rb b/spec/features/school_owner/removing_a_school_owner_spec.rb index 60406ccb6..92991d82d 100644 --- a/spec/features/school_owner/removing_a_school_owner_spec.rb +++ b/spec/features/school_owner/removing_a_school_owner_spec.rb @@ -5,7 +5,6 @@ RSpec.describe 'Removing a school owner', type: :request do before do stub_hydra_public_api - stub_user_info_api stub_profile_api_remove_school_owner end diff --git a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb index 046cb6aab..931ebcea5 100644 --- a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb +++ b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb @@ -6,7 +6,6 @@ before do stub_hydra_public_api stub_profile_api_invite_school_teacher - stub_user_info_api end let(:headers) { { Authorization: UserProfileMock::TOKEN } } diff --git a/spec/features/school_teacher/removing_a_school_teacher_spec.rb b/spec/features/school_teacher/removing_a_school_teacher_spec.rb index cb230a1c5..b30fb00b6 100644 --- a/spec/features/school_teacher/removing_a_school_teacher_spec.rb +++ b/spec/features/school_teacher/removing_a_school_teacher_spec.rb @@ -5,7 +5,6 @@ RSpec.describe 'Removing a school teacher', type: :request do before do stub_hydra_public_api - stub_user_info_api stub_profile_api_remove_school_teacher end From 0b85a1e16ff7ce325ca0932c6f243db74fb31311 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 16:35:30 +0000 Subject: [PATCH 072/124] =?UTF-8?q?Remove=20student=20=E2=80=98nickname?= =?UTF-8?q?=E2=80=99=20and=20=E2=80=98picture=E2=80=99=20fields=20from=20r?= =?UTF-8?q?esponse=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/api/class_members/index.json.jbuilder | 2 -- app/views/api/class_members/show.json.jbuilder | 2 -- app/views/api/school_classes/index.json.jbuilder | 2 -- app/views/api/school_classes/show.json.jbuilder | 2 -- app/views/api/school_students/show.json.jbuilder | 4 +--- 5 files changed, 1 insertion(+), 11 deletions(-) diff --git a/app/views/api/class_members/index.json.jbuilder b/app/views/api/class_members/index.json.jbuilder index fdea7ee57..03b5dae8a 100644 --- a/app/views/api/class_members/index.json.jbuilder +++ b/app/views/api/class_members/index.json.jbuilder @@ -11,6 +11,4 @@ json.array!(@class_members_with_students) do |class_member, student| ) json.student_name(student&.name) - json.student_nickname(student&.nickname) - json.student_picture(student&.picture) end diff --git a/app/views/api/class_members/show.json.jbuilder b/app/views/api/class_members/show.json.jbuilder index 9ddaa128d..52d91c3fa 100644 --- a/app/views/api/class_members/show.json.jbuilder +++ b/app/views/api/class_members/show.json.jbuilder @@ -12,5 +12,3 @@ json.call( ) json.student_name(student&.name) -json.student_nickname(student&.nickname) -json.student_picture(student&.picture) diff --git a/app/views/api/school_classes/index.json.jbuilder b/app/views/api/school_classes/index.json.jbuilder index c351f08cf..bacd654cc 100644 --- a/app/views/api/school_classes/index.json.jbuilder +++ b/app/views/api/school_classes/index.json.jbuilder @@ -12,6 +12,4 @@ json.array!(@school_classes_with_teachers) do |school_class, teacher| ) json.teacher_name(teacher&.name) - json.teacher_nickname(teacher&.nickname) - json.teacher_picture(teacher&.picture) end diff --git a/app/views/api/school_classes/show.json.jbuilder b/app/views/api/school_classes/show.json.jbuilder index 688aead5c..7d27d25b7 100644 --- a/app/views/api/school_classes/show.json.jbuilder +++ b/app/views/api/school_classes/show.json.jbuilder @@ -13,5 +13,3 @@ json.call( ) json.teacher_name(teacher&.name) -json.teacher_nickname(teacher&.nickname) -json.teacher_picture(teacher&.picture) diff --git a/app/views/api/school_students/show.json.jbuilder b/app/views/api/school_students/show.json.jbuilder index 02a6cc93c..99abd8d52 100644 --- a/app/views/api/school_students/show.json.jbuilder +++ b/app/views/api/school_students/show.json.jbuilder @@ -4,7 +4,5 @@ json.call( @school_student, :id, :username, - :name, - :nickname, - :picture + :name ) From 54a56d15567cc5302f4e4309b6c4eeab53826d6e Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 16:37:46 +0000 Subject: [PATCH 073/124] =?UTF-8?q?Add=20=E2=80=98student=5Fusername?= =?UTF-8?q?=E2=80=99=20to=20ClassMembers=20response=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This information is only visible to owners/teachers. --- app/views/api/class_members/index.json.jbuilder | 1 + app/views/api/class_members/show.json.jbuilder | 1 + 2 files changed, 2 insertions(+) diff --git a/app/views/api/class_members/index.json.jbuilder b/app/views/api/class_members/index.json.jbuilder index 03b5dae8a..f9db4669a 100644 --- a/app/views/api/class_members/index.json.jbuilder +++ b/app/views/api/class_members/index.json.jbuilder @@ -10,5 +10,6 @@ json.array!(@class_members_with_students) do |class_member, student| :updated_at ) + json.student_username(student&.username) json.student_name(student&.name) end diff --git a/app/views/api/class_members/show.json.jbuilder b/app/views/api/class_members/show.json.jbuilder index 52d91c3fa..4e8bc607b 100644 --- a/app/views/api/class_members/show.json.jbuilder +++ b/app/views/api/class_members/show.json.jbuilder @@ -11,4 +11,5 @@ json.call( :updated_at ) +json.student_username(student&.username) json.student_name(student&.name) From 7991febd0ead13b2b1e23f151f4ae78a5f69ea36 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 16:45:26 +0000 Subject: [PATCH 074/124] =?UTF-8?q?Distribute=20the=20=E2=80=98can=20read?= =?UTF-8?q?=20school=E2=80=99=20ability=20to=20improve=20code=20consistenc?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/ability.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index d610535f7..7fd41a119 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -16,10 +16,8 @@ def initialize(user) can %i[create], School # The user agrees to become a school-owner by creating a school. user.organisation_ids.each do |organisation_id| - can(%i[read], School, id: organisation_id) - if user.school_owner?(organisation_id:) - can(%i[update], School, id: organisation_id) + can(%i[read update], School, id: organisation_id) can(%i[read create update], SchoolClass, school: { id: organisation_id }) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[create destroy], :school_owner) @@ -28,6 +26,7 @@ def initialize(user) end if user.school_teacher?(organisation_id:) + can(%i[read], School, id: organisation_id) can(%i[create], SchoolClass, school: { id: organisation_id }) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) @@ -35,6 +34,7 @@ def initialize(user) end if user.school_student?(organisation_id:) + can(%i[read], School, id: organisation_id) can(%i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id }) end end From 9c89f1e8df41ab38babc50725e858dd327651101 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 17:33:15 +0000 Subject: [PATCH 075/124] Add a SchoolStudents#update controller action --- .../api/school_students_controller.rb | 10 +++ app/models/ability.rb | 4 +- config/routes.rb | 2 +- lib/concepts/school_student/create.rb | 10 ++- lib/concepts/school_student/update.rb | 38 +++++++++++ lib/profile_api_client.rb | 22 +++++++ spec/concepts/school_student/update_spec.rb | 64 +++++++++++++++++++ .../updating_a_school_student_spec.rb | 56 ++++++++++++++++ spec/support/profile_api_mock.rb | 4 ++ 9 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 lib/concepts/school_student/update.rb create mode 100644 spec/concepts/school_student/update_spec.rb create mode 100644 spec/features/school_student/updating_a_school_student_spec.rb diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb index bd90479ec..7a1cf1025 100644 --- a/app/controllers/api/school_students_controller.rb +++ b/app/controllers/api/school_students_controller.rb @@ -17,6 +17,16 @@ def create end end + def update + result = SchoolStudent::Update.call(school: @school, school_student_params:, token: current_user.token) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + def destroy result = SchoolStudent::Delete.call(school: @school, student_id: params[:id], token: current_user.token) diff --git a/app/models/ability.rb b/app/models/ability.rb index 7fd41a119..8fcaad760 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -22,7 +22,7 @@ def initialize(user) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[create destroy], :school_owner) can(%i[create destroy], :school_teacher) - can(%i[create destroy], :school_student) + can(%i[create update destroy], :school_student) end if user.school_teacher?(organisation_id:) @@ -30,7 +30,7 @@ def initialize(user) can(%i[create], SchoolClass, school: { id: organisation_id }) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) - can(%i[create], :school_student) + can(%i[create update], :school_student) end if user.school_student?(organisation_id:) diff --git a/config/routes.rb b/config/routes.rb index 4bd721674..07ee9d1d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,7 @@ resources :owners, only: %i[create destroy], controller: 'school_owners' resources :teachers, only: %i[create destroy], controller: 'school_teachers' - resources :students, only: %i[create destroy], controller: 'school_students' + resources :students, only: %i[create update destroy], controller: 'school_students' end end diff --git a/lib/concepts/school_student/create.rb b/lib/concepts/school_student/create.rb index 50af120d9..e51b85c8f 100644 --- a/lib/concepts/school_student/create.rb +++ b/lib/concepts/school_student/create.rb @@ -21,15 +21,19 @@ def create_student(school, school_student_params, token) password = school_student_params.fetch(:password) name = school_student_params.fetch(:name) - raise ArgumentError, "username '#{username}' is invalid" if username.blank? - raise ArgumentError, "password '#{password}' is invalid" if password.size < 8 - raise ArgumentError, "name '#{name}' is invalid" if name.blank? + validate(username:, password:, name:) response = ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id:) user_id = response.fetch(:id) User.from_userinfo(ids: user_id).first end + + def validate(username:, password:, name:) + raise ArgumentError, "username '#{username}' is invalid" if username.blank? + raise ArgumentError, "password '#{password}' is invalid" if password.size < 8 + raise ArgumentError, "name '#{name}' is invalid" if name.blank? + end end end end diff --git a/lib/concepts/school_student/update.rb b/lib/concepts/school_student/update.rb new file mode 100644 index 000000000..91a979a04 --- /dev/null +++ b/lib/concepts/school_student/update.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module SchoolStudent + class Update + class << self + def call(school:, school_student_params:, token:) + response = OperationResponse.new + update_student(school, school_student_params, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error updating school student: #{e}" + response + end + + private + + def update_student(school, school_student_params, token) + username = school_student_params.fetch(:username, nil) + password = school_student_params.fetch(:password, nil) + name = school_student_params.fetch(:name, nil) + + validate(username:, password:, name:) + + attributes_to_update = { username:, password:, name: }.compact + return if attributes_to_update.empty? + + ProfileApiClient.update_school_student(token:, attributes_to_update:, organisation_id: school.id) + end + + def validate(username:, password:, name:) + raise ArgumentError, "username '#{username}' is invalid" if !username.nil? && username.blank? + raise ArgumentError, "password '#{password}' is invalid" if !password.nil? && password.size < 8 + raise ArgumentError, "name '#{name}' is invalid" if !name.nil? && name.blank? + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 6376f13c5..05a5c6cee 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -126,6 +126,7 @@ def remove_school_teacher(token:, teacher_id:, organisation_id:) # - The token has the school-owner role for the given organisation ID # - The token user should not be under 13 # - The email must be verified + # - The student_id must be a school-student for the given organisation ID # # The API should respond: # - 404 Not Found if the user doesn't exist @@ -141,5 +142,26 @@ def delete_school_student(token:, student_id:, organisation_id:) response = {} response.deep_symbolize_keys end + + # The API should enforce these constraints: + # - The token has the school-owner or school-teacher role for the given organisation ID + # - The token user should not be under 13 + # - The email must be verified + # - The student_id must be a school-student for the given organisation ID + # + # The API should respond: + # - 404 Not Found if the user doesn't exist + # - 422 Unprocessable if the constraints are not met + def update_school_student(token:, attributes_to_update:, organisation_id:) + return nil if token.blank? + + _ = attributes_to_update + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Remove propagates the error in the response. + response = {} + response.deep_symbolize_keys + end end end diff --git a/spec/concepts/school_student/update_spec.rb b/spec/concepts/school_student/update_spec.rb new file mode 100644 index 000000000..4f9bda2c3 --- /dev/null +++ b/spec/concepts/school_student/update_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::Update, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + let(:school_student_params) do + { + username: 'new-username', + password: 'new-password', + name: 'New Name' + } + end + + before do + stub_profile_api_update_school_student + end + + it 'makes a profile API call' do + described_class.call(school:, school_student_params:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:update_school_student) + .with(token:, attributes_to_update: school_student_params, organisation_id: school.id) + end + + context 'when updating fails' do + let(:school_student_params) do + { + username: ' ', + password: 'new-password', + name: 'New Name' + } + end + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, school_student_params:, token:) + expect(ProfileApiClient).not_to have_received(:update_school_student) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:error]).to match(/username ' ' is invalid/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_student_params:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_student/updating_a_school_student_spec.rb b/spec/features/school_student/updating_a_school_student_spec.rb new file mode 100644 index 000000000..2ff28de5b --- /dev/null +++ b/spec/features/school_student/updating_a_school_student_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a school student', type: :request do + before do + stub_hydra_public_api + stub_profile_api_update_school_student + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + let(:params) do + { + school_student: { + username: 'new-username', + password: 'new-password', + name: 'New Name' + } + } + end + + it 'responds 204 No Content' do + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/schools/#{school.id}/students/#{student_id}", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + put("/api/schools/#{school.id}/students/#{student_id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 8718febd1..ed1222cd0 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -32,4 +32,8 @@ def stub_profile_api_remove_school_teacher def stub_profile_api_delete_school_student allow(ProfileApiClient).to receive(:delete_school_student) end + + def stub_profile_api_update_school_student + allow(ProfileApiClient).to receive(:update_school_student) + end end From 381479e7394dfcc45557d7df5822bd5b1750477f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 14 Feb 2024 17:36:27 +0000 Subject: [PATCH 076/124] =?UTF-8?q?Don=E2=80=99t=20render=20the=20school?= =?UTF-8?q?=20student=20in=20the=20response=20after=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There shouldn’t be a need to do this. Students should always be viewed as a list in the context of a school or class. This avoids an additional UserInfoApi request. --- .../api/school_students_controller.rb | 3 +-- app/views/api/school_students/show.json.jbuilder | 8 -------- lib/concepts/school_student/create.rb | 7 ++----- lib/profile_api_client.rb | 2 +- spec/concepts/school_student/create_spec.rb | 6 ------ .../creating_a_school_student_spec.rb | 16 ++++------------ 6 files changed, 8 insertions(+), 34 deletions(-) delete mode 100644 app/views/api/school_students/show.json.jbuilder diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb index 7a1cf1025..153f6afce 100644 --- a/app/controllers/api/school_students_controller.rb +++ b/app/controllers/api/school_students_controller.rb @@ -10,8 +10,7 @@ def create result = SchoolStudent::Create.call(school: @school, school_student_params:, token: current_user.token) if result.success? - @school_student = result[:school_student] - render :show, formats: [:json], status: :created + head :no_content else render json: { error: result[:error] }, status: :unprocessable_entity end diff --git a/app/views/api/school_students/show.json.jbuilder b/app/views/api/school_students/show.json.jbuilder deleted file mode 100644 index 99abd8d52..000000000 --- a/app/views/api/school_students/show.json.jbuilder +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -json.call( - @school_student, - :id, - :username, - :name -) diff --git a/lib/concepts/school_student/create.rb b/lib/concepts/school_student/create.rb index e51b85c8f..941c3d31b 100644 --- a/lib/concepts/school_student/create.rb +++ b/lib/concepts/school_student/create.rb @@ -5,7 +5,7 @@ class Create class << self def call(school:, school_student_params:, token:) response = OperationResponse.new - response[:school_student] = create_student(school, school_student_params, token) + create_student(school, school_student_params, token) response rescue StandardError => e Sentry.capture_exception(e) @@ -23,10 +23,7 @@ def create_student(school, school_student_params, token) validate(username:, password:, name:) - response = ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id:) - user_id = response.fetch(:id) - - User.from_userinfo(ids: user_id).first + ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id:) end def validate(username:, password:, name:) diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 05a5c6cee..045e799bf 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -78,7 +78,7 @@ def create_school_student(token:, username:, password:, name:, organisation_id:) # TODO: We should make Faraday raise a Ruby error for a non-2xx status # code so that SchoolStudent::Create propagates the error in the response. - response = { 'id' => '99999999-9999-9999-9999-999999999999' } + response = {} response.deep_symbolize_keys end diff --git a/spec/concepts/school_student/create_spec.rb b/spec/concepts/school_student/create_spec.rb index e20dd9cd9..1404f0b4d 100644 --- a/spec/concepts/school_student/create_spec.rb +++ b/spec/concepts/school_student/create_spec.rb @@ -18,7 +18,6 @@ before do stub_profile_api_create_school_student(user_id: student_id) - stub_user_info_api end it 'makes a profile API call' do @@ -29,11 +28,6 @@ .with(token:, username: 'student-to-create', password: 'at-least-8-characters', name: 'School Student', organisation_id: school.id) end - it 'returns the school student in the operation response' do - response = described_class.call(school:, school_student_params:, token:) - expect(response[:school_student]).to be_a(User) - end - context 'when creation fails' do let(:school_student_params) do { diff --git a/spec/features/school_student/creating_a_school_student_spec.rb b/spec/features/school_student/creating_a_school_student_spec.rb index 43a40bfc1..f3337f1d9 100644 --- a/spec/features/school_student/creating_a_school_student_spec.rb +++ b/spec/features/school_student/creating_a_school_student_spec.rb @@ -6,7 +6,6 @@ before do stub_hydra_public_api stub_profile_api_create_school_student(user_id: student_id) - stub_user_info_api end let(:headers) { { Authorization: UserProfileMock::TOKEN } } @@ -24,23 +23,16 @@ } end - it 'responds 201 Created' do + it 'responds 204 No Content' do post("/api/schools/#{school.id}/students", headers:, params:) - expect(response).to have_http_status(:created) + expect(response).to have_http_status(:no_content) end - it 'responds 201 Created when the user is a school-teacher' do + it 'responds 204 No Content when the user is a school-teacher' do stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) post("/api/schools/#{school.id}/students", headers:, params:) - expect(response).to have_http_status(:created) - end - - it 'responds with the created student JSON' do - post("/api/schools/#{school.id}/students", headers:, params:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data[:name]).to eq('School Student') + expect(response).to have_http_status(:no_content) end it 'responds 400 Bad Request when params are missing' do From e1d07baa9db2e5e1b45a25a6b6fd019f634e166e Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 12:15:07 +0000 Subject: [PATCH 077/124] Prevent users being invited from unverified schools --- lib/concepts/school_owner/invite.rb | 1 + lib/concepts/school_student/create.rb | 5 +++-- lib/concepts/school_teacher/invite.rb | 1 + spec/concepts/school_owner/invite_spec.rb | 11 ++++++++++- spec/concepts/school_student/create_spec.rb | 11 ++++++++++- spec/concepts/school_teacher/invite_spec.rb | 11 ++++++++++- .../school_owner/inviting_a_school_owner_spec.rb | 2 +- .../school_student/creating_a_school_student_spec.rb | 2 +- .../school_teacher/inviting_a_school_teacher_spec.rb | 2 +- 9 files changed, 38 insertions(+), 8 deletions(-) diff --git a/lib/concepts/school_owner/invite.rb b/lib/concepts/school_owner/invite.rb index e50352322..fb1f60904 100644 --- a/lib/concepts/school_owner/invite.rb +++ b/lib/concepts/school_owner/invite.rb @@ -18,6 +18,7 @@ def call(school:, school_owner_params:, token:) def invite_owner(school, school_owner_params, token) email_address = school_owner_params.fetch(:email_address) + raise ArgumentError, 'school is not verified' unless school.verified_at raise ArgumentError, "email address '#{email_address}' is invalid" unless EmailValidator.valid?(email_address) ProfileApiClient.invite_school_owner(token:, email_address:, organisation_id: school.id) diff --git a/lib/concepts/school_student/create.rb b/lib/concepts/school_student/create.rb index 941c3d31b..9462f82c5 100644 --- a/lib/concepts/school_student/create.rb +++ b/lib/concepts/school_student/create.rb @@ -21,12 +21,13 @@ def create_student(school, school_student_params, token) password = school_student_params.fetch(:password) name = school_student_params.fetch(:name) - validate(username:, password:, name:) + validate(school:, username:, password:, name:) ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id:) end - def validate(username:, password:, name:) + def validate(school:, username:, password:, name:) + raise ArgumentError, 'school is not verified' unless school.verified_at raise ArgumentError, "username '#{username}' is invalid" if username.blank? raise ArgumentError, "password '#{password}' is invalid" if password.size < 8 raise ArgumentError, "name '#{name}' is invalid" if name.blank? diff --git a/lib/concepts/school_teacher/invite.rb b/lib/concepts/school_teacher/invite.rb index 8da9b218e..8305ce454 100644 --- a/lib/concepts/school_teacher/invite.rb +++ b/lib/concepts/school_teacher/invite.rb @@ -18,6 +18,7 @@ def call(school:, school_teacher_params:, token:) def invite_teacher(school, school_teacher_params, token) email_address = school_teacher_params.fetch(:email_address) + raise ArgumentError, 'school is not verified' unless school.verified_at raise ArgumentError, "email address '#{email_address}' is invalid" unless EmailValidator.valid?(email_address) ProfileApiClient.invite_school_teacher(token:, email_address:, organisation_id: school.id) diff --git a/spec/concepts/school_owner/invite_spec.rb b/spec/concepts/school_owner/invite_spec.rb index 2f4cbae91..d56d0b0fb 100644 --- a/spec/concepts/school_owner/invite_spec.rb +++ b/spec/concepts/school_owner/invite_spec.rb @@ -4,7 +4,7 @@ RSpec.describe SchoolOwner::Invite, type: :unit do let(:token) { UserProfileMock::TOKEN } - let(:school) { create(:school) } + let(:school) { create(:school, verified_at: Time.zone.now) } let(:owner_index) { user_index_by_role('school-owner') } let(:owner_id) { user_id_by_index(owner_index) } @@ -53,4 +53,13 @@ expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) end end + + context 'when the school is not verified' do + let(:school) { create(:school) } + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response[:error]).to match(/school is not verified/) + end + end end diff --git a/spec/concepts/school_student/create_spec.rb b/spec/concepts/school_student/create_spec.rb index 1404f0b4d..99cf6db0c 100644 --- a/spec/concepts/school_student/create_spec.rb +++ b/spec/concepts/school_student/create_spec.rb @@ -4,7 +4,7 @@ RSpec.describe SchoolStudent::Create, type: :unit do let(:token) { UserProfileMock::TOKEN } - let(:school) { create(:school) } + let(:school) { create(:school, verified_at: Time.zone.now) } let(:student_index) { user_index_by_role('school-student') } let(:student_id) { user_id_by_index(student_index) } @@ -61,4 +61,13 @@ expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) end end + + context 'when the school is not verified' do + let(:school) { create(:school) } + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response[:error]).to match(/school is not verified/) + end + end end diff --git a/spec/concepts/school_teacher/invite_spec.rb b/spec/concepts/school_teacher/invite_spec.rb index 3cc8da290..65f0dee94 100644 --- a/spec/concepts/school_teacher/invite_spec.rb +++ b/spec/concepts/school_teacher/invite_spec.rb @@ -4,7 +4,7 @@ RSpec.describe SchoolTeacher::Invite, type: :unit do let(:token) { UserProfileMock::TOKEN } - let(:school) { create(:school) } + let(:school) { create(:school, verified_at: Time.zone.now) } let(:teacher_index) { user_index_by_role('school-teacher') } let(:teacher_id) { user_id_by_index(teacher_index) } @@ -53,4 +53,13 @@ expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) end end + + context 'when the school is not verified' do + let(:school) { create(:school) } + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response[:error]).to match(/school is not verified/) + end + end end diff --git a/spec/features/school_owner/inviting_a_school_owner_spec.rb b/spec/features/school_owner/inviting_a_school_owner_spec.rb index cde074fbe..9f78ab6ae 100644 --- a/spec/features/school_owner/inviting_a_school_owner_spec.rb +++ b/spec/features/school_owner/inviting_a_school_owner_spec.rb @@ -9,7 +9,7 @@ end let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:school) { create(:school) } + let(:school) { create(:school, verified_at: Time.zone.now) } let(:owner_index) { user_index_by_role('school-owner') } let(:owner_id) { user_id_by_index(owner_index) } diff --git a/spec/features/school_student/creating_a_school_student_spec.rb b/spec/features/school_student/creating_a_school_student_spec.rb index f3337f1d9..e292519e4 100644 --- a/spec/features/school_student/creating_a_school_student_spec.rb +++ b/spec/features/school_student/creating_a_school_student_spec.rb @@ -9,7 +9,7 @@ end let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:school) { create(:school) } + let(:school) { create(:school, verified_at: Time.zone.now) } let(:student_index) { user_index_by_role('school-student') } let(:student_id) { user_id_by_index(student_index) } diff --git a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb index 931ebcea5..c5ba6d3d7 100644 --- a/spec/features/school_teacher/inviting_a_school_teacher_spec.rb +++ b/spec/features/school_teacher/inviting_a_school_teacher_spec.rb @@ -9,7 +9,7 @@ end let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:school) { create(:school) } + let(:school) { create(:school, verified_at: Time.zone.now) } let(:teacher_index) { user_index_by_role('school-teacher') } let(:teacher_id) { user_id_by_index(teacher_index) } From 001673eb63187cbf802cf5c816f25295f70f629f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 12:54:57 +0000 Subject: [PATCH 078/124] Reorder methods in ProfileApiClient --- lib/profile_api_client.rb | 46 ++++++++++++++++---------------- spec/support/profile_api_mock.rb | 20 +++++++------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 045e799bf..e267866a9 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -42,42 +42,40 @@ def invite_school_owner(token:, email_address:, organisation_id:) # The API should enforce these constraints: # - The token has the school-owner role for the given organisation ID - # - The token user or given user should not be under 13 + # - The token user should not be under 13 # - The email must be verified # # The API should respond: # - 404 Not Found if the user doesn't exist # - 422 Unprocessable if the constraints are not met - def invite_school_teacher(token:, email_address:, organisation_id:) + def remove_school_owner(token:, owner_id:, organisation_id:) return nil if token.blank? - _ = email_address + _ = owner_id _ = organisation_id # TODO: We should make Faraday raise a Ruby error for a non-2xx status - # code so that SchoolTeacher::Invite propagates the error in the response. + # code so that SchoolOwner::Remove propagates the error in the response. response = {} response.deep_symbolize_keys end # The API should enforce these constraints: - # - The token has the school-owner or school-teacher role for the given organisation ID - # - The token user should not be under 13 + # - The token has the school-owner role for the given organisation ID + # - The token user or given user should not be under 13 # - The email must be verified # # The API should respond: # - 404 Not Found if the user doesn't exist # - 422 Unprocessable if the constraints are not met - def create_school_student(token:, username:, password:, name:, organisation_id:) + def invite_school_teacher(token:, email_address:, organisation_id:) return nil if token.blank? - _ = username - _ = password - _ = name + _ = email_address _ = organisation_id # TODO: We should make Faraday raise a Ruby error for a non-2xx status - # code so that SchoolStudent::Create propagates the error in the response. + # code so that SchoolTeacher::Invite propagates the error in the response. response = {} response.deep_symbolize_keys end @@ -90,10 +88,10 @@ def create_school_student(token:, username:, password:, name:, organisation_id:) # The API should respond: # - 404 Not Found if the user doesn't exist # - 422 Unprocessable if the constraints are not met - def remove_school_owner(token:, owner_id:, organisation_id:) + def remove_school_teacher(token:, teacher_id:, organisation_id:) return nil if token.blank? - _ = owner_id + _ = teacher_id _ = organisation_id # TODO: We should make Faraday raise a Ruby error for a non-2xx status @@ -103,27 +101,29 @@ def remove_school_owner(token:, owner_id:, organisation_id:) end # The API should enforce these constraints: - # - The token has the school-owner role for the given organisation ID + # - The token has the school-owner or school-teacher role for the given organisation ID # - The token user should not be under 13 # - The email must be verified # # The API should respond: # - 404 Not Found if the user doesn't exist # - 422 Unprocessable if the constraints are not met - def remove_school_teacher(token:, teacher_id:, organisation_id:) + def create_school_student(token:, username:, password:, name:, organisation_id:) return nil if token.blank? - _ = teacher_id + _ = username + _ = password + _ = name _ = organisation_id # TODO: We should make Faraday raise a Ruby error for a non-2xx status - # code so that SchoolOwner::Remove propagates the error in the response. + # code so that SchoolStudent::Create propagates the error in the response. response = {} response.deep_symbolize_keys end # The API should enforce these constraints: - # - The token has the school-owner role for the given organisation ID + # - The token has the school-owner or school-teacher role for the given organisation ID # - The token user should not be under 13 # - The email must be verified # - The student_id must be a school-student for the given organisation ID @@ -131,10 +131,10 @@ def remove_school_teacher(token:, teacher_id:, organisation_id:) # The API should respond: # - 404 Not Found if the user doesn't exist # - 422 Unprocessable if the constraints are not met - def delete_school_student(token:, student_id:, organisation_id:) + def update_school_student(token:, attributes_to_update:, organisation_id:) return nil if token.blank? - _ = student_id + _ = attributes_to_update _ = organisation_id # TODO: We should make Faraday raise a Ruby error for a non-2xx status @@ -144,7 +144,7 @@ def delete_school_student(token:, student_id:, organisation_id:) end # The API should enforce these constraints: - # - The token has the school-owner or school-teacher role for the given organisation ID + # - The token has the school-owner role for the given organisation ID # - The token user should not be under 13 # - The email must be verified # - The student_id must be a school-student for the given organisation ID @@ -152,10 +152,10 @@ def delete_school_student(token:, student_id:, organisation_id:) # The API should respond: # - 404 Not Found if the user doesn't exist # - 422 Unprocessable if the constraints are not met - def update_school_student(token:, attributes_to_update:, organisation_id:) + def delete_school_student(token:, student_id:, organisation_id:) return nil if token.blank? - _ = attributes_to_update + _ = student_id _ = organisation_id # TODO: We should make Faraday raise a Ruby error for a non-2xx status diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index ed1222cd0..e0e6724c6 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -13,27 +13,27 @@ def stub_profile_api_invite_school_owner allow(ProfileApiClient).to receive(:invite_school_owner) end - def stub_profile_api_invite_school_teacher - allow(ProfileApiClient).to receive(:invite_school_teacher) - end - - def stub_profile_api_create_school_student(user_id:) - allow(ProfileApiClient).to receive(:create_school_student).and_return(id: user_id) - end - def stub_profile_api_remove_school_owner allow(ProfileApiClient).to receive(:remove_school_owner) end + def stub_profile_api_invite_school_teacher + allow(ProfileApiClient).to receive(:invite_school_teacher) + end + def stub_profile_api_remove_school_teacher allow(ProfileApiClient).to receive(:remove_school_teacher) end - def stub_profile_api_delete_school_student - allow(ProfileApiClient).to receive(:delete_school_student) + def stub_profile_api_create_school_student(user_id:) + allow(ProfileApiClient).to receive(:create_school_student).and_return(id: user_id) end def stub_profile_api_update_school_student allow(ProfileApiClient).to receive(:update_school_student) end + + def stub_profile_api_delete_school_student + allow(ProfileApiClient).to receive(:delete_school_student) + end end From c427e7c8c03c90af269e5535f06918e1a4c4b2d8 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 13:16:48 +0000 Subject: [PATCH 079/124] Add a SchoolOwners#index controller action --- .../api/school_owners_controller.rb | 11 ++++ app/models/ability.rb | 3 +- .../api/school_owners/index.json.jbuilder | 10 ++++ config/routes.rb | 2 +- lib/concepts/school_owner/list.rb | 26 +++++++++ lib/profile_api_client.rb | 18 +++++++ spec/concepts/school_owner/list_spec.rb | 49 +++++++++++++++++ .../listing_school_owners_spec.rb | 54 +++++++++++++++++++ spec/support/profile_api_mock.rb | 4 ++ 9 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 app/views/api/school_owners/index.json.jbuilder create mode 100644 lib/concepts/school_owner/list.rb create mode 100644 spec/concepts/school_owner/list_spec.rb create mode 100644 spec/features/school_owner/listing_school_owners_spec.rb diff --git a/app/controllers/api/school_owners_controller.rb b/app/controllers/api/school_owners_controller.rb index a482e342f..f7c533737 100644 --- a/app/controllers/api/school_owners_controller.rb +++ b/app/controllers/api/school_owners_controller.rb @@ -6,6 +6,17 @@ class SchoolOwnersController < ApiController load_and_authorize_resource :school authorize_resource :school_owner, class: false + def index + result = SchoolOwner::List.call(school: @school, token: current_user.token) + + if result.success? + @school_owners = result[:school_owners] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + def create result = SchoolOwner::Invite.call(school: @school, school_owner_params:, token: current_user.token) diff --git a/app/models/ability.rb b/app/models/ability.rb index 8fcaad760..5021f3cd8 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -20,7 +20,7 @@ def initialize(user) can(%i[read update], School, id: organisation_id) can(%i[read create update], SchoolClass, school: { id: organisation_id }) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) - can(%i[create destroy], :school_owner) + can(%i[read create destroy], :school_owner) can(%i[create destroy], :school_teacher) can(%i[create update destroy], :school_student) end @@ -30,6 +30,7 @@ def initialize(user) can(%i[create], SchoolClass, school: { id: organisation_id }) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) + can(%i[read], :school_owner) can(%i[create update], :school_student) end diff --git a/app/views/api/school_owners/index.json.jbuilder b/app/views/api/school_owners/index.json.jbuilder new file mode 100644 index 000000000..9e67792f4 --- /dev/null +++ b/app/views/api/school_owners/index.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.array!(@school_owners) do |owner| + json.call( + owner, + :id, + :email, + :name + ) +end diff --git a/config/routes.rb b/config/routes.rb index 07ee9d1d4..050dad069 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,7 +22,7 @@ resources :members, only: %i[index create], controller: 'class_members' end - resources :owners, only: %i[create destroy], controller: 'school_owners' + resources :owners, only: %i[index create destroy], controller: 'school_owners' resources :teachers, only: %i[create destroy], controller: 'school_teachers' resources :students, only: %i[create update destroy], controller: 'school_students' end diff --git a/lib/concepts/school_owner/list.rb b/lib/concepts/school_owner/list.rb new file mode 100644 index 000000000..f8941a29a --- /dev/null +++ b/lib/concepts/school_owner/list.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SchoolOwner + class List + class << self + def call(school:, token:) + response = OperationResponse.new + response[:school_owners] = list_owners(school, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing school owners: #{e}" + response + end + + private + + def list_owners(school, token) + response = ProfileApiClient.list_school_owners(token:, organisation_id: school.id) + user_ids = response.fetch(:ids) + + User.from_userinfo(ids: user_ids) + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index e267866a9..5f751c719 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -20,6 +20,24 @@ def create_organisation(token:) response.deep_symbolize_keys end + # The API should enforce these constraints: + # - The token has the school-owner or school-teacher role for the given organisation ID + # - The token user or given user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 422 Unprocessable if the constraints are not met + def list_school_owners(token:, organisation_id:) + return [] if token.blank? + + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Invite propagates the error in the response. + response = { 'ids' => ['99999999-9999-9999-9999-999999999999'] } + response.deep_symbolize_keys + end + # The API should enforce these constraints: # - The token has the school-owner role for the given organisation ID # - The token user or given user should not be under 13 diff --git a/spec/concepts/school_owner/list_spec.rb b/spec/concepts/school_owner/list_spec.rb new file mode 100644 index 000000000..aa38000a2 --- /dev/null +++ b/spec/concepts/school_owner/list_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolOwner::List, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + before do + stub_profile_api_list_school_owners(user_id: owner_id) + stub_user_info_api + end + + it 'makes a profile API call' do + described_class.call(school:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:list_school_owners).with(token:, organisation_id: school.id) + end + + it 'returns the school owners in the operation response' do + response = described_class.call(school:, token:) + expect(response[:school_owners].first).to be_a(User) + end + + context 'when listing fails' do + before do + allow(ProfileApiClient).to receive(:list_school_owners).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_owner/listing_school_owners_spec.rb b/spec/features/school_owner/listing_school_owners_spec.rb new file mode 100644 index 000000000..8d1db0555 --- /dev/null +++ b/spec/features/school_owner/listing_school_owners_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school owners', type: :request do + before do + stub_hydra_public_api + stub_profile_api_list_school_owners(user_id: owner_id) + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school owners JSON' do + get("/api/schools/#{school.id}/owners", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('School Owner') + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/owners" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get("/api/schools/#{school.id}/owners", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index e0e6724c6..2a73bdc18 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -9,6 +9,10 @@ def stub_profile_api_create_organisation(organisation_id: ORGANISATION_ID) allow(ProfileApiClient).to receive(:create_organisation).and_return(id: organisation_id) end + def stub_profile_api_list_school_owners(user_id:) + allow(ProfileApiClient).to receive(:list_school_owners).and_return(ids: [user_id]) + end + def stub_profile_api_invite_school_owner allow(ProfileApiClient).to receive(:invite_school_owner) end From 246444c57ebcb1d1eb8575691990840f878d99b2 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 13:22:40 +0000 Subject: [PATCH 080/124] Add a SchoolTeachers#index controller action --- .../api/school_teachers_controller.rb | 11 ++++ app/models/ability.rb | 3 +- .../api/school_teachers/index.json.jbuilder | 10 ++++ config/routes.rb | 2 +- lib/concepts/school_teacher/list.rb | 26 +++++++++ lib/profile_api_client.rb | 18 +++++++ spec/concepts/school_teacher/list_spec.rb | 49 +++++++++++++++++ .../listing_school_teachers_spec.rb | 54 +++++++++++++++++++ spec/support/profile_api_mock.rb | 4 ++ 9 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 app/views/api/school_teachers/index.json.jbuilder create mode 100644 lib/concepts/school_teacher/list.rb create mode 100644 spec/concepts/school_teacher/list_spec.rb create mode 100644 spec/features/school_teacher/listing_school_teachers_spec.rb diff --git a/app/controllers/api/school_teachers_controller.rb b/app/controllers/api/school_teachers_controller.rb index f61a573b2..d7994495c 100644 --- a/app/controllers/api/school_teachers_controller.rb +++ b/app/controllers/api/school_teachers_controller.rb @@ -6,6 +6,17 @@ class SchoolTeachersController < ApiController load_and_authorize_resource :school authorize_resource :school_teacher, class: false + def index + result = SchoolTeacher::List.call(school: @school, token: current_user.token) + + if result.success? + @school_teachers = result[:school_teachers] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + def create result = SchoolTeacher::Invite.call(school: @school, school_teacher_params:, token: current_user.token) diff --git a/app/models/ability.rb b/app/models/ability.rb index 5021f3cd8..6e74ba1f7 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -21,7 +21,7 @@ def initialize(user) can(%i[read create update], SchoolClass, school: { id: organisation_id }) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[read create destroy], :school_owner) - can(%i[create destroy], :school_teacher) + can(%i[read create destroy], :school_teacher) can(%i[create update destroy], :school_student) end @@ -31,6 +31,7 @@ def initialize(user) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) can(%i[read], :school_owner) + can(%i[read], :school_teacher) can(%i[create update], :school_student) end diff --git a/app/views/api/school_teachers/index.json.jbuilder b/app/views/api/school_teachers/index.json.jbuilder new file mode 100644 index 000000000..d1881a381 --- /dev/null +++ b/app/views/api/school_teachers/index.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.array!(@school_teachers) do |teacher| + json.call( + teacher, + :id, + :email, + :name + ) +end diff --git a/config/routes.rb b/config/routes.rb index 050dad069..6f40b6499 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,7 +23,7 @@ end resources :owners, only: %i[index create destroy], controller: 'school_owners' - resources :teachers, only: %i[create destroy], controller: 'school_teachers' + resources :teachers, only: %i[index create destroy], controller: 'school_teachers' resources :students, only: %i[create update destroy], controller: 'school_students' end end diff --git a/lib/concepts/school_teacher/list.rb b/lib/concepts/school_teacher/list.rb new file mode 100644 index 000000000..dfefc5536 --- /dev/null +++ b/lib/concepts/school_teacher/list.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SchoolTeacher + class List + class << self + def call(school:, token:) + response = OperationResponse.new + response[:school_teachers] = list_teachers(school, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing school teachers: #{e}" + response + end + + private + + def list_teachers(school, token) + response = ProfileApiClient.list_school_teachers(token:, organisation_id: school.id) + user_ids = response.fetch(:ids) + + User.from_userinfo(ids: user_ids) + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 5f751c719..65ec2b4ce 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -78,6 +78,24 @@ def remove_school_owner(token:, owner_id:, organisation_id:) response.deep_symbolize_keys end + # The API should enforce these constraints: + # - The token has the school-owner or school-teacher role for the given organisation ID + # - The token user or given user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 422 Unprocessable if the constraints are not met + def list_school_teachers(token:, organisation_id:) + return [] if token.blank? + + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Invite propagates the error in the response. + response = { 'ids' => ['99999999-9999-9999-9999-999999999999'] } + response.deep_symbolize_keys + end + # The API should enforce these constraints: # - The token has the school-owner role for the given organisation ID # - The token user or given user should not be under 13 diff --git a/spec/concepts/school_teacher/list_spec.rb b/spec/concepts/school_teacher/list_spec.rb new file mode 100644 index 000000000..d6a138089 --- /dev/null +++ b/spec/concepts/school_teacher/list_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolTeacher::List, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + before do + stub_profile_api_list_school_teachers(user_id: teacher_id) + stub_user_info_api + end + + it 'makes a profile API call' do + described_class.call(school:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:list_school_teachers).with(token:, organisation_id: school.id) + end + + it 'returns the school teachers in the operation response' do + response = described_class.call(school:, token:) + expect(response[:school_teachers].first).to be_a(User) + end + + context 'when listing fails' do + before do + allow(ProfileApiClient).to receive(:list_school_teachers).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_teacher/listing_school_teachers_spec.rb b/spec/features/school_teacher/listing_school_teachers_spec.rb new file mode 100644 index 000000000..96fbb8c36 --- /dev/null +++ b/spec/features/school_teacher/listing_school_teachers_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school teachers', type: :request do + before do + stub_hydra_public_api + stub_profile_api_list_school_teachers(user_id: teacher_id) + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school teachers JSON' do + get("/api/schools/#{school.id}/teachers", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('School Teacher') + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/teachers" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get("/api/schools/#{school.id}/teachers", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 2a73bdc18..3be29ff73 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -25,6 +25,10 @@ def stub_profile_api_invite_school_teacher allow(ProfileApiClient).to receive(:invite_school_teacher) end + def stub_profile_api_list_school_teachers(user_id:) + allow(ProfileApiClient).to receive(:list_school_teachers).and_return(ids: [user_id]) + end + def stub_profile_api_remove_school_teacher allow(ProfileApiClient).to receive(:remove_school_teacher) end From 2c6ece68f4e400a50272d288ff4eb2fe251abf40 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 13:39:35 +0000 Subject: [PATCH 081/124] Add a SchoolStudents#index controller action --- .../api/school_students_controller.rb | 11 ++++ app/models/ability.rb | 4 +- .../api/school_students/index.json.jbuilder | 10 ++++ config/routes.rb | 2 +- lib/concepts/school_student/list.rb | 26 +++++++++ lib/profile_api_client.rb | 18 +++++++ spec/concepts/school_student/list_spec.rb | 49 +++++++++++++++++ .../listing_school_students_spec.rb | 54 +++++++++++++++++++ spec/support/profile_api_mock.rb | 4 ++ 9 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 app/views/api/school_students/index.json.jbuilder create mode 100644 lib/concepts/school_student/list.rb create mode 100644 spec/concepts/school_student/list_spec.rb create mode 100644 spec/features/school_student/listing_school_students_spec.rb diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb index 153f6afce..d2b8a399e 100644 --- a/app/controllers/api/school_students_controller.rb +++ b/app/controllers/api/school_students_controller.rb @@ -6,6 +6,17 @@ class SchoolStudentsController < ApiController load_and_authorize_resource :school authorize_resource :school_student, class: false + def index + result = SchoolStudent::List.call(school: @school, token: current_user.token) + + if result.success? + @school_students = result[:school_students] + render :index, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + def create result = SchoolStudent::Create.call(school: @school, school_student_params:, token: current_user.token) diff --git a/app/models/ability.rb b/app/models/ability.rb index 6e74ba1f7..f0db889cb 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -22,7 +22,7 @@ def initialize(user) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) - can(%i[create update destroy], :school_student) + can(%i[read create update destroy], :school_student) end if user.school_teacher?(organisation_id:) @@ -32,7 +32,7 @@ def initialize(user) can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) can(%i[read], :school_owner) can(%i[read], :school_teacher) - can(%i[create update], :school_student) + can(%i[read create update], :school_student) end if user.school_student?(organisation_id:) diff --git a/app/views/api/school_students/index.json.jbuilder b/app/views/api/school_students/index.json.jbuilder new file mode 100644 index 000000000..ebb7c14e0 --- /dev/null +++ b/app/views/api/school_students/index.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.array!(@school_students) do |student| + json.call( + student, + :id, + :username, + :name + ) +end diff --git a/config/routes.rb b/config/routes.rb index 6f40b6499..48037afbe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,7 @@ resources :owners, only: %i[index create destroy], controller: 'school_owners' resources :teachers, only: %i[index create destroy], controller: 'school_teachers' - resources :students, only: %i[create update destroy], controller: 'school_students' + resources :students, only: %i[index create update destroy], controller: 'school_students' end end diff --git a/lib/concepts/school_student/list.rb b/lib/concepts/school_student/list.rb new file mode 100644 index 000000000..b8d0a9245 --- /dev/null +++ b/lib/concepts/school_student/list.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SchoolStudent + class List + class << self + def call(school:, token:) + response = OperationResponse.new + response[:school_students] = list_students(school, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error listing school students: #{e}" + response + end + + private + + def list_students(school, token) + response = ProfileApiClient.list_school_students(token:, organisation_id: school.id) + user_ids = response.fetch(:ids) + + User.from_userinfo(ids: user_ids) + end + end + end +end diff --git a/lib/profile_api_client.rb b/lib/profile_api_client.rb index 65ec2b4ce..b3b369e68 100644 --- a/lib/profile_api_client.rb +++ b/lib/profile_api_client.rb @@ -136,6 +136,24 @@ def remove_school_teacher(token:, teacher_id:, organisation_id:) response.deep_symbolize_keys end + # The API should enforce these constraints: + # - The token has the school-owner or school-teacher role for the given organisation ID + # - The token user or given user should not be under 13 + # - The email must be verified + # + # The API should respond: + # - 422 Unprocessable if the constraints are not met + def list_school_students(token:, organisation_id:) + return [] if token.blank? + + _ = organisation_id + + # TODO: We should make Faraday raise a Ruby error for a non-2xx status + # code so that SchoolOwner::Invite propagates the error in the response. + response = { 'ids' => ['99999999-9999-9999-9999-999999999999'] } + response.deep_symbolize_keys + end + # The API should enforce these constraints: # - The token has the school-owner or school-teacher role for the given organisation ID # - The token user should not be under 13 diff --git a/spec/concepts/school_student/list_spec.rb b/spec/concepts/school_student/list_spec.rb new file mode 100644 index 000000000..25088df2d --- /dev/null +++ b/spec/concepts/school_student/list_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::List, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + before do + stub_profile_api_list_school_students(user_id: student_id) + stub_user_info_api + end + + it 'makes a profile API call' do + described_class.call(school:, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:list_school_students).with(token:, organisation_id: school.id) + end + + it 'returns the school students in the operation response' do + response = described_class.call(school:, token:) + expect(response[:school_students].first).to be_a(User) + end + + context 'when listing fails' do + before do + allow(ProfileApiClient).to receive(:list_school_students).and_raise('Some API error') + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, token:) + expect(response[:error]).to match(/Some API error/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_student/listing_school_students_spec.rb b/spec/features/school_student/listing_school_students_spec.rb new file mode 100644 index 000000000..45752d4ec --- /dev/null +++ b/spec/features/school_student/listing_school_students_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing school students', type: :request do + before do + stub_hydra_public_api + stub_profile_api_list_school_students(user_id: student_id) + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the school students JSON' do + get("/api/schools/#{school.id}/students", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('School Student') + end + + it 'responds 401 Unauthorized when no token is given' do + get "/api/schools/#{school.id}/students" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get("/api/schools/#{school.id}/students", headers:) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 3be29ff73..8d3f85e5e 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -33,6 +33,10 @@ def stub_profile_api_remove_school_teacher allow(ProfileApiClient).to receive(:remove_school_teacher) end + def stub_profile_api_list_school_students(user_id:) + allow(ProfileApiClient).to receive(:list_school_students).and_return(ids: [user_id]) + end + def stub_profile_api_create_school_student(user_id:) allow(ProfileApiClient).to receive(:create_school_student).and_return(id: user_id) end From df35bf30cc9f10a81e0ec3db5fac05c38d98fe21 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 14:13:24 +0000 Subject: [PATCH 082/124] Add a ClassMembers#destroy controller action --- .../api/class_members_controller.rb | 10 ++++ app/models/ability.rb | 8 +-- config/routes.rb | 2 +- .../class_member/operations/delete.rb | 24 ++++++++ spec/concepts/class_member/delete_spec.rb | 41 +++++++++++++ .../deleting_a_class_member_spec.rb | 57 +++++++++++++++++++ 6 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 lib/concepts/class_member/operations/delete.rb create mode 100644 spec/concepts/class_member/delete_spec.rb create mode 100644 spec/features/class_member/deleting_a_class_member_spec.rb diff --git a/app/controllers/api/class_members_controller.rb b/app/controllers/api/class_members_controller.rb index 96530c3c6..91140cf14 100644 --- a/app/controllers/api/class_members_controller.rb +++ b/app/controllers/api/class_members_controller.rb @@ -23,6 +23,16 @@ def create end end + def destroy + result = ClassMember::Delete.call(school_class: @school_class, class_member_id: params[:id]) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def class_member_params diff --git a/app/models/ability.rb b/app/models/ability.rb index f0db889cb..5d3c27f90 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -3,7 +3,7 @@ class Ability include CanCan::Ability - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Layout/LineLength def initialize(user) can :show, Project, user_id: nil can :show, Component, project: { user_id: nil } @@ -19,7 +19,7 @@ def initialize(user) if user.school_owner?(organisation_id:) can(%i[read update], School, id: organisation_id) can(%i[read create update], SchoolClass, school: { id: organisation_id }) - can(%i[read create], ClassMember, school_class: { school: { id: organisation_id } }) + can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) can(%i[read create update destroy], :school_student) @@ -29,7 +29,7 @@ def initialize(user) can(%i[read], School, id: organisation_id) can(%i[create], SchoolClass, school: { id: organisation_id }) can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) - can(%i[read create], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) + can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) can(%i[read], :school_owner) can(%i[read], :school_teacher) can(%i[read create update], :school_student) @@ -41,5 +41,5 @@ def initialize(user) end end end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Layout/LineLength end diff --git a/config/routes.rb b/config/routes.rb index 48037afbe..e2ae9e9c2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,7 +19,7 @@ resources :schools, only: %i[index show create update] do resources :classes, only: %i[index show create update], controller: 'school_classes' do - resources :members, only: %i[index create], controller: 'class_members' + resources :members, only: %i[index create destroy], controller: 'class_members' end resources :owners, only: %i[index create destroy], controller: 'school_owners' diff --git a/lib/concepts/class_member/operations/delete.rb b/lib/concepts/class_member/operations/delete.rb new file mode 100644 index 000000000..23d99c6b5 --- /dev/null +++ b/lib/concepts/class_member/operations/delete.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ClassMember + class Delete + class << self + def call(school_class:, class_member_id:) + response = OperationResponse.new + delete_class_member(school_class, class_member_id) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting class member: #{e}" + response + end + + private + + def delete_class_member(school_class, class_member_id) + class_member = school_class.members.find(class_member_id) + class_member.destroy! + end + end + end +end diff --git a/spec/concepts/class_member/delete_spec.rb b/spec/concepts/class_member/delete_spec.rb new file mode 100644 index 000000000..1ac9aae41 --- /dev/null +++ b/spec/concepts/class_member/delete_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClassMember::Delete, type: :unit do + before do + stub_user_info_api + end + + let!(:class_member) { create(:class_member) } + let(:class_member_id) { class_member.id } + let(:school_class) { class_member.school_class } + let(:school) { school_class.school } + + it 'deletes a class member' do + expect { described_class.call(school_class:, class_member_id:) }.to change(ClassMember, :count).by(-1) + end + + context 'when deletion fails' do + let(:class_member_id) { 'does-not-exist' } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school_class:, class_member_id:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_class:, class_member_id:) + expect(response[:error]).to match(/does-not-exist/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_class:, class_member_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/class_member/deleting_a_class_member_spec.rb b/spec/features/class_member/deleting_a_class_member_spec.rb new file mode 100644 index 000000000..597d233fd --- /dev/null +++ b/spec/features/class_member/deleting_a_class_member_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a class member', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:class_member) { create(:class_member) } + let(:school_class) { class_member.school_class } + let(:school) { school_class.school } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is the class teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + school_class.update!(teacher_id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}/members/#{class_member.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end From e25b7059711b04198c030fabcc985ae247b8490d Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 15:28:24 +0000 Subject: [PATCH 083/124] Add a SchoolClasses#destroy controller action --- .../api/school_classes_controller.rb | 10 ++++ app/models/ability.rb | 4 +- config/routes.rb | 2 +- .../school_class/operations/delete.rb | 24 +++++++++ spec/concepts/school_class/delete_spec.rb | 45 ++++++++++++++++ .../deleting_a_school_class_spec.rb | 54 +++++++++++++++++++ 6 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 lib/concepts/school_class/operations/delete.rb create mode 100644 spec/concepts/school_class/delete_spec.rb create mode 100644 spec/features/school_class/deleting_a_school_class_spec.rb diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index f2eb042e5..20ab093c3 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -39,6 +39,16 @@ def update end end + def destroy + result = SchoolClass::Delete.call(school: @school, school_class_id: params[:id]) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def school_class_params diff --git a/app/models/ability.rb b/app/models/ability.rb index 5d3c27f90..3cb37b981 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -18,7 +18,7 @@ def initialize(user) user.organisation_ids.each do |organisation_id| if user.school_owner?(organisation_id:) can(%i[read update], School, id: organisation_id) - can(%i[read create update], SchoolClass, school: { id: organisation_id }) + can(%i[read create update destroy], SchoolClass, school: { id: organisation_id }) can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) @@ -28,7 +28,7 @@ def initialize(user) if user.school_teacher?(organisation_id:) can(%i[read], School, id: organisation_id) can(%i[create], SchoolClass, school: { id: organisation_id }) - can(%i[read update], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) + can(%i[read update destroy], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) can(%i[read], :school_owner) can(%i[read], :school_teacher) diff --git a/config/routes.rb b/config/routes.rb index e2ae9e9c2..13b9b7587 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,7 @@ resource :project_errors, only: %i[create] resources :schools, only: %i[index show create update] do - resources :classes, only: %i[index show create update], controller: 'school_classes' do + resources :classes, only: %i[index show create update destroy], controller: 'school_classes' do resources :members, only: %i[index create destroy], controller: 'class_members' end diff --git a/lib/concepts/school_class/operations/delete.rb b/lib/concepts/school_class/operations/delete.rb new file mode 100644 index 000000000..f189f9caf --- /dev/null +++ b/lib/concepts/school_class/operations/delete.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class SchoolClass + class Delete + class << self + def call(school:, school_class_id:) + response = OperationResponse.new + delete_school_class(school, school_class_id) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting school class: #{e}" + response + end + + private + + def delete_school_class(school, school_class_id) + school_class = school.classes.find(school_class_id) + school_class.destroy! + end + end + end +end diff --git a/spec/concepts/school_class/delete_spec.rb b/spec/concepts/school_class/delete_spec.rb new file mode 100644 index 000000000..9f4cd54bc --- /dev/null +++ b/spec/concepts/school_class/delete_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolClass::Delete, type: :unit do + before do + stub_user_info_api + end + + let!(:class_member) { create(:class_member) } + let(:school_class) { class_member.school_class } + let(:school_class_id) { school_class.id } + let(:school) { school_class.school } + + it 'deletes a school class' do + expect { described_class.call(school:, school_class_id:) }.to change(SchoolClass, :count).by(-1) + end + + it 'deletes class members in the school class' do + expect { described_class.call(school:, school_class_id:) }.to change(ClassMember, :count).by(-1) + end + + context 'when deletion fails' do + let(:school_class_id) { 'does-not-exist' } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, school_class_id:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, school_class_id:) + expect(response[:error]).to match(/does-not-exist/) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, school_class_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school_class/deleting_a_school_class_spec.rb b/spec/features/school_class/deleting_a_school_class_spec.rb new file mode 100644 index 000000000..c6c227cb5 --- /dev/null +++ b/spec/features/school_class/deleting_a_school_class_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a school class', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class) } + let(:school) { school_class.school } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is the class teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}/classes/#{school_class.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + school_class.update!(teacher_id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + delete("/api/schools/#{school.id}/classes/#{school_class.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end From ad6ef7df5ba15858b00bb68178e1029f1e621b7f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 15:39:08 +0000 Subject: [PATCH 084/124] Add a School#destroy controller action --- app/controllers/api/schools_controller.rb | 10 ++++ app/models/ability.rb | 2 +- config/routes.rb | 2 +- lib/concepts/school/operations/delete.rb | 24 +++++++++ spec/concepts/school/delete_spec.rb | 49 +++++++++++++++++++ .../features/school/deleting_a_school_spec.rb | 44 +++++++++++++++++ 6 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 lib/concepts/school/operations/delete.rb create mode 100644 spec/concepts/school/delete_spec.rb create mode 100644 spec/features/school/deleting_a_school_spec.rb diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 4ce562c8e..7680ce68f 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -37,6 +37,16 @@ def update end end + def destroy + result = School::Delete.call(school_id: params[:id]) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def school_params diff --git a/app/models/ability.rb b/app/models/ability.rb index 3cb37b981..cbd9a3542 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -17,7 +17,7 @@ def initialize(user) user.organisation_ids.each do |organisation_id| if user.school_owner?(organisation_id:) - can(%i[read update], School, id: organisation_id) + can(%i[read update destroy], School, id: organisation_id) can(%i[read create update destroy], SchoolClass, school: { id: organisation_id }) can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[read create destroy], :school_owner) diff --git a/config/routes.rb b/config/routes.rb index 13b9b7587..43e822fe9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,7 +17,7 @@ resource :project_errors, only: %i[create] - resources :schools, only: %i[index show create update] do + resources :schools, only: %i[index show create update destroy] do resources :classes, only: %i[index show create update destroy], controller: 'school_classes' do resources :members, only: %i[index create destroy], controller: 'class_members' end diff --git a/lib/concepts/school/operations/delete.rb b/lib/concepts/school/operations/delete.rb new file mode 100644 index 000000000..24b49f7f8 --- /dev/null +++ b/lib/concepts/school/operations/delete.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class School + class Delete + class << self + def call(school_id:) + response = OperationResponse.new + delete_school(school_id) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error deleting school: #{e}" + response + end + + private + + def delete_school(school_id) + school = School.find(school_id) + school.destroy! + end + end + end +end diff --git a/spec/concepts/school/delete_spec.rb b/spec/concepts/school/delete_spec.rb new file mode 100644 index 000000000..67f13c0ea --- /dev/null +++ b/spec/concepts/school/delete_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe School::Delete, type: :unit do + before do + stub_user_info_api + end + + let!(:class_member) { create(:class_member) } + let(:school_class) { class_member.school_class } + let(:school) { school_class.school } + let(:school_id) { school.id } + + it 'deletes a school' do + expect { described_class.call(school_id:) }.to change(School, :count).by(-1) + end + + it 'deletes a school classes in the school' do + expect { described_class.call(school_id:) }.to change(SchoolClass, :count).by(-1) + end + + it 'deletes class members in the school' do + expect { described_class.call(school_id:) }.to change(ClassMember, :count).by(-1) + end + + context 'when deletion fails' do + let(:school_id) { 'does-not-exist' } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'returns a failed operation response' do + response = described_class.call(school_id:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school_id:) + expect(response[:error]).to match(/does-not-exist/) + end + + it 'sent the exception to Sentry' do + described_class.call(school_id:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/school/deleting_a_school_spec.rb b/spec/features/school/deleting_a_school_spec.rb new file mode 100644 index 000000000..038648f79 --- /dev/null +++ b/spec/features/school/deleting_a_school_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting a school', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + + it 'responds 204 No Content' do + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/schools/#{school.id}" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + delete("/api/schools/#{school.id}", headers:) + expect(response).to have_http_status(:forbidden) + end +end From fe278c46807de8bdec5fc2ccc0ae2a90cde3de50 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 15 Feb 2024 18:10:11 +0000 Subject: [PATCH 085/124] =?UTF-8?q?Add=20the=20=E2=80=98roo=E2=80=99=20gem?= =?UTF-8?q?=20for=20parsing=20spreadsheets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 1 + Gemfile.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/Gemfile b/Gemfile index 1afcb5b25..00bdcea13 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,7 @@ gem 'pg', '~> 1.1' gem 'puma', '~> 5.6' gem 'rack-cors' gem 'rails', '~> 7.0.0' +gem 'roo' gem 'scout_apm' gem 'sentry-rails', '~> 5.5.0' diff --git a/Gemfile.lock b/Gemfile.lock index 435c5aaf0..0b9b9fcb4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -254,6 +254,9 @@ GEM rake (13.0.6) regexp_parser (2.7.0) rexml (3.2.5) + roo (2.10.1) + nokogiri (~> 1) + rubyzip (>= 1.3.0, < 3.0.0) rspec (3.12.0) rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) @@ -302,6 +305,7 @@ GEM rubocop-capybara (~> 2.17) ruby-progressbar (1.11.0) ruby2_keywords (0.0.5) + rubyzip (2.3.2) scout_apm (5.3.5) parser sentry-rails (5.5.0) @@ -377,6 +381,7 @@ DEPENDENCIES puma (~> 5.6) rack-cors rails (~> 7.0.0) + roo rspec rspec-rails rspec_junit_formatter From 5e5901da69182a26422f3f696b500529ba4be0f1 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 17 Feb 2024 13:43:15 +0000 Subject: [PATCH 086/124] Add a SchoolStudents#create_batch controller action --- .../api/school_students_controller.rb | 10 ++ app/models/ability.rb | 4 +- config/routes.rb | 4 +- lib/concepts/school_student/create_batch.rb | 64 +++++++++++ spec/concepts/class_member/create_spec.rb | 5 + spec/concepts/class_member/delete_spec.rb | 5 + spec/concepts/school/create_spec.rb | 5 + spec/concepts/school/delete_spec.rb | 5 + spec/concepts/school/update_spec.rb | 5 + spec/concepts/school_class/create_spec.rb | 5 + spec/concepts/school_class/delete_spec.rb | 5 + spec/concepts/school_class/update_spec.rb | 5 + spec/concepts/school_owner/invite_spec.rb | 5 + spec/concepts/school_owner/list_spec.rb | 5 + spec/concepts/school_owner/remove_spec.rb | 5 + .../school_student/create_batch_spec.rb | 104 ++++++++++++++++++ spec/concepts/school_student/create_spec.rb | 9 +- spec/concepts/school_student/delete_spec.rb | 5 + spec/concepts/school_student/list_spec.rb | 5 + spec/concepts/school_student/update_spec.rb | 5 + spec/concepts/school_teacher/invite_spec.rb | 5 + spec/concepts/school_teacher/list_spec.rb | 5 + spec/concepts/school_teacher/remove_spec.rb | 5 + ...reating_a_batch_of_school_students_spec.rb | 53 +++++++++ .../creating_a_school_student_spec.rb | 4 +- spec/fixtures/files/students-invalid.csv | 9 ++ spec/fixtures/files/students.csv | 3 + spec/fixtures/files/students.xlsx | Bin 0 -> 6396 bytes spec/support/profile_api_mock.rb | 4 +- 29 files changed, 342 insertions(+), 11 deletions(-) create mode 100644 lib/concepts/school_student/create_batch.rb create mode 100644 spec/concepts/school_student/create_batch_spec.rb create mode 100644 spec/features/school_student/creating_a_batch_of_school_students_spec.rb create mode 100644 spec/fixtures/files/students-invalid.csv create mode 100644 spec/fixtures/files/students.csv create mode 100644 spec/fixtures/files/students.xlsx diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb index d2b8a399e..4faec95b9 100644 --- a/app/controllers/api/school_students_controller.rb +++ b/app/controllers/api/school_students_controller.rb @@ -27,6 +27,16 @@ def create end end + def create_batch + result = SchoolStudent::CreateBatch.call(school: @school, uploaded_file: params[:file], token: current_user.token) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + def update result = SchoolStudent::Update.call(school: @school, school_student_params:, token: current_user.token) diff --git a/app/models/ability.rb b/app/models/ability.rb index cbd9a3542..5f87d13af 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -22,7 +22,7 @@ def initialize(user) can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } }) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) - can(%i[read create update destroy], :school_student) + can(%i[read create create_batch update destroy], :school_student) end if user.school_teacher?(organisation_id:) @@ -32,7 +32,7 @@ def initialize(user) can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) can(%i[read], :school_owner) can(%i[read], :school_teacher) - can(%i[read create update], :school_student) + can(%i[read create create_batch update], :school_student) end if user.school_student?(organisation_id:) diff --git a/config/routes.rb b/config/routes.rb index 43e822fe9..94b904f74 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,9 @@ resources :owners, only: %i[index create destroy], controller: 'school_owners' resources :teachers, only: %i[index create destroy], controller: 'school_teachers' - resources :students, only: %i[index create update destroy], controller: 'school_students' + resources :students, only: %i[index create update destroy], controller: 'school_students' do + post :batch, on: :collection, to: 'school_students#create_batch' + end end end diff --git a/lib/concepts/school_student/create_batch.rb b/lib/concepts/school_student/create_batch.rb new file mode 100644 index 000000000..ef7ff6b66 --- /dev/null +++ b/lib/concepts/school_student/create_batch.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'roo' + +module SchoolStudent + class CreateBatch + class << self + def call(school:, uploaded_file:, token:) + response = OperationResponse.new + create_batch(school, uploaded_file, token) + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error creating school students: #{e}" + response + end + + private + + def create_batch(school, uploaded_file, token) + sheet = Roo::Spreadsheet.open(uploaded_file.tempfile).sheet(0) + + validate(school:, sheet:) + + non_header_rows_with_content(sheet:).each do |name, username, password| + ProfileApiClient.create_school_student(token:, username:, password:, name:, organisation_id: school.id) + end + end + + def validate(school:, sheet:) + expected_header = ['Student Name', 'Username', 'Password'] + + raise ArgumentError, 'school is not verified' unless school.verified_at + raise ArgumentError, 'the spreadsheet header row is invalid' unless sheet.row(1) == expected_header + + @errors = [] + + non_header_rows_with_content(sheet:).each do |name, username, password| + validate_row(name:, username:, password:) + end + + raise ArgumentError, @errors.join(', ') if @errors.any? + end + + def validate_row(name:, username:, password:) + @errors.push("name '#{name}' is invalid") if name.blank? + @errors.push("username '#{username}' is invalid") if username.blank? + @errors.push("password '#{password}' is invalid") if password.blank? || password.size < 8 + end + + def non_header_rows_with_content(sheet:) + Enumerator.new do |yielder| + (2..sheet.last_row).each do |i| + name, username, password = sheet.row(i) + + next if name.blank? && username.blank? && password.blank? + + yielder.yield [name, username, password] + end + end + end + end + end +end diff --git a/spec/concepts/class_member/create_spec.rb b/spec/concepts/class_member/create_spec.rb index 13485fcdb..295f2dbd8 100644 --- a/spec/concepts/class_member/create_spec.rb +++ b/spec/concepts/class_member/create_spec.rb @@ -16,6 +16,11 @@ { student_id: } end + it 'returns a successful operation response' do + response = described_class.call(school_class:, class_member_params:) + expect(response.success?).to be(true) + end + it 'creates a school class' do expect { described_class.call(school_class:, class_member_params:) }.to change(ClassMember, :count).by(1) end diff --git a/spec/concepts/class_member/delete_spec.rb b/spec/concepts/class_member/delete_spec.rb index 1ac9aae41..3193a664d 100644 --- a/spec/concepts/class_member/delete_spec.rb +++ b/spec/concepts/class_member/delete_spec.rb @@ -12,6 +12,11 @@ let(:school_class) { class_member.school_class } let(:school) { school_class.school } + it 'returns a successful operation response' do + response = described_class.call(school_class:, class_member_id:) + expect(response.success?).to be(true) + end + it 'deletes a class member' do expect { described_class.call(school_class:, class_member_id:) }.to change(ClassMember, :count).by(-1) end diff --git a/spec/concepts/school/create_spec.rb b/spec/concepts/school/create_spec.rb index f960f862e..a23535f96 100644 --- a/spec/concepts/school/create_spec.rb +++ b/spec/concepts/school/create_spec.rb @@ -19,6 +19,11 @@ stub_profile_api_create_organisation end + it 'returns a successful operation response' do + response = described_class.call(school_params:, token:) + expect(response.success?).to be(true) + end + it 'creates a school' do expect { described_class.call(school_params:, token:) }.to change(School, :count).by(1) end diff --git a/spec/concepts/school/delete_spec.rb b/spec/concepts/school/delete_spec.rb index 67f13c0ea..0fcf4d963 100644 --- a/spec/concepts/school/delete_spec.rb +++ b/spec/concepts/school/delete_spec.rb @@ -12,6 +12,11 @@ let(:school) { school_class.school } let(:school_id) { school.id } + it 'returns a successful operation response' do + response = described_class.call(school_id:) + expect(response.success?).to be(true) + end + it 'deletes a school' do expect { described_class.call(school_id:) }.to change(School, :count).by(-1) end diff --git a/spec/concepts/school/update_spec.rb b/spec/concepts/school/update_spec.rb index 152835401..0e63948ff 100644 --- a/spec/concepts/school/update_spec.rb +++ b/spec/concepts/school/update_spec.rb @@ -10,6 +10,11 @@ stub_user_info_api end + it 'returns a successful operation response' do + response = described_class.call(school:, school_params:) + expect(response.success?).to be(true) + end + it 'updates the school' do response = described_class.call(school:, school_params:) expect(response[:school].name).to eq('New Name') diff --git a/spec/concepts/school_class/create_spec.rb b/spec/concepts/school_class/create_spec.rb index 25eb76028..728533543 100644 --- a/spec/concepts/school_class/create_spec.rb +++ b/spec/concepts/school_class/create_spec.rb @@ -15,6 +15,11 @@ stub_user_info_api end + it 'returns a successful operation response' do + response = described_class.call(school:, school_class_params:) + expect(response.success?).to be(true) + end + it 'creates a school class' do expect { described_class.call(school:, school_class_params:) }.to change(SchoolClass, :count).by(1) end diff --git a/spec/concepts/school_class/delete_spec.rb b/spec/concepts/school_class/delete_spec.rb index 9f4cd54bc..d3b1a3c9d 100644 --- a/spec/concepts/school_class/delete_spec.rb +++ b/spec/concepts/school_class/delete_spec.rb @@ -12,6 +12,11 @@ let(:school_class_id) { school_class.id } let(:school) { school_class.school } + it 'returns a successful operation response' do + response = described_class.call(school:, school_class_id:) + expect(response.success?).to be(true) + end + it 'deletes a school class' do expect { described_class.call(school:, school_class_id:) }.to change(SchoolClass, :count).by(-1) end diff --git a/spec/concepts/school_class/update_spec.rb b/spec/concepts/school_class/update_spec.rb index 6706707e4..f981fcbe9 100644 --- a/spec/concepts/school_class/update_spec.rb +++ b/spec/concepts/school_class/update_spec.rb @@ -10,6 +10,11 @@ stub_user_info_api end + it 'returns a successful operation response' do + response = described_class.call(school_class:, school_class_params:) + expect(response.success?).to be(true) + end + it 'updates the school class' do response = described_class.call(school_class:, school_class_params:) expect(response[:school_class].name).to eq('New Name') diff --git a/spec/concepts/school_owner/invite_spec.rb b/spec/concepts/school_owner/invite_spec.rb index d56d0b0fb..d6e9f74ac 100644 --- a/spec/concepts/school_owner/invite_spec.rb +++ b/spec/concepts/school_owner/invite_spec.rb @@ -16,6 +16,11 @@ stub_profile_api_invite_school_owner end + it 'returns a successful operation response' do + response = described_class.call(school:, school_owner_params:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, school_owner_params:, token:) diff --git a/spec/concepts/school_owner/list_spec.rb b/spec/concepts/school_owner/list_spec.rb index aa38000a2..6e38c3b62 100644 --- a/spec/concepts/school_owner/list_spec.rb +++ b/spec/concepts/school_owner/list_spec.rb @@ -13,6 +13,11 @@ stub_user_info_api end + it 'returns a successful operation response' do + response = described_class.call(school:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, token:) diff --git a/spec/concepts/school_owner/remove_spec.rb b/spec/concepts/school_owner/remove_spec.rb index bde74a8fd..5d483e630 100644 --- a/spec/concepts/school_owner/remove_spec.rb +++ b/spec/concepts/school_owner/remove_spec.rb @@ -12,6 +12,11 @@ stub_profile_api_remove_school_owner end + it 'returns a successful operation response' do + response = described_class.call(school:, owner_id:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, owner_id:, token:) diff --git a/spec/concepts/school_student/create_batch_spec.rb b/spec/concepts/school_student/create_batch_spec.rb new file mode 100644 index 000000000..293a781bc --- /dev/null +++ b/spec/concepts/school_student/create_batch_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolStudent::CreateBatch, type: :unit do + let(:token) { UserProfileMock::TOKEN } + let(:school) { create(:school, verified_at: Time.zone.now) } + let(:file) { fixture_file_upload('students.csv') } + + before do + stub_profile_api_create_school_student + end + + it 'returns a successful operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response.success?).to be(true) + end + + it "makes a profile API call to create Jane Doe's account" do + described_class.call(school:, uploaded_file: file, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:create_school_student) + .with(token:, username: 'jane123', password: 'secret123', name: 'Jane Doe', organisation_id: school.id) + end + + it "makes a profile API call to create John Doe's account" do + described_class.call(school:, uploaded_file: file, token:) + + # TODO: Replace with WebMock assertion once the profile API has been built. + expect(ProfileApiClient).to have_received(:create_school_student) + .with(token:, username: 'john123', password: 'secret456', name: 'John Doe', organisation_id: school.id) + end + + context 'when an .xlsx file is provided' do + let(:file) { fixture_file_upload('students.xlsx') } + + it 'returns a successful operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response.success?).to be(true) + end + end + + context 'when creation fails' do + let(:file) { fixture_file_upload('test_image_1.png') } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not make a profile API request' do + described_class.call(school:, uploaded_file: file, token:) + expect(ProfileApiClient).not_to have_received(:create_school_student) + end + + it 'returns a failed operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response[:error]).to match(/can't detect the type/i) + end + + it 'sent the exception to Sentry' do + described_class.call(school:, uploaded_file: file, token:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end + + context 'when the school is not verified' do + let(:school) { create(:school) } + + it 'returns a failed operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response[:error]).to match(/school is not verified/) + end + end + + context 'when the file contains invalid data' do + let(:file) { fixture_file_upload('students-invalid.csv') } + + it 'returns a failed operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response.failure?).to be(true) + end + + it 'returns all of the validation errors in the operation response' do + response = described_class.call(school:, uploaded_file: file, token:) + expect(response[:error]).to match(/password 'invalid' is invalid, name ' ' is invalid, username ' '/) + end + + it 'does not make a profile API request' do + described_class.call(school:, uploaded_file: file, token:) + expect(ProfileApiClient).not_to have_received(:create_school_student) + end + end +end diff --git a/spec/concepts/school_student/create_spec.rb b/spec/concepts/school_student/create_spec.rb index 99cf6db0c..2834f89d2 100644 --- a/spec/concepts/school_student/create_spec.rb +++ b/spec/concepts/school_student/create_spec.rb @@ -5,8 +5,6 @@ RSpec.describe SchoolStudent::Create, type: :unit do let(:token) { UserProfileMock::TOKEN } let(:school) { create(:school, verified_at: Time.zone.now) } - let(:student_index) { user_index_by_role('school-student') } - let(:student_id) { user_id_by_index(student_index) } let(:school_student_params) do { @@ -17,7 +15,12 @@ end before do - stub_profile_api_create_school_student(user_id: student_id) + stub_profile_api_create_school_student + end + + it 'returns a successful operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response.success?).to be(true) end it 'makes a profile API call' do diff --git a/spec/concepts/school_student/delete_spec.rb b/spec/concepts/school_student/delete_spec.rb index 122834f7a..a3398b530 100644 --- a/spec/concepts/school_student/delete_spec.rb +++ b/spec/concepts/school_student/delete_spec.rb @@ -12,6 +12,11 @@ stub_profile_api_delete_school_student end + it 'returns a successful operation response' do + response = described_class.call(school:, student_id:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, student_id:, token:) diff --git a/spec/concepts/school_student/list_spec.rb b/spec/concepts/school_student/list_spec.rb index 25088df2d..7e7f87703 100644 --- a/spec/concepts/school_student/list_spec.rb +++ b/spec/concepts/school_student/list_spec.rb @@ -13,6 +13,11 @@ stub_user_info_api end + it 'returns a successful operation response' do + response = described_class.call(school:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, token:) diff --git a/spec/concepts/school_student/update_spec.rb b/spec/concepts/school_student/update_spec.rb index 4f9bda2c3..05011534b 100644 --- a/spec/concepts/school_student/update_spec.rb +++ b/spec/concepts/school_student/update_spec.rb @@ -20,6 +20,11 @@ stub_profile_api_update_school_student end + it 'returns a successful operation response' do + response = described_class.call(school:, school_student_params:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, school_student_params:, token:) diff --git a/spec/concepts/school_teacher/invite_spec.rb b/spec/concepts/school_teacher/invite_spec.rb index 65f0dee94..6d2b9f25f 100644 --- a/spec/concepts/school_teacher/invite_spec.rb +++ b/spec/concepts/school_teacher/invite_spec.rb @@ -16,6 +16,11 @@ stub_profile_api_invite_school_teacher end + it 'returns a successful operation response' do + response = described_class.call(school:, school_teacher_params:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, school_teacher_params:, token:) diff --git a/spec/concepts/school_teacher/list_spec.rb b/spec/concepts/school_teacher/list_spec.rb index d6a138089..f93443b2e 100644 --- a/spec/concepts/school_teacher/list_spec.rb +++ b/spec/concepts/school_teacher/list_spec.rb @@ -13,6 +13,11 @@ stub_user_info_api end + it 'returns a successful operation response' do + response = described_class.call(school:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, token:) diff --git a/spec/concepts/school_teacher/remove_spec.rb b/spec/concepts/school_teacher/remove_spec.rb index 288991fef..85c8e014b 100644 --- a/spec/concepts/school_teacher/remove_spec.rb +++ b/spec/concepts/school_teacher/remove_spec.rb @@ -12,6 +12,11 @@ stub_profile_api_remove_school_teacher end + it 'returns a successful operation response' do + response = described_class.call(school:, teacher_id:, token:) + expect(response.success?).to be(true) + end + it 'makes a profile API call' do described_class.call(school:, teacher_id:, token:) diff --git a/spec/features/school_student/creating_a_batch_of_school_students_spec.rb b/spec/features/school_student/creating_a_batch_of_school_students_spec.rb new file mode 100644 index 000000000..4a4aa98b1 --- /dev/null +++ b/spec/features/school_student/creating_a_batch_of_school_students_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a batch of school students', type: :request do + before do + stub_hydra_public_api + stub_profile_api_create_school_student + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school, verified_at: Time.zone.now) } + let(:student_index) { user_index_by_role('school-student') } + let(:student_id) { user_id_by_index(student_index) } + + let(:file) { fixture_file_upload('students.csv') } + + it 'responds 204 No Content' do + post("/api/schools/#{school.id}/students/batch", headers:, params: { file: }) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content when the user is a school-teacher' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post("/api/schools/#{school.id}/students/batch", headers:, params: { file: }) + expect(response).to have_http_status(:no_content) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/schools/#{school.id}/students/batch", headers:, params: {}) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/students/batch", params: { file: }) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/students/batch", headers:, params: { file: }) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post("/api/schools/#{school.id}/students/batch", headers:, params: { file: }) + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/features/school_student/creating_a_school_student_spec.rb b/spec/features/school_student/creating_a_school_student_spec.rb index e292519e4..e11b068ea 100644 --- a/spec/features/school_student/creating_a_school_student_spec.rb +++ b/spec/features/school_student/creating_a_school_student_spec.rb @@ -5,13 +5,11 @@ RSpec.describe 'Creating a school student', type: :request do before do stub_hydra_public_api - stub_profile_api_create_school_student(user_id: student_id) + stub_profile_api_create_school_student end let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:school) { create(:school, verified_at: Time.zone.now) } - let(:student_index) { user_index_by_role('school-student') } - let(:student_id) { user_id_by_index(student_index) } let(:params) do { diff --git a/spec/fixtures/files/students-invalid.csv b/spec/fixtures/files/students-invalid.csv new file mode 100644 index 000000000..5d0b80523 --- /dev/null +++ b/spec/fixtures/files/students-invalid.csv @@ -0,0 +1,9 @@ +Student Name,Username,Password +Jane Doe,jane123,secret123 +John Doe,john123,secret456 +,, +Jack Doe,jack123,invalid +,, + ,jade123,secret789 +Jacob Doe, ,secret012 +Julia Doe,julia123, diff --git a/spec/fixtures/files/students.csv b/spec/fixtures/files/students.csv new file mode 100644 index 000000000..0bf9f9eaf --- /dev/null +++ b/spec/fixtures/files/students.csv @@ -0,0 +1,3 @@ +Student Name,Username,Password +Jane Doe,jane123,secret123 +John Doe,john123,secret456 diff --git a/spec/fixtures/files/students.xlsx b/spec/fixtures/files/students.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..36989f4a64bf56cec919406aeefa630b03cde117 GIT binary patch literal 6396 zcmZ`-WmuH!)*cC!MoJjEySriN?v`d~7&-)`OL{~k1tcY;y95LTX_1!h0qF+$M)&!S zdvng2x#oSY_s4x-v(|c6-Req+NO%AQz+=ECYYlxlsC2dd0|4Lv2>`$a002f(5N8jt zvxlj+uM61Sn9aw@u{>EtwVMMw{5+HDh=A7FfFdm2OVS+WHNzv0v;3-^=@gqQZ$COa zw8zmB-<&OE&iocHe0RsJ<{BL_P@E152ni6Af~Pw5I~aQAZWudoiJ(WaO-s3W!V;WB zrg0lBk5j9rEg9|K`LNUO0D<8$QA@sssP8pCb=XnnjVsjwe7Ond6-h;ci!u$dILX>L z#0?NGO2Sy@aNAH@mJI)lSa|)H>yDq!!4Pn`nl?>Q(NLBz-@Un(H zVN`+0RC^>#GYqjP$(_I;px&-Z!WyHt{W^o+eD$^1F%qWQDrpys{!Dr2Y_AyTF5<;h z`3QQ*>DPR|c}3A&2*|KKva;1k3aVlA6yW6cP?>B72WO_javuy! zT=KZ6LNPoQkB+Quu~ql#KIp0nyt?!JX8-zQtLDl^<3-%{kAN~MFRaN6MD>3J;%Pm< zz&1PpN(cY|8vHbM13S92v;BIOLx=3UInaal{ZRA#@@m65WwT5MavHd*AB{S$N7AHJ zwXp_V`$kqFH;;G6rxd7Vhk0H50>24IPRD$$WK&e%jh$Yo1Dfm`NvF9&tP9fU2te~VgmZs=vG$e85L82rrW95Jl6 zUfEH>mfz|2T!&bm`!IWX?^mrp>u&KHRXv%6*)|b*jp-&N zM3E9u>1d`WykYz#Je#C$0W?V}B8)DBhcb#>m~tvIQCr6<#N)++UncsChfKT~^uU)i zU*{2b(A27GP{)ULDbg8N0$Zi#&K{bE3dU|-Du&U#jTmGHW(ip#%NbjdCr+*EDfPN9 z^tfMTi(r--p<~79E_r~ihZ)5WTB9#72nJW79Hx|e{gg*UYdlvnLw)awd=FQ5$p7h} zsUf7D2zc%aBLV=}a0gjIEY;m0F7E8+E-t@(lcm+^Jja3Fw$F_IomjAw124GDLZ1I* z)ZziO-bS_1GPj;4sw`<2MlW0ms}=oL)YlhTME_EkzpuVuDAuZt`IB2x40_gKfoCL0 zXaZD`XGn6=Q{U}^$4L0i=OXKsg204YEKGBx5i=BXGvQrJ`+R;uG3U7{m`p#nj)i;q1cM@Jmd_@KI^6~a8bma=E2 z>9dMffx(ptT2Ywqko#6xgr6chBe+hCh_$Q7ea>H##&r3RWqEDP{zwwx4M-4K;eu;r z!7cQpXcn}lhQq!Y9f`hD6H87NAK6y3c)=GWJQUG!$ax{4Y}{W}PWG|$fnO-fccXJq zg84oq*fsNp_PrOcG|N{@6?X0?J8Z_3d~GkZLWRu)?Zx=6M16(s(4IFLu5A-fTCOGg z8~NSdi`nJ_ViPxb%$~qw_T=v|vxK;Te+8`o+WD*6O7Hv2tT8L@g(2u97vfN;Qc!9j z=~+UIzJ99T4NvUobDd2_;8z{9>K$pJeiJiNs4Di+7krXwg)56Q+9h8w<$hG#s$xTP z__=PaZj6JT)Tg>wRnZ*HoJ2&xvNIPU8!7QDywbf|vKG0(#4%_21rCl-D)TBZUb30D z?wR8y&~|tl*$L3J_Sp_gc~723I3JICyuC#TAwNf53K1weL`NiQ_2k*=od|Xoq+izD zW_e_5f#hqpNnB^W6aAh+Y{Vt^m0RFBp`Oa?AEh`FC7Lfm0!y~fPV*aPYlWJ~JQO;7 z0}^=8eaL~PjEQ>R_b>M4n~dB)1}S&>^i&Qm_=>f(8!SMXY%Y1gzUjtnHFmCItkb*6 z>&TY3gmQ_9bu-^j#CPA~v`B>WhJ9Pnqf6kMXS{s~up|xr{)pCRIl1S6q|}!5_VH{- zM|<$cP7;|dTQp;b%o82Q-JGj8z%wJ_DU3-h&y$y>?Eg$E_ar}q8e{;Vkod2L2(LOG zwqPglUr)|oA=)!ggUs>ab?j?lH#bx}_Irj1$?Q(UCJQle8l)0xrIpGUH7K_8+cEZB zKL(k9uly49-HWgu-!|B|S?yJM_vC|HRKF|Bn3vmpc3O^7uuxWL$9eYtp+>v^y2;=u zejRCxkRXwyj7A3; z#e$4_)_~OBBPulo1P5F;i!w6?kTCYeG0D8xg1S00 zF|&i$3~4GC$|M&}(pgk}54Gs8liX))5J)X1>m*b^CdjV6t;X)w>;z5OuaSRQXm(_e zPBoM&9m6_O?NzyMd{-C1jsx_J>zZgg0|*LrEZ{Wf3xUU=j|g zSc-hZH|tEA9ZDtpY8zE3W|NCc>2p|3sjSbVrL3NZ#p?C-EvQ%0<#P_4?O%GZ0lqF( z?@%<4la(v68pY!KLRORk3pDyhjD`M9T(A2<<4<{XjA5&IQnK2KPt^kb4^KFsAD)aI z-qQD-2MWG8Si1N*^02E^0L8Owb20GnydnEt2U+0x&9a_#>%?Kl-N_83ug~+_kc|m`RXV84`Us)=Q}n1)2e7<5*C%NZAclv`jpGb`%ZrX4o;e_vnv` zf1S3)6i3PC?uh9*#NX1Ok-&|6P3opE2LhQ{TPWxc#gVV1byaCtl5msyl9s|tW9PFA z-bf|nButc5TWoAb6(0Vc*1{jW=&wH$o*DBcz~pncCc;|d1*ejP{bv(gGJ&3j3i+1e zP_5~AgnO=F?T{&M8e%CDmukNG21X4VC72OU>>x3XQ5uStBDTp$pJ0X=H2Jhm_q}C$ zYndZAs^@jUjLFA_Wo*kLhDn&qT{li!s7*gvaG8&3?xNn7cNM7O%ldJ}7ahj>Qxjnu z+zO{w`s6A^UYoV%!I(y*;}A;ajnGWnna!dnA}K5Ojp|D`L?|D`a+!ppTu?1CpMe;Z zCUd13-?+3?P96|vj-LNtq_FX=$t5l0f zQKtK(=U`o8-qdUJGAfio0A$IBdrf%a74scm9Ys#YofA7T*SYC%C(YElvwunq>}8fB z+gPSR%6f(@5@@oMn80(o%iG*h$XM>q)8pr@((lE_(^yW?@m!Laf@9#!c4`GALsFs+ z)CgqAas3PqHhDZwrG-+pOX^j~ELgUq+nLAUW}bg$7ljnASVSfH`7P6)KQedG`|Jud zS#Xv?3G>$#Z)HprV(`q|@NR>8Z82Q|wcC7u&P?GhFtYp3WpkC!q)4pIoiNMK?EeA#a{l zE?r1NTnT8@uJv|9%iYT%u7)_SVbyVD$zgx}L%p8X#3UQnrS19%iawfE8s9!o`n z{j49I1+^U)#0VGD_}i1>|E%#y&e}J_@aF3ZZ^U?iO_uJq=5AmsEe|(4XB+olMcy-! zQx%aDTgFe-Tt%%_fdauwSO!}LH4I5GsE({!7IwIjrm}QKSP+Gma@4_p90Qp&eXsY3 zg@oH6bp)s`?}V9jM&JceoAS=l7eQXM0ii8^uJqE2FwhR5tsjj>$1-%EGd~jff~d*6 z-$cXCo6@$Cn}an^skn3c&Q4e8Qa(raWj3|*k-Wt+S?2OqY@;3#BDB~hd(_rd_U`D_ z94A5g68qcBRHeo>Bei0(o0lcg6exUo7ycCjmI~5LnekLP6oZ!Ky`ay*`&)D2T@H9D)AW7$ru-x`FY7qOORHQ zHj1h#6kKpVQ}k(tW~m8LV&$=Sz0GT*Jq&%0r#U9tdjlGAM6H%NmT^)-iHofKrmzx;|-kc~{AF zo0`$1Q|kUO-Z-5~?5i4KVy&etWYtRFEV?|Zws4aa;p$ZCZX*nj+syEEa-p1TALe;% zFwQN_!Q_o%uwt=e?m&U+Nv_=xp>lXRX*t&y@M`%e=cuvLlYFlj_TqS`&e&e71=7LK zBmc7#Ja&ly61m>LZ4gY3$3R)!lH1#(U5Ihmx=V)+5dL zPwMiK%s(vxiH5b-i|^23X!M1(=3UK~m08O>1*3wdypZ8(7R>UEL0!@t1DzE$YsKuF z(k4FLuLHqoYdfT0NNdP4R^=&~ztcn-+uKf4RO%NbJ0GeQUs808IecS(G1jO(bf_zo zpjCTIU48e@JM!$u9$Ojwj-0}`FRZ`r$lv#_IH-~uu_Syay~WRKdSlEa;gpP_nQNDH zB|%%T*Vm-#W@8Q~A1TZcOT zYZGP*+~=znL8{^i+s8c_mwX%7p0VeOM>ff^_@)CAiTpV+oa#k#8$1rcYHA-TIZjFJ z5$qa%7W;Mwc{gP>YJlHVtHWGPFERQxYRyeL(3Nns3tamu@?#4XQKq>;w5)Re)8hxE z^FQjf=?eCHzqDehGTTInpMDwb*y?y1S zUT^=lTnFNn$fS)LF7Zu?`H3DEtcoKuSL8@1P&0;v%a4#l!W00V2Gbd(zGUzv-zh5n zsUqOrJUYAmbd3JdskG*c)XWxaSi%8sdV4i=vAfF+!k#jI?h@5=Zv#<%#`A&)zJ2gN z1^@{E1lHXa4EAtm|9$+Patn0ker=Bdi+Y4FAelUEWs*<}r#E#HkpyjJBQIt846Vjq zt>w>5B|zCVpkjE_uX>~o57epF57+%D^YCq{)m2s`E6C+?G}l0^bce7nyIT$kh;~8@ zxK61!@wyvrn5^99_p@n{aZAhzw?|yeQljykIm%Jh>*>ZQ(hAa**%ZH4w;ih;x(-ch z2bR(&`e^wHwRiSEFs0Otd&oVfRArk_s_UEdYNrg{P8T`oLnV?Z7RX!tH6xgy%xN1B$sz8ghk9@}nE5G#}o3t`PX(#LNp( zq{Y8ti$`lFnF1$8IN}5n$9{-(j$AxY`Z$XO`&@m=EicdNqP92d6yNAEEF4m-q7n}y zH$3pulq$FOMCQE+gE=HOXg{v2i=nd^S%-38l`R4DJ%9{LYScERlk1PC$}7`4P?$2M zdYSAPM_764-kzwg6bq*N=?D9}*^Dpt$iMEMeFsSef352E%!jQqr)8vd+RDGwdL$J5 z$@#O1%Jr(1O!auLH~!VbmUh^v_xvT!z&BcvpB&4s*aGuPgjcF(I1%k%6L2xbl?Bid zMt;2=w}s{I!%Vtp1QrVKM)}V7_Y^WY?TU?EerC9QouNbSsr|^RnUD>8gNP2+DGo^f z5rBpwGqT*-3%oU3-77>=`CdIQB*6P2JRk!2-Mm=%hU_ctfpOi#(|R*qgG>}VNpE#V zU0c)nma37nk~KLp@UoZN-&2i)^;;iX*(Y$cyry(J8t-h^0wcOe4D^y=3aMuwx7xi-L-;4=agNTW1alp@U`HJo| zuZ1`n2?orEBzXixOERbS@p&PYh2X5Vh&kRi+LAdkp!0X+FxicKPfFnw{^lHGW)T2a zOW@Cwdzm(*q)Fui&#*Z7kNQ6{?Jq6sPrm(DvQ%O@F%c-S0e+^E_2&|3mV>TSmw-I1 zX%}W}b4nJ@D?dh7`d4Ybw`=(e6Ep<%E~)4Y7JBuc7rItoe9x9tkXzuur$aNv(SP&Z zKQAnR2gd@RmyU%1w14`pu2F&r%=FqP)y9tK`s0SD%sEKy%63qp;qKAa`XhCv2MFSb z|63M@|NGyc`ru#3e-VlA6Wo_G|AGSm^}*5brt&v&^FHvt>h}kj0q;crTLZk$b6*_$ z!_x{ce)pxa`{?^J%^$Q2TpRc|`u|0n`|$fB!XNmPN574@FDcw7xIdx(Avi_V1a$Gv41A;3FU0kM94T`0hjRxBfp+BDl;5hyHyKxDUSHUjBfO;Ck1;!2i)~?z7xa goj)wk@b9zy*F;iRLVoxQga-eP!7mOz!LPUf18KTCTmS$7 literal 0 HcmV?d00001 diff --git a/spec/support/profile_api_mock.rb b/spec/support/profile_api_mock.rb index 8d3f85e5e..a7d01a2ad 100644 --- a/spec/support/profile_api_mock.rb +++ b/spec/support/profile_api_mock.rb @@ -37,8 +37,8 @@ def stub_profile_api_list_school_students(user_id:) allow(ProfileApiClient).to receive(:list_school_students).and_return(ids: [user_id]) end - def stub_profile_api_create_school_student(user_id:) - allow(ProfileApiClient).to receive(:create_school_student).and_return(id: user_id) + def stub_profile_api_create_school_student + allow(ProfileApiClient).to receive(:create_school_student) end def stub_profile_api_update_school_student From 96987aa724e26582a17d894f7da37ec866376704 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sat, 17 Feb 2024 14:45:19 +0000 Subject: [PATCH 087/124] Add a lessons table --- db/migrate/20240217144009_create_lessons.rb | 19 +++++++++++++++++++ db/schema.rb | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240217144009_create_lessons.rb diff --git a/db/migrate/20240217144009_create_lessons.rb b/db/migrate/20240217144009_create_lessons.rb new file mode 100644 index 000000000..b2f00a699 --- /dev/null +++ b/db/migrate/20240217144009_create_lessons.rb @@ -0,0 +1,19 @@ +class CreateLessons < ActiveRecord::Migration[7.0] + def change + create_table :lessons, id: :uuid do |t| + t.references :school_class, type: :uuid, foreign_key: true, index: true + t.uuid :user_id, null: false + + t.string :name, null: false + t.string :description + t.string :visibility, null: false, default: 'private' + + t.datetime :due_date + t.timestamps + end + + add_index :lessons, :user_id + add_index :lessons, :name + add_index :lessons, :visibility + end +end diff --git a/db/schema.rb b/db/schema.rb index 739dce6d3..9e2c68c9c 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[7.0].define(version: 2024_02_01_171700) do +ActiveRecord::Schema[7.0].define(version: 2024_02_17_144009) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -124,6 +124,21 @@ t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" end + create_table "lessons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_class_id" + t.uuid "user_id", null: false + t.string "name", null: false + t.string "description" + t.string "visibility", default: "private", null: false + t.datetime "due_date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_lessons_on_name" + t.index ["school_class_id"], name: "index_lessons_on_school_class_id" + t.index ["user_id"], name: "index_lessons_on_user_id" + t.index ["visibility"], name: "index_lessons_on_visibility" + end + create_table "project_errors", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "project_id" t.string "error", null: false @@ -183,6 +198,7 @@ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "class_members", "school_classes" add_foreign_key "components", "projects" + add_foreign_key "lessons", "school_classes" add_foreign_key "project_errors", "projects" add_foreign_key "school_classes", "schools" end From 15166409d0ba2f0cf1c5c4df400fa502af0a797b Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 18 Feb 2024 14:31:22 +0000 Subject: [PATCH 088/124] Add a Lesson model --- app/models/lesson.rb | 9 +++++++ spec/factories/lesson.rb | 9 +++++++ spec/models/lesson_spec.rb | 53 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 app/models/lesson.rb create mode 100644 spec/factories/lesson.rb create mode 100644 spec/models/lesson_spec.rb diff --git a/app/models/lesson.rb b/app/models/lesson.rb new file mode 100644 index 000000000..021b06e18 --- /dev/null +++ b/app/models/lesson.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Lesson < ApplicationRecord + belongs_to :school_class, optional: true + + validates :user_id, presence: true + validates :name, presence: true + validates :visibility, presence: true, inclusion: { in: %w[private school public] } +end diff --git a/spec/factories/lesson.rb b/spec/factories/lesson.rb new file mode 100644 index 000000000..ca1fec2cb --- /dev/null +++ b/spec/factories/lesson.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :lesson do + user_id { '11111111-1111-1111-1111-111111111111' } # Matches users.json. + sequence(:name) { |n| "Lesson #{n}" } + visibility { 'private' } + end +end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb new file mode 100644 index 000000000..f40e1bb42 --- /dev/null +++ b/spec/models/lesson_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson do + before do + stub_user_info_api + end + + describe 'associations' do + it 'optionally belongs to a school class' do + lesson = create(:lesson, school_class: build(:school_class)) + expect(lesson.school_class).to be_a(SchoolClass) + end + end + + describe 'validations' do + subject(:lesson) { build(:lesson) } + + it 'has a valid default factory' do + expect(lesson).to be_valid + end + + it 'can save the default factory' do + expect { lesson.save! }.not_to raise_error + end + + it 'requires a user_id' do + lesson.user_id = ' ' + expect(lesson).to be_invalid + end + + it 'requires a UUID user_id' do + lesson.user_id = 'invalid' + expect(lesson).to be_invalid + end + + it 'requires a name' do + lesson.name = ' ' + expect(lesson).to be_invalid + end + + it 'requires a visibility' do + lesson.visibility = ' ' + expect(lesson).to be_invalid + end + + it "requires a visibility that is either 'private', 'school' or 'public'" do + lesson.visibility = 'invalid' + expect(lesson).to be_invalid + end + end +end From aade1a7d76d33e13e1b89cb5655c8ac803d73b42 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 18 Feb 2024 14:31:36 +0000 Subject: [PATCH 089/124] Add a SchoolClass#lessons association --- app/models/school_class.rb | 1 + spec/models/school_class_spec.rb | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/models/school_class.rb b/app/models/school_class.rb index 581dd60b2..ab473a762 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -3,6 +3,7 @@ class SchoolClass < ApplicationRecord belongs_to :school has_many :members, class_name: :ClassMember, inverse_of: :school_class, dependent: :destroy + has_many :lessons, dependent: :nullify validates :teacher_id, presence: true validates :name, presence: true diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index 35f6cc37d..f1468e223 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -18,12 +18,26 @@ expect(school_class.members.size).to eq(1) end + it 'has many lessons' do + school_class = create(:school_class, lessons: [build(:lesson)]) + expect(school_class.lessons.size).to eq(1) + end + context 'when a school_class is destroyed' do - let!(:school_class) { create(:school_class, members: [build(:class_member)]) } + let!(:school_class) { create(:school_class, members: [build(:class_member)], lessons: [build(:lesson)]) } it 'also destroys class members to avoid making them invalid' do expect { school_class.destroy! }.to change(ClassMember, :count).by(-1) end + + it 'does not destroy lessons' do + expect { school_class.destroy! }.not_to change(Lesson, :count) + end + + it 'nullifies school_class_id on lessons' do + school_class.destroy! + expect(Lesson.last.school_class).to be_nil + end end end From d416f0d0e7dc292f19ed8236437a9ef7ed5c25e5 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 19 Feb 2024 11:11:43 +0000 Subject: [PATCH 090/124] Allow lessons to optionally belong to schools --- app/models/lesson.rb | 9 ++++++++ app/models/school.rb | 1 + db/migrate/20240217144009_create_lessons.rb | 1 + db/schema.rb | 3 +++ spec/models/lesson_spec.rb | 21 ++++++++++++++++- spec/models/school_spec.rb | 25 +++++++++++++++++++-- 6 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 021b06e18..6d8216e62 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true class Lesson < ApplicationRecord + belongs_to :school, optional: true belongs_to :school_class, optional: true validates :user_id, presence: true validates :name, presence: true validates :visibility, presence: true, inclusion: { in: %w[private school public] } + + before_save :assign_school_from_school_class + + private + + def assign_school_from_school_class + self.school ||= school_class&.school + end end diff --git a/app/models/school.rb b/app/models/school.rb index d976d3432..e3e3680c3 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -2,6 +2,7 @@ class School < ApplicationRecord has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy + has_many :lessons, dependent: :nullify validates :id, presence: true, uniqueness: { case_sensitive: false } validates :name, presence: true diff --git a/db/migrate/20240217144009_create_lessons.rb b/db/migrate/20240217144009_create_lessons.rb index b2f00a699..eebe2f256 100644 --- a/db/migrate/20240217144009_create_lessons.rb +++ b/db/migrate/20240217144009_create_lessons.rb @@ -1,6 +1,7 @@ class CreateLessons < ActiveRecord::Migration[7.0] def change create_table :lessons, id: :uuid do |t| + t.references :school, type: :uuid, foreign_key: true, index: true t.references :school_class, type: :uuid, foreign_key: true, index: true t.uuid :user_id, null: false diff --git a/db/schema.rb b/db/schema.rb index 9e2c68c9c..615a51443 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -125,6 +125,7 @@ end create_table "lessons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_id" t.uuid "school_class_id" t.uuid "user_id", null: false t.string "name", null: false @@ -135,6 +136,7 @@ t.datetime "updated_at", null: false t.index ["name"], name: "index_lessons_on_name" t.index ["school_class_id"], name: "index_lessons_on_school_class_id" + t.index ["school_id"], name: "index_lessons_on_school_id" t.index ["user_id"], name: "index_lessons_on_user_id" t.index ["visibility"], name: "index_lessons_on_visibility" end @@ -199,6 +201,7 @@ add_foreign_key "class_members", "school_classes" add_foreign_key "components", "projects" add_foreign_key "lessons", "school_classes" + add_foreign_key "lessons", "schools" add_foreign_key "project_errors", "projects" add_foreign_key "school_classes", "schools" end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index f40e1bb42..0cc64c70b 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -8,8 +8,15 @@ end describe 'associations' do + it 'optionally belongs to a school (library)' do + lesson = create(:lesson, school: build(:school)) + expect(lesson.school).to be_a(School) + end + it 'optionally belongs to a school class' do - lesson = create(:lesson, school_class: build(:school_class)) + school_class = create(:school_class) + + lesson = create(:lesson, school_class:, school: school_class.school) expect(lesson.school_class).to be_a(SchoolClass) end end @@ -50,4 +57,16 @@ expect(lesson).to be_invalid end end + + describe '#school' do + it 'is set from the school_class' do + lesson = create(:lesson, school_class: build(:school_class)) + expect(lesson.school).to eq(lesson.school_class.school) + end + + it 'is not nullified when there is no school_class' do + lesson = create(:lesson, school: build(:school)) + expect(lesson.school).not_to eq(lesson.school_class&.school) + end + end end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 984a01064..3aba95660 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -13,9 +13,17 @@ expect(school.classes.size).to eq(2) end + it 'has many lessons' do + school = create(:school, lessons: [build(:lesson), build(:lesson)]) + expect(school.lessons.size).to eq(2) + end + context 'when a school is destroyed' do - let!(:school_class) { build(:school_class, members: [build(:class_member)]) } - let!(:school) { create(:school, classes: [school_class]) } + let(:lesson1) { build(:lesson) } + let(:lesson2) { build(:lesson) } + + let!(:school_class) { build(:school_class, members: [build(:class_member)], lessons: [lesson1]) } + let!(:school) { create(:school, classes: [school_class], lessons: [lesson2]) } it 'also destroys school classes to avoid making them invalid' do expect { school.destroy! }.to change(SchoolClass, :count).by(-1) @@ -24,6 +32,19 @@ it 'also destroys class members to avoid making them invalid' do expect { school.destroy! }.to change(ClassMember, :count).by(-1) end + + it 'does not destroy lessons' do + expect { school.destroy! }.not_to change(Lesson, :count) + end + + it 'nullifies school_id and school_class_id fields on lessons' do + school.destroy! + + lessons = [lesson1, lesson2].map(&:reload) + values = lessons.flat_map { |l| [l.school_id, l.school_class_id] } + + expect(values).to eq [nil, nil, nil, nil] + end end end From 7b8479f68323f7a13531349a5c02950280121ef4 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 19 Feb 2024 18:08:07 +0000 Subject: [PATCH 091/124] Add a Lessons#create controller action --- app/controllers/api/lessons_controller.rb | 25 ++++ app/models/ability.rb | 12 +- app/views/api/lessons/show.json.jbuilder | 14 ++ config/routes.rb | 2 + lib/concepts/lesson/operations/create.rb | 19 +++ .../features/lesson/creating_a_lesson_spec.rb | 132 ++++++++++++++++++ 6 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/lessons_controller.rb create mode 100644 app/views/api/lessons/show.json.jbuilder create mode 100644 lib/concepts/lesson/operations/create.rb create mode 100644 spec/features/lesson/creating_a_lesson_spec.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb new file mode 100644 index 000000000..b7af580b4 --- /dev/null +++ b/app/controllers/api/lessons_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + class LessonsController < ApiController + before_action :authorize_user + load_and_authorize_resource :lesson + + def create + result = Lesson::Create.call(lesson_params:) + + if result.success? + @lesson = result[:lesson] + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + private + + def lesson_params + params.require(:lesson).permit(:school_id, :school_class_id, :name).merge(user_id: current_user.id) + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 5f87d13af..921f7c1da 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -3,7 +3,7 @@ class Ability include CanCan::Ability - # rubocop:disable Metrics/AbcSize, Layout/LineLength + # rubocop:disable Metrics/AbcSize, Layout/LineLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength def initialize(user) can :show, Project, user_id: nil can :show, Component, project: { user_id: nil } @@ -14,6 +14,7 @@ def initialize(user) can %i[read create update destroy], Component, project: { user_id: user.id } can %i[create], School # The user agrees to become a school-owner by creating a school. + can %i[create], Lesson, school_id: nil, school_class_id: nil # Can create public lessons. user.organisation_ids.each do |organisation_id| if user.school_owner?(organisation_id:) @@ -23,6 +24,7 @@ def initialize(user) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) can(%i[read create create_batch update destroy], :school_student) + can(%i[create], Lesson, school_id: organisation_id) end if user.school_teacher?(organisation_id:) @@ -33,6 +35,12 @@ def initialize(user) can(%i[read], :school_owner) can(%i[read], :school_teacher) can(%i[read create create_batch update], :school_student) + can(%i[create], Lesson) do |lesson| + is_my_lesson = lesson.school_id == organisation_id && lesson.user_id == user.id + is_my_class = lesson.school_class && lesson.school_class.teacher_id == user.id + + is_my_lesson && (is_my_class || !lesson.school_class) + end end if user.school_student?(organisation_id:) @@ -41,5 +49,5 @@ def initialize(user) end end end - # rubocop:enable Metrics/AbcSize, Layout/LineLength + # rubocop:enable Metrics/AbcSize, Layout/LineLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength end diff --git a/app/views/api/lessons/show.json.jbuilder b/app/views/api/lessons/show.json.jbuilder new file mode 100644 index 000000000..1a20ecca5 --- /dev/null +++ b/app/views/api/lessons/show.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.call( + @lesson, + :id, + :school_id, + :school_class_id, + :user_id, + :name, + :visibility, + :due_date, + :created_at, + :updated_at +) diff --git a/config/routes.rb b/config/routes.rb index 94b904f74..73181a919 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -28,6 +28,8 @@ post :batch, on: :collection, to: 'school_students#create_batch' end end + + resources :lessons, only: %i[create] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/lib/concepts/lesson/operations/create.rb b/lib/concepts/lesson/operations/create.rb new file mode 100644 index 000000000..a078ea8d6 --- /dev/null +++ b/lib/concepts/lesson/operations/create.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Lesson + class Create + class << self + def call(lesson_params:) + response = OperationResponse.new + response[:lesson] = Lesson.new(lesson_params) + response[:lesson].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:lesson].errors.full_messages.join(',') + response[:error] = "Error creating lesson: #{errors}" + response + end + end + end +end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb new file mode 100644 index 000000000..29c9075eb --- /dev/null +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a public lesson', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + let(:params) do + { + lesson: { + name: 'Test Lesson' + } + } + end + + it 'responds 201 Created' do + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the lesson JSON' do + post('/api/lessons', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Lesson') + end + + it 'responds 400 Bad Request when params are missing' do + post('/api/lessons', headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post('/api/lessons', headers:, params: { lesson: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post('/api/lessons', params:) + expect(response).to have_http_status(:unauthorized) + end + + context 'when the lesson is associated with a school (library)' do + let(:school) { create(:school) } + + let(:params) do + { + lesson: { + name: 'Test Lesson', + school_id: school.id + } + } + end + + it 'responds 201 Created' do + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher for the school' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when the lesson is associated with a school class' do + let(:school_class) { create(:school_class) } + let(:school) { school_class.school } + + let(:params) do + { + lesson: { + name: 'Test Lesson', + school_id: school.id, + school_class_id: school_class.id + } + } + end + + it 'responds 201 Created' do + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is the school-teacher for the class' do + teacher_index = user_index_by_role('school-teacher') + + stub_hydra_public_api(user_index: teacher_index) + school_class.update!(teacher_id: user_id_by_index(teacher_index)) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + school_class.update!(school_id: school.id) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-teacher for a different class' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + school_class.update!(teacher_id: SecureRandom.uuid) + + post('/api/lessons', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end +end From 2b1532271c27e3840b1f2da122ca7609148f1218 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Mon, 19 Feb 2024 18:22:32 +0000 Subject: [PATCH 092/124] Split ability definitions into methods to fix Rubocop warnings --- app/models/ability.rb | 76 ++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index 921f7c1da..7652f6902 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -3,7 +3,6 @@ class Ability include CanCan::Ability - # rubocop:disable Metrics/AbcSize, Layout/LineLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength def initialize(user) can :show, Project, user_id: nil can :show, Component, project: { user_id: nil } @@ -13,41 +12,50 @@ def initialize(user) can %i[read create update destroy], Project, user_id: user.id can %i[read create update destroy], Component, project: { user_id: user.id } - can %i[create], School # The user agrees to become a school-owner by creating a school. - can %i[create], Lesson, school_id: nil, school_class_id: nil # Can create public lessons. + can :create, School # The user agrees to become a school-owner by creating a school. + can :create, Lesson, school_id: nil, school_class_id: nil # Can create public lessons. user.organisation_ids.each do |organisation_id| - if user.school_owner?(organisation_id:) - can(%i[read update destroy], School, id: organisation_id) - can(%i[read create update destroy], SchoolClass, school: { id: organisation_id }) - can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } }) - can(%i[read create destroy], :school_owner) - can(%i[read create destroy], :school_teacher) - can(%i[read create create_batch update destroy], :school_student) - can(%i[create], Lesson, school_id: organisation_id) - end - - if user.school_teacher?(organisation_id:) - can(%i[read], School, id: organisation_id) - can(%i[create], SchoolClass, school: { id: organisation_id }) - can(%i[read update destroy], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) - can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) - can(%i[read], :school_owner) - can(%i[read], :school_teacher) - can(%i[read create create_batch update], :school_student) - can(%i[create], Lesson) do |lesson| - is_my_lesson = lesson.school_id == organisation_id && lesson.user_id == user.id - is_my_class = lesson.school_class && lesson.school_class.teacher_id == user.id - - is_my_lesson && (is_my_class || !lesson.school_class) - end - end - - if user.school_student?(organisation_id:) - can(%i[read], School, id: organisation_id) - can(%i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id }) - end + define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:) + define_school_teacher_abilities(user:, organisation_id:) if user.school_teacher?(organisation_id:) + define_school_student_abilities(user:, organisation_id:) if user.school_student?(organisation_id:) end end - # rubocop:enable Metrics/AbcSize, Layout/LineLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength + + private + + def define_school_owner_abilities(organisation_id:) + can %i[read update destroy], School, id: organisation_id + can %i[read create update destroy], SchoolClass, school: { id: organisation_id } + can %i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } } + can %i[read create destroy], :school_owner + can %i[read create destroy], :school_teacher + can %i[read create create_batch update destroy], :school_student + can %i[create], Lesson, school_id: organisation_id + end + + def define_school_teacher_abilities(user:, organisation_id:) + can %i[read], School, id: organisation_id + can %i[create], SchoolClass, school: { id: organisation_id } + can %i[read update destroy], SchoolClass, school: { id: organisation_id }, teacher_id: user.id + can %i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id } + can %i[read], :school_owner + can %i[read], :school_teacher + can %i[read create create_batch update], :school_student + can %i[create], Lesson do |lesson| + school_teacher_can_manage?(user:, organisation_id:, lesson:) + end + end + + def define_school_student_abilities(user:, organisation_id:) + can %i[read], School, id: organisation_id + can %i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id } + end + + def school_teacher_can_manage?(user:, organisation_id:, lesson:) + is_my_lesson = lesson.school_id == organisation_id && lesson.user_id == user.id + is_my_class = lesson.school_class && lesson.school_class.teacher_id == user.id + + is_my_lesson && (is_my_class || !lesson.school_class) + end end From a0ea21d76e1594849c899d457a32dd878abe1aa1 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 20 Feb 2024 14:10:24 +0000 Subject: [PATCH 093/124] Add Lesson.users, .with_users and #with_user methods --- app/models/class_member.rb | 4 +-- app/models/lesson.rb | 13 ++++++++ app/models/school_class.rb | 4 +-- spec/models/lesson_spec.rb | 66 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/app/models/class_member.rb b/app/models/class_member.rb index 4f7abbf0f..217e2d4b1 100644 --- a/app/models/class_member.rb +++ b/app/models/class_member.rb @@ -16,8 +16,8 @@ def self.students end def self.with_students - users = students.index_by(&:id) - all.map { |instance| [instance, users[instance.student_id]] } + by_id = students.index_by(&:id) + all.map { |instance| [instance, by_id[instance.student_id]] } end def with_student diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 6d8216e62..9a6d5240e 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -10,6 +10,19 @@ class Lesson < ApplicationRecord before_save :assign_school_from_school_class + def self.users + User.from_userinfo(ids: pluck(:user_id)) + end + + def self.with_users + by_id = users.index_by(&:id) + all.map { |instance| [instance, by_id[instance.user_id]] } + end + + def with_user + [self, User.from_userinfo(ids: user_id).first] + end + private def assign_school_from_school_class diff --git a/app/models/school_class.rb b/app/models/school_class.rb index ab473a762..2f8788111 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -14,8 +14,8 @@ def self.teachers end def self.with_teachers - users = teachers.index_by(&:id) - all.map { |instance| [instance, users[instance.teacher_id]] } + by_id = teachers.index_by(&:id) + all.map { |instance| [instance, by_id[instance.teacher_id]] } end def with_teacher diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 0cc64c70b..3cd7819a5 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -69,4 +69,70 @@ expect(lesson.school).not_to eq(lesson.school_class&.school) end end + + describe '.users' do + it 'returns User instances for the current scope' do + create(:lesson) + + user = described_class.all.users.first + expect(user.name).to eq('School Teacher') + end + + it 'ignores members where no profile account exists' do + create(:lesson, user_id: SecureRandom.uuid) + + user = described_class.all.users.first + expect(user).to be_nil + end + + it 'ignores members not included in the current scope' do + create(:lesson) + + user = described_class.none.users.first + expect(user).to be_nil + end + end + + describe '.with_users' do + it 'returns an array of class members paired with their User instance' do + lesson = create(:lesson) + + pair = described_class.all.with_users.first + user = described_class.all.users.first + + expect(pair).to eq([lesson, user]) + end + + it 'returns nil values for members where no profile account exists' do + lesson = create(:lesson, user_id: SecureRandom.uuid) + + pair = described_class.all.with_users.first + expect(pair).to eq([lesson, nil]) + end + + it 'ignores members not included in the current scope' do + create(:lesson) + + pair = described_class.none.with_users.first + expect(pair).to be_nil + end + end + + describe '#with_user' do + it 'returns the class member paired with their User instance' do + lesson = create(:lesson) + + pair = lesson.with_user + user = described_class.all.users.first + + expect(pair).to eq([lesson, user]) + end + + it 'returns a nil value if the member has no profile account' do + lesson = create(:lesson, user_id: SecureRandom.uuid) + + pair = lesson.with_user + expect(pair).to eq([lesson, nil]) + end + end end From f039f84ed3fc6915110df31a3127edd6d1d13a14 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 20 Feb 2024 14:23:03 +0000 Subject: [PATCH 094/124] Make some spec/feature tests slightly more robust Set the request body from the existing params that we know are valid. Otherwise, the request might fail with an error but the test would pass for the wrong reason. --- spec/features/class_member/creating_a_class_member_spec.rb | 3 ++- spec/features/school_class/creating_a_school_class_spec.rb | 3 ++- spec/features/school_class/updating_a_school_class_spec.rb | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb index 4f9a71ec5..199a5466f 100644 --- a/spec/features/class_member/creating_a_class_member_spec.rb +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -50,8 +50,9 @@ it "responds with nil attributes for the student if their user profile doesn't exist" do student_id = SecureRandom.uuid + new_params = { class_member: params[:class_member].merge(student_id:) } - post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: { class_member: { student_id: } }) + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: new_params) data = JSON.parse(response.body, symbolize_names: true) expect(data[:student_name]).to be_nil diff --git a/spec/features/school_class/creating_a_school_class_spec.rb b/spec/features/school_class/creating_a_school_class_spec.rb index 3366e4ec2..805c5a6be 100644 --- a/spec/features/school_class/creating_a_school_class_spec.rb +++ b/spec/features/school_class/creating_a_school_class_spec.rb @@ -50,8 +50,9 @@ it "responds with nil attributes for the teacher if their user profile doesn't exist" do teacher_id = SecureRandom.uuid + new_params = { school_class: params[:school_class].merge(teacher_id:) } - post("/api/schools/#{school.id}/classes", headers:, params: { school_class: { teacher_id: } }) + post("/api/schools/#{school.id}/classes", headers:, params: new_params) data = JSON.parse(response.body, symbolize_names: true) expect(data[:teacher_name]).to be_nil diff --git a/spec/features/school_class/updating_a_school_class_spec.rb b/spec/features/school_class/updating_a_school_class_spec.rb index c3522b2dc..b9338204b 100644 --- a/spec/features/school_class/updating_a_school_class_spec.rb +++ b/spec/features/school_class/updating_a_school_class_spec.rb @@ -50,8 +50,9 @@ it "responds with nil attributes for the teacher if their user profile doesn't exist" do teacher_id = SecureRandom.uuid + new_params = { school_class: params[:school_class].merge(teacher_id:) } - put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params: { school_class: { teacher_id: } }) + put("/api/schools/#{school.id}/classes/#{school_class.id}", headers:, params: new_params) data = JSON.parse(response.body, symbolize_names: true) expect(data[:teacher_name]).to be_nil From 896d10ad56952356fe357364bbbf5097d474f348 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 20 Feb 2024 14:26:11 +0000 Subject: [PATCH 095/124] =?UTF-8?q?Include=20the=20lesson=E2=80=99s=20user?= =?UTF-8?q?=20name=20in=20the=20JSON=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is so that we can present the user attributed to the lesson. This might not necessarily be a school-teacher if it is a public lesson authored by Raspberry Pi. --- app/controllers/api/lessons_controller.rb | 7 ++++++- app/views/api/lessons/show.json.jbuilder | 6 +++++- spec/features/lesson/creating_a_lesson_spec.rb | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index b7af580b4..ed02f049a 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -9,13 +9,18 @@ def create result = Lesson::Create.call(lesson_params:) if result.success? - @lesson = result[:lesson] + @lesson_with_user = result[:lesson].with_user render :show, formats: [:json], status: :created else render json: { error: result[:error] }, status: :unprocessable_entity end end + def show + @lesson_with_user = @lesson.with_user + render :show, formats: [:json], status: :ok + end + private def lesson_params diff --git a/app/views/api/lessons/show.json.jbuilder b/app/views/api/lessons/show.json.jbuilder index 1a20ecca5..7e3684cd6 100644 --- a/app/views/api/lessons/show.json.jbuilder +++ b/app/views/api/lessons/show.json.jbuilder @@ -1,7 +1,9 @@ # frozen_string_literal: true +lesson, user = @lesson_with_user + json.call( - @lesson, + lesson, :id, :school_id, :school_class_id, @@ -12,3 +14,5 @@ json.call( :created_at, :updated_at ) + +json.user_name(user&.name) diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 29c9075eb..5148d77b2 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -30,6 +30,13 @@ expect(data[:name]).to eq('Test Lesson') end + it 'responds with the user JSON which is set from the current user' do + post('/api/lessons', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Owner') + end + it 'responds 400 Bad Request when params are missing' do post('/api/lessons', headers:) expect(response).to have_http_status(:bad_request) From 6025c031029a763b3b919152623adf843f9aa5e7 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 20 Feb 2024 18:05:02 +0000 Subject: [PATCH 096/124] Allow school-owner users to set the Lesson#user We already have this pattern in SchoolClasses controller so make it so that school-owner users are allowed to set the Lesson#user whereas regular users may not - the lesson is always assigned to themselves. --- app/controllers/api/lessons_controller.rb | 28 ++++++++++++- .../api/school_classes_controller.rb | 2 +- app/controllers/api_controller.rb | 19 ++++++--- .../features/lesson/creating_a_lesson_spec.rb | 41 ++++++++++++++++++- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index ed02f049a..09441f9c4 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -3,6 +3,7 @@ module Api class LessonsController < ApiController before_action :authorize_user + before_action :verify_school_class_belongs_to_school load_and_authorize_resource :lesson def create @@ -23,8 +24,33 @@ def show private + def verify_school_class_belongs_to_school + return if base_params[:school_class_id].blank? + return if school&.classes&.pluck(:id)&.include?(base_params[:school_class_id]) + + raise ParameterError, 'school_class_id does not correspond to school_id' + end + def lesson_params - params.require(:lesson).permit(:school_id, :school_class_id, :name).merge(user_id: current_user.id) + if school_owner? + # A school owner must specify who the lesson user is. + base_params + else + # A school teacher may only create classes they own. + base_params.merge(user_id: current_user.id) + end + end + + def base_params + params.require(:lesson).permit(:school_id, :school_class_id, :user_id, :name) + end + + def school_owner? + school && current_user.school_owner?(organisation_id: school.id) + end + + def school + @school ||= School.find_by(id: base_params[:school_id]) end end end diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 20ab093c3..8be892a22 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -53,7 +53,7 @@ def destroy def school_class_params if school_owner? - # The school owner must specify who the class teacher is. + # A school owner must specify who the class teacher is. params.require(:school_class).permit(:teacher_id, :name) else # A school teacher may only create classes they own. diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 9c1a4f5ba..737d382dd 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -1,29 +1,36 @@ # frozen_string_literal: true class ApiController < ActionController::API + class ::ParameterError < StandardError; end + include Identifiable unless Rails.application.config.consider_all_requests_local rescue_from ActionController::ParameterMissing, with: -> { bad_request } rescue_from ActiveRecord::RecordNotFound, with: -> { not_found } rescue_from CanCan::AccessDenied, with: -> { denied } + rescue_from ParameterError, with: -> { unprocessable } end private + def bad_request + head :bad_request # 400 status + end + def authorize_user - head :unauthorized unless current_user + head :unauthorized unless current_user # 401 status end - def bad_request - head :bad_request + def denied + head :forbidden # 403 status end def not_found - head :not_found + head :not_found # 404 status end - def denied - head :forbidden + def unprocessable + head :unprocessable_entity # 422 status end end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 5148d77b2..4890ad6c6 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -54,12 +54,15 @@ context 'when the lesson is associated with a school (library)' do let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } let(:params) do { lesson: { name: 'Test Lesson', - school_id: school.id + school_id: school.id, + user_id: teacher_id } } end @@ -76,6 +79,23 @@ expect(response).to have_http_status(:created) end + it 'sets the lesson user to the specified user for school-owner users' do + post('/api/lessons', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to eq(teacher_id) + end + + it 'sets the lesson user to the current user for school-teacher users' do + stub_hydra_public_api(user_index: teacher_index) + new_params = { lesson: params[:lesson].merge(user_id: 'ignored') } + + post('/api/lessons', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to eq(teacher_id) + end + it 'responds 403 Forbidden when the user is a school-owner for a different school' do school.update!(id: SecureRandom.uuid) @@ -94,13 +114,16 @@ context 'when the lesson is associated with a school class' do let(:school_class) { create(:school_class) } let(:school) { school_class.school } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } let(:params) do { lesson: { name: 'Test Lesson', school_id: school.id, - school_class_id: school_class.id + school_class_id: school_class.id, + user_id: teacher_id } } end @@ -120,6 +143,20 @@ expect(response).to have_http_status(:created) end + it 'responds 422 Unprocessable if school_id is missing' do + new_params = { lesson: params[:lesson].without(:school_id) } + + post('/api/lessons', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 422 Unprocessable if school_id does not correspond to school_class_id' do + new_params = { lesson: params[:lesson].merge(school_id: SecureRandom.uuid) } + + post('/api/lessons', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + it 'responds 403 Forbidden when the user is a school-owner for a different school' do school = create(:school, id: SecureRandom.uuid) school_class.update!(school_id: school.id) From 218c1e168c8ba31fa7286f00282dca36fc869e6b Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Tue, 20 Feb 2024 18:54:32 +0000 Subject: [PATCH 097/124] Add a Lessons#show controller action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorisation logic varies by #visibility: - ‘private’ lessons can only be seen by the lesson’s user (e.g. a draft) - ‘public’ lessons can be seen by all users (e.g. for Raspberry Pi lessons) - ‘teachers’ lessons can be seen by all teachers within the school - ‘students’ lessons can be seen by students within the school, provided they are a member of the lesson’s class --- app/controllers/api/lessons_controller.rb | 14 +- app/models/ability.rb | 49 +++--- app/models/lesson.rb | 2 +- config/routes.rb | 2 +- .../features/lesson/creating_a_lesson_spec.rb | 2 +- spec/features/lesson/showing_a_lesson_spec.rb | 140 ++++++++++++++++++ spec/models/lesson_spec.rb | 2 +- 7 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 spec/features/lesson/showing_a_lesson_spec.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 09441f9c4..ef7385289 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -2,10 +2,15 @@ module Api class LessonsController < ApiController - before_action :authorize_user - before_action :verify_school_class_belongs_to_school + before_action :authorize_user, except: %i[show] + before_action :verify_school_class_belongs_to_school, except: %i[show] load_and_authorize_resource :lesson + def show + @lesson_with_user = @lesson.with_user + render :show, formats: [:json], status: :ok + end + def create result = Lesson::Create.call(lesson_params:) @@ -17,11 +22,6 @@ def create end end - def show - @lesson_with_user = @lesson.with_user - render :show, formats: [:json], status: :ok - end - private def verify_school_class_belongs_to_school diff --git a/app/models/ability.rb b/app/models/ability.rb index 7652f6902..2b29c8dd0 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -3,9 +3,11 @@ class Ability include CanCan::Ability + # rubocop:disable Metrics/AbcSize def initialize(user) can :show, Project, user_id: nil can :show, Component, project: { user_id: nil } + can :show, Lesson, visibility: 'public' return unless user @@ -13,7 +15,8 @@ def initialize(user) can %i[read create update destroy], Component, project: { user_id: user.id } can :create, School # The user agrees to become a school-owner by creating a school. - can :create, Lesson, school_id: nil, school_class_id: nil # Can create public lessons. + can :create, Lesson, school_id: nil, school_class_id: nil + can :read, Lesson, user_id: user.id user.organisation_ids.each do |organisation_id| define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:) @@ -21,38 +24,42 @@ def initialize(user) define_school_student_abilities(user:, organisation_id:) if user.school_student?(organisation_id:) end end + # rubocop:enable Metrics/AbcSize private def define_school_owner_abilities(organisation_id:) - can %i[read update destroy], School, id: organisation_id - can %i[read create update destroy], SchoolClass, school: { id: organisation_id } - can %i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } } - can %i[read create destroy], :school_owner - can %i[read create destroy], :school_teacher - can %i[read create create_batch update destroy], :school_student - can %i[create], Lesson, school_id: organisation_id + can(%i[read update destroy], School, id: organisation_id) + can(%i[read create update destroy], SchoolClass, school: { id: organisation_id }) + can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id } }) + can(%i[read create destroy], :school_owner) + can(%i[read create destroy], :school_teacher) + can(%i[read create create_batch update destroy], :school_student) + can(%i[create], Lesson, school_id: organisation_id) + can(%i[read], Lesson, school_id: organisation_id, visibility: 'teachers') end def define_school_teacher_abilities(user:, organisation_id:) - can %i[read], School, id: organisation_id - can %i[create], SchoolClass, school: { id: organisation_id } - can %i[read update destroy], SchoolClass, school: { id: organisation_id }, teacher_id: user.id - can %i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id } - can %i[read], :school_owner - can %i[read], :school_teacher - can %i[read create create_batch update], :school_student - can %i[create], Lesson do |lesson| - school_teacher_can_manage?(user:, organisation_id:, lesson:) - end + can(%i[read], School, id: organisation_id) + can(%i[create], SchoolClass, school: { id: organisation_id }) + can(%i[read update destroy], SchoolClass, school: { id: organisation_id }, teacher_id: user.id) + can(%i[read create destroy], ClassMember, school_class: { school: { id: organisation_id }, teacher_id: user.id }) + can(%i[read], :school_owner) + can(%i[read], :school_teacher) + can(%i[read create create_batch update], :school_student) + can(%i[create], Lesson) { |lesson| school_teacher_can_create_lesson?(user:, organisation_id:, lesson:) } + can(%i[read], Lesson, school_id: organisation_id, visibility: 'teachers') end + # rubocop:disable Layout/LineLength def define_school_student_abilities(user:, organisation_id:) - can %i[read], School, id: organisation_id - can %i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id } + can(%i[read], School, id: organisation_id) + can(%i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id }) + can(%i[read], Lesson, school_id: organisation_id, visibility: 'students', school_class: { members: { student_id: user.id } }) end + # rubocop:enable Layout/LineLength - def school_teacher_can_manage?(user:, organisation_id:, lesson:) + def school_teacher_can_create_lesson?(user:, organisation_id:, lesson:) is_my_lesson = lesson.school_id == organisation_id && lesson.user_id == user.id is_my_class = lesson.school_class && lesson.school_class.teacher_id == user.id diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 9a6d5240e..5d935fe73 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -6,7 +6,7 @@ class Lesson < ApplicationRecord validates :user_id, presence: true validates :name, presence: true - validates :visibility, presence: true, inclusion: { in: %w[private school public] } + validates :visibility, presence: true, inclusion: { in: %w[private teachers students public] } before_save :assign_school_from_school_class diff --git a/config/routes.rb b/config/routes.rb index 73181a919..436a0dcf7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,7 +29,7 @@ end end - resources :lessons, only: %i[create] + resources :lessons, only: %i[create show] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 4890ad6c6..2d016c33e 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -90,7 +90,7 @@ stub_hydra_public_api(user_index: teacher_index) new_params = { lesson: params[:lesson].merge(user_id: 'ignored') } - post('/api/lessons', headers:, params:) + post('/api/lessons', headers:, params: new_params) data = JSON.parse(response.body, symbolize_names: true) expect(data[:user_id]).to eq(teacher_id) diff --git a/spec/features/lesson/showing_a_lesson_spec.rb b/spec/features/lesson/showing_a_lesson_spec.rb new file mode 100644 index 000000000..208f6dfd5 --- /dev/null +++ b/spec/features/lesson/showing_a_lesson_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing a lesson', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'public') } + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + it 'responds 200 OK' do + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when no token is given' do + get "/api/lessons/#{lesson.id}" + expect(response).to have_http_status(:ok) + end + + it 'responds with the lesson JSON' do + get("/api/lessons/#{lesson.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Lesson') + end + + it 'responds with the user JSON' do + get("/api/lessons/#{lesson.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Teacher') + end + + it "responds with nil attributes for the user if their user profile doesn't exist" do + lesson.update!(user_id: SecureRandom.uuid) + + get("/api/lessons/#{lesson.id}", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to be_nil + end + + it 'responds 404 Not Found when no lesson exists' do + get('/api/lessons/not-a-real-id', headers:) + expect(response).to have_http_status(:not_found) + end + + context "when the lesson's visibility is 'private'" do + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'private') } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'responds 200 OK when the user owns the lesson' do + lesson.update!(user_id: owner_id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 403 Forbidden when the user does not own the lesson' do + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context "when the lesson's visibility is 'teachers'" do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers') } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'responds 200 OK when the user owns the lesson' do + lesson.update!(user_id: owner_id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is a school-owner or school-teacher within the school' do + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end + + context "when the lesson's visibility is 'students'" do + let(:school_class) { create(:school_class) } + let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'students') } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'responds 200 OK when the user owns the lesson' do + lesson.update!(user_id: owner_id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it "responds 200 OK when the user is a school-student within the lesson's class" do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + create(:class_member, school_class:) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:ok) + end + + it "responds 403 Forbidden when the user is not a school-student within the lesson's class" do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 3cd7819a5..92e0413da 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -52,7 +52,7 @@ expect(lesson).to be_invalid end - it "requires a visibility that is either 'private', 'school' or 'public'" do + it "requires a visibility that is either 'private', 'teachers', 'students' or 'public'" do lesson.visibility = 'invalid' expect(lesson).to be_invalid end From 360d242cb6fa2ef1f94ee5d6bda1c98db7605094 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 12:43:07 +0000 Subject: [PATCH 098/124] =?UTF-8?q?Validate=20the=20a=20lesson=E2=80=99s?= =?UTF-8?q?=20user=20is=20a=20school-owner=20or=20school-teacher=20if=20th?= =?UTF-8?q?ere=20is=20a=20school?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/lesson.rb | 19 +++++++++++++++++-- spec/models/lesson_spec.rb | 11 +++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 5d935fe73..28dd79cf2 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -4,11 +4,12 @@ class Lesson < ApplicationRecord belongs_to :school, optional: true belongs_to :school_class, optional: true + before_validation :assign_school_from_school_class + validates :user_id, presence: true validates :name, presence: true validates :visibility, presence: true, inclusion: { in: %w[private teachers students public] } - - before_save :assign_school_from_school_class + validate :user_has_the_school_owner_or_school_teacher_role_for_the_school def self.users User.from_userinfo(ids: pluck(:user_id)) @@ -28,4 +29,18 @@ def with_user def assign_school_from_school_class self.school ||= school_class&.school end + + # rubocop:disable Metrics/AbcSize + def user_has_the_school_owner_or_school_teacher_role_for_the_school + return unless user_id_changed? && errors.blank? && school + + _, user = with_user + + return if user.blank? + return if user.school_owner?(organisation_id: school.id) + return if user.school_teacher?(organisation_id: school.id) + + errors.add(:user, "'#{user_id}' does not have the 'school-teacher' role for organisation '#{school.id}'") + end + # rubocop:enable Metrics/AbcSize end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 92e0413da..518e6c690 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -42,6 +42,17 @@ expect(lesson).to be_invalid end + context 'when the lesson has a school' do + before do + lesson.update!(school_class: create(:school_class)) + end + + it 'requires a user that has the school-owner or school-teacher role for the school' do + lesson.user_id = '22222222-2222-2222-2222-222222222222' # school-student + expect(lesson).to be_invalid + end + end + it 'requires a name' do lesson.name = ' ' expect(lesson).to be_invalid From fe5e449fa0a3e78aff95d9c6ec84c8a343f91f82 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 13:04:28 +0000 Subject: [PATCH 099/124] =?UTF-8?q?Validate=20the=20a=20lesson=E2=80=99s?= =?UTF-8?q?=20user=20is=20the=20school-teacher=20for=20the=20school=5Fclas?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/lesson.rb | 11 ++++++++++- spec/features/lesson/showing_a_lesson_spec.rb | 7 ++++--- spec/models/lesson_spec.rb | 13 ++++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 28dd79cf2..22cbd3dc1 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -9,7 +9,9 @@ class Lesson < ApplicationRecord validates :user_id, presence: true validates :name, presence: true validates :visibility, presence: true, inclusion: { in: %w[private teachers students public] } + validate :user_has_the_school_owner_or_school_teacher_role_for_the_school + validate :user_is_the_school_teacher_for_the_school_class def self.users User.from_userinfo(ids: pluck(:user_id)) @@ -40,7 +42,14 @@ def user_has_the_school_owner_or_school_teacher_role_for_the_school return if user.school_owner?(organisation_id: school.id) return if user.school_teacher?(organisation_id: school.id) - errors.add(:user, "'#{user_id}' does not have the 'school-teacher' role for organisation '#{school.id}'") + msg = "'#{user_id}' does not have the 'school-owner' or 'school-teacher' role for organisation '#{school.id}'" + errors.add(:user, msg) end # rubocop:enable Metrics/AbcSize + + def user_is_the_school_teacher_for_the_school_class + return if !school_class || user_id == school_class.teacher_id + + errors.add(:user, "'#{user_id}' is not the 'school-teacher' for school_class '#{school_class.id}'") + end end diff --git a/spec/features/lesson/showing_a_lesson_spec.rb b/spec/features/lesson/showing_a_lesson_spec.rb index 208f6dfd5..a862ee655 100644 --- a/spec/features/lesson/showing_a_lesson_spec.rb +++ b/spec/features/lesson/showing_a_lesson_spec.rb @@ -104,11 +104,12 @@ context "when the lesson's visibility is 'students'" do let(:school_class) { create(:school_class) } let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'students') } - let(:owner_index) { user_index_by_role('school-owner') } - let(:owner_id) { user_id_by_index(owner_index) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } it 'responds 200 OK when the user owns the lesson' do - lesson.update!(user_id: owner_id) + stub_hydra_public_api(user_index: teacher_index) + lesson.update!(user_id: teacher_id) get("/api/lessons/#{lesson.id}", headers:) expect(response).to have_http_status(:ok) diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 518e6c690..5404c9d8e 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -44,7 +44,7 @@ context 'when the lesson has a school' do before do - lesson.update!(school_class: create(:school_class)) + lesson.update!(school: create(:school)) end it 'requires a user that has the school-owner or school-teacher role for the school' do @@ -53,6 +53,17 @@ end end + context 'when the lesson has a school_class' do + before do + lesson.update!(school_class: create(:school_class)) + end + + it 'requires a user that is the school-teacher for the school_class' do + lesson.user_id = '00000000-0000-0000-0000-000000000000' # school-owner + expect(lesson).to be_invalid + end + end + it 'requires a name' do lesson.name = ' ' expect(lesson).to be_invalid From 97be5fd599c6106d6206b1596e6e673eebc9be0c Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 14:06:58 +0000 Subject: [PATCH 100/124] Add a Lessons#index controller action --- app/controllers/api/lessons_controller.rb | 9 +- app/models/ability.rb | 2 +- app/views/api/lessons/index.json.jbuilder | 18 +++ config/routes.rb | 2 +- spec/features/lesson/listing_lessons_spec.rb | 156 +++++++++++++++++++ 5 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 app/views/api/lessons/index.json.jbuilder create mode 100644 spec/features/lesson/listing_lessons_spec.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index ef7385289..21956f5c4 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -2,10 +2,15 @@ module Api class LessonsController < ApiController - before_action :authorize_user, except: %i[show] - before_action :verify_school_class_belongs_to_school, except: %i[show] + before_action :authorize_user, except: %i[index show] + before_action :verify_school_class_belongs_to_school, except: %i[index show] load_and_authorize_resource :lesson + def index + @lessons_with_users = Lesson.accessible_by(current_ability).with_users + render :index, formats: [:json], status: :ok + end + def show @lesson_with_user = @lesson.with_user render :show, formats: [:json], status: :ok diff --git a/app/models/ability.rb b/app/models/ability.rb index 2b29c8dd0..5496f4613 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -7,7 +7,7 @@ class Ability def initialize(user) can :show, Project, user_id: nil can :show, Component, project: { user_id: nil } - can :show, Lesson, visibility: 'public' + can :read, Lesson, visibility: 'public' return unless user diff --git a/app/views/api/lessons/index.json.jbuilder b/app/views/api/lessons/index.json.jbuilder new file mode 100644 index 000000000..32d4e0fdb --- /dev/null +++ b/app/views/api/lessons/index.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.array!(@lessons_with_users) do |lesson, user| + json.call( + lesson, + :id, + :school_id, + :school_class_id, + :user_id, + :name, + :visibility, + :due_date, + :created_at, + :updated_at + ) + + json.user_name(user&.name) +end diff --git a/config/routes.rb b/config/routes.rb index 436a0dcf7..913dab977 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,7 +29,7 @@ end end - resources :lessons, only: %i[create show] + resources :lessons, only: %i[index create show] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/spec/features/lesson/listing_lessons_spec.rb b/spec/features/lesson/listing_lessons_spec.rb new file mode 100644 index 000000000..ad139e2af --- /dev/null +++ b/spec/features/lesson/listing_lessons_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Listing lessons', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'public') } + + it 'responds 200 OK' do + get('/api/lessons', headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when no token is given' do + get '/api/lessons' + expect(response).to have_http_status(:ok) + end + + it 'responds with the lessons JSON' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test Lesson') + end + + it 'responds with the user JSON' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:user_name]).to eq('School Teacher') + end + + it "responds with nil attributes for the user if their user profile doesn't exist" do + lesson.update!(user_id: SecureRandom.uuid) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:user_name]).to be_nil + end + + context "when the lesson's visibility is 'private'" do + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'private') } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'includes the lesson when the user owns the lesson' do + lesson.update!(user_id: owner_id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'does not include the lesson whent he user does not own the lesson' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + end + + context "when the lesson's visibility is 'teachers'" do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers') } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'includes the lesson when the user owns the lesson' do + lesson.update!(user_id: owner_id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'includes the lesson when the user is a school-owner or school-teacher within the school' do + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'does not include the lesson when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'does not include the lesson when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + end + + context "when the lesson's visibility is 'students'" do + let(:school_class) { create(:school_class) } + let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'students') } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + it 'includes the lesson when the user owns the lesson' do + stub_hydra_public_api(user_index: teacher_index) + lesson.update!(user_id: teacher_id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it "includes the lesson when the user is a school-student within the lesson's class" do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + create(:class_member, school_class:) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it "does not include the lesson when the user is not a school-student within the lesson's class" do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'does not include the lesson when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + end +end From fc749de733a2a6bf2545a503d2134aae120c680d Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 16:43:19 +0000 Subject: [PATCH 101/124] Add a Lessons#update controller action --- app/controllers/api/lessons_controller.rb | 15 +- app/models/ability.rb | 7 +- config/routes.rb | 2 +- lib/concepts/lesson/operations/update.rb | 20 +++ .../features/lesson/creating_a_lesson_spec.rb | 9 +- .../features/lesson/updating_a_lesson_spec.rb | 132 ++++++++++++++++++ 6 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 lib/concepts/lesson/operations/update.rb create mode 100644 spec/features/lesson/updating_a_lesson_spec.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 21956f5c4..2658a5818 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -3,7 +3,7 @@ module Api class LessonsController < ApiController before_action :authorize_user, except: %i[index show] - before_action :verify_school_class_belongs_to_school, except: %i[index show] + before_action :verify_school_class_belongs_to_school, only: :create load_and_authorize_resource :lesson def index @@ -27,6 +27,17 @@ def create end end + def update + result = Lesson::Update.call(lesson: @lesson, lesson_params:) + + if result.success? + @lesson_with_user = result[:lesson].with_user + render :show, formats: [:json], status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def verify_school_class_belongs_to_school @@ -55,7 +66,7 @@ def school_owner? end def school - @school ||= School.find_by(id: base_params[:school_id]) + @school ||= @lesson&.school || School.find_by(id: base_params[:school_id]) end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 5496f4613..42837dfde 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -16,7 +16,7 @@ def initialize(user) can :create, School # The user agrees to become a school-owner by creating a school. can :create, Lesson, school_id: nil, school_class_id: nil - can :read, Lesson, user_id: user.id + can %i[read update], Lesson, user_id: user.id user.organisation_ids.each do |organisation_id| define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:) @@ -36,7 +36,8 @@ def define_school_owner_abilities(organisation_id:) can(%i[read create destroy], :school_teacher) can(%i[read create create_batch update destroy], :school_student) can(%i[create], Lesson, school_id: organisation_id) - can(%i[read], Lesson, school_id: organisation_id, visibility: 'teachers') + can(%i[read update], Lesson, school_id: organisation_id, visibility: %w[teachers students]) + can(%i[update], Lesson, school_id: organisation_id, visibility: 'public') end def define_school_teacher_abilities(user:, organisation_id:) @@ -48,7 +49,7 @@ def define_school_teacher_abilities(user:, organisation_id:) can(%i[read], :school_teacher) can(%i[read create create_batch update], :school_student) can(%i[create], Lesson) { |lesson| school_teacher_can_create_lesson?(user:, organisation_id:, lesson:) } - can(%i[read], Lesson, school_id: organisation_id, visibility: 'teachers') + can(%i[read], Lesson, school_id: organisation_id, visibility: %w[teachers students]) end # rubocop:disable Layout/LineLength diff --git a/config/routes.rb b/config/routes.rb index 913dab977..f204a13a8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,7 +29,7 @@ end end - resources :lessons, only: %i[index create show] + resources :lessons, only: %i[index create show update] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/lib/concepts/lesson/operations/update.rb b/lib/concepts/lesson/operations/update.rb new file mode 100644 index 000000000..8f4de6c02 --- /dev/null +++ b/lib/concepts/lesson/operations/update.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Lesson + class Update + class << self + def call(lesson:, lesson_params:) + response = OperationResponse.new + response[:lesson] = lesson + response[:lesson].assign_attributes(lesson_params) + response[:lesson].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:lesson].errors.full_messages.join(',') + response[:error] = "Error updating lesson: #{errors}" + response + end + end + end +end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 2d016c33e..2922196c7 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -165,12 +165,19 @@ expect(response).to have_http_status(:forbidden) end - it 'responds 403 Forbidden when the user is a school-teacher for a different class' do + it 'responds 403 Forbidden when the current user is a school-teacher for a different class' do stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) school_class.update!(teacher_id: SecureRandom.uuid) post('/api/lessons', headers:, params:) expect(response).to have_http_status(:forbidden) end + + it 'responds 422 Unprocessable Entity when the user_id is a school-teacher for a different class' do + new_params = { lesson: params[:lesson].merge(user_id: SecureRandom.uuid) } + + post('/api/lessons', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end end end diff --git a/spec/features/lesson/updating_a_lesson_spec.rb b/spec/features/lesson/updating_a_lesson_spec.rb new file mode 100644 index 000000000..ead0f4619 --- /dev/null +++ b/spec/features/lesson/updating_a_lesson_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a lesson', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:lesson) { create(:lesson, name: 'Test Lesson', user_id: owner_id) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + let(:params) do + { + lesson: { + name: 'New Name' + } + } + end + + it 'responds 200 OK' do + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the lesson JSON' do + put("/api/lessons/#{lesson.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds with the user JSON' do + put("/api/lessons/#{lesson.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Owner') + end + + it 'responds 400 Bad Request when params are missing' do + put("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:bad_request) + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/lessons/#{lesson.id}", headers:, params: { lesson: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/lessons/#{lesson.id}", params:) + expect(response).to have_http_status(:unauthorized) + end + + it "responds 403 Forbidden when the user is not the lesson's owner" do + lesson.update!(user_id: SecureRandom.uuid) + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + context 'when the lesson is associated with a school (library)' do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers') } + + it 'responds 200 OK when the user is a school-owner' do + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when assigning the lesson to a school class' do + school_class = create(:school_class, school:) + + new_params = { lesson: params[:lesson].merge(school_class_id: school_class.id) } + put("/api/lessons/#{lesson.id}", headers:, params: new_params) + + expect(response).to have_http_status(:ok) + end + + it "responds 403 Forbidden when the user a school-owner but visibility is 'private'" do + lesson.update!(visibility: 'private') + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is another school-teacher in the school' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + lesson.update!(user_id: SecureRandom.uuid) + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when the lesson is associated with a school class' do + let(:school_class) { create(:school_class) } + let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'students') } + + it 'responds 200 OK when the user is a school-owner' do + put("/api/lessons/#{lesson.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds 422 Unprocessable Entity when trying to re-assign the lesson to a different class' do + school = create(:school, id: SecureRandom.uuid) + school_class = create(:school_class, school:, teacher_id: SecureRandom.uuid) + + new_params = { lesson: params[:lesson].merge(school_class_id: school_class.id) } + put("/api/lessons/#{lesson.id}", headers:, params: new_params) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 422 Unprocessable Entity when trying to re-assign the lesson to a different user' do + new_params = { lesson: params[:lesson].merge(user_id: owner_id) } + put("/api/lessons/#{lesson.id}", headers:, params: new_params) + + expect(response).to have_http_status(:unprocessable_entity) + end + end +end From ef6458ba52f80954e200df2795f778ea660104c2 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 17:00:01 +0000 Subject: [PATCH 102/124] Add unit tests for Lesson::Create and Lesson::Update --- spec/concepts/lesson/create_spec.rb | 67 +++++++++++++++++++++++++++++ spec/concepts/lesson/update_spec.rb | 58 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 spec/concepts/lesson/create_spec.rb create mode 100644 spec/concepts/lesson/update_spec.rb diff --git a/spec/concepts/lesson/create_spec.rb b/spec/concepts/lesson/create_spec.rb new file mode 100644 index 000000000..f44b18661 --- /dev/null +++ b/spec/concepts/lesson/create_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Create, type: :unit do + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let(:lesson_params) do + { name: 'Test Lesson', user_id: teacher_id } + end + + before do + stub_user_info_api + end + + it 'returns a successful operation response' do + response = described_class.call(lesson_params:) + expect(response.success?).to be(true) + end + + it 'creates a lesson' do + expect { described_class.call(lesson_params:) }.to change(Lesson, :count).by(1) + end + + it 'returns the lesson in the operation response' do + response = described_class.call(lesson_params:) + expect(response[:lesson]).to be_a(Lesson) + end + + it 'assigns the name' do + response = described_class.call(lesson_params:) + expect(response[:lesson].name).to eq('Test Lesson') + end + + it 'assigns the user_id' do + response = described_class.call(lesson_params:) + expect(response[:lesson].user_id).to eq(teacher_id) + end + + context 'when creation fails' do + let(:lesson_params) { {} } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a lesson' do + expect { described_class.call(lesson_params:) }.not_to change(Lesson, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(lesson_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(lesson_params:) + expect(response[:error]).to match(/Error creating lesson/) + end + + it 'sent the exception to Sentry' do + described_class.call(lesson_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/concepts/lesson/update_spec.rb b/spec/concepts/lesson/update_spec.rb new file mode 100644 index 000000000..61cca3457 --- /dev/null +++ b/spec/concepts/lesson/update_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Update, type: :unit do + let(:lesson) { create(:lesson, name: 'Test Lesson') } + + let(:lesson_params) do + { name: 'New Name' } + end + + before do + stub_user_info_api + end + + it 'returns a successful operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.success?).to be(true) + end + + it 'updates the lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].name).to eq('New Name') + end + + it 'returns the lesson in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson]).to be_a(Lesson) + end + + context 'when updating fails' do + let(:lesson_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not update the lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].reload.name).to eq('Test Lesson') + end + + it 'returns a failed operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:error]).to match(/Error updating lesson/) + end + + it 'sent the exception to Sentry' do + described_class.call(lesson:, lesson_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end From 95b8ec0ffa0e71dbec81f5949650e98c0ebf92fa Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 17:14:01 +0000 Subject: [PATCH 103/124] Prevent lessons from being destroyed More context: https://raspberrypifoundation.slack.com/archives/C068W8321PB/p1708506719840249?thread_ts=1708456870.645179&cid=C068W8321PB --- app/models/lesson.rb | 2 ++ spec/models/lesson_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 22cbd3dc1..c65eb2e61 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -13,6 +13,8 @@ class Lesson < ApplicationRecord validate :user_has_the_school_owner_or_school_teacher_role_for_the_school validate :user_is_the_school_teacher_for_the_school_class + before_destroy -> { throw :abort } + def self.users User.from_userinfo(ids: pluck(:user_id)) end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 5404c9d8e..9a8b1e337 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -21,6 +21,13 @@ end end + describe 'callbacks' do + it 'cannot be destroyed and should be archived instead' do + lesson = create(:lesson) + expect { lesson.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) + end + end + describe 'validations' do subject(:lesson) { build(:lesson) } From 6532a245d58e2d2358542703d4c6267536db8934 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 17:33:31 +0000 Subject: [PATCH 104/124] Add Lesson#archive! and #unarchive! methods --- app/models/lesson.rb | 18 ++++++ db/migrate/20240217144009_create_lessons.rb | 2 + db/schema.rb | 2 + spec/models/lesson_spec.rb | 63 +++++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index c65eb2e61..bcac19796 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -28,6 +28,24 @@ def with_user [self, User.from_userinfo(ids: user_id).first] end + def archived? + archived_at.present? + end + + def archive! + return if archived? + + self.archived_at = Time.now.utc + save!(validate: false) + end + + def unarchive! + return unless archived? + + self.archived_at = nil + save!(validate: false) + end + private def assign_school_from_school_class diff --git a/db/migrate/20240217144009_create_lessons.rb b/db/migrate/20240217144009_create_lessons.rb index eebe2f256..adeaed5cc 100644 --- a/db/migrate/20240217144009_create_lessons.rb +++ b/db/migrate/20240217144009_create_lessons.rb @@ -10,11 +10,13 @@ def change t.string :visibility, null: false, default: 'private' t.datetime :due_date + t.datetime :archived_at t.timestamps end add_index :lessons, :user_id add_index :lessons, :name add_index :lessons, :visibility + add_index :lessons, :archived_at end end diff --git a/db/schema.rb b/db/schema.rb index 615a51443..8c4bd621c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -132,8 +132,10 @@ t.string "description" t.string "visibility", default: "private", null: false t.datetime "due_date" + t.datetime "archived_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["archived_at"], name: "index_lessons_on_archived_at" t.index ["name"], name: "index_lessons_on_name" t.index ["school_class_id"], name: "index_lessons_on_school_class_id" t.index ["school_id"], name: "index_lessons_on_school_id" diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 9a8b1e337..f52fa485f 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -164,4 +164,67 @@ expect(pair).to eq([lesson, nil]) end end + + describe '#archive!' do + let(:lesson) { build(:lesson) } + + it 'archives the lesson' do + lesson.archive! + expect(lesson.archived?).to be(true) + end + + it 'sets archived_at' do + lesson.archive! + expect(lesson.archived_at).to be_present + end + + it 'does not set archived_at if it was already set' do + lesson.update!(archived_at: 1.day.ago) + + lesson.archive! + expect(lesson.archived_at).to be < 23.hours.ago + end + + it 'saves the record' do + lesson.archive! + expect(lesson).to be_persisted + end + + it 'is infallible to other validation errors' do + lesson.save! + lesson.name = ' ' + lesson.save!(validate: false) + + lesson.archive! + expect(lesson.archived?).to be(true) + end + end + + describe '#unarchive!' do + let(:lesson) { build(:lesson, archived_at: Time.now.utc) } + + it 'unarchives the lesson' do + lesson.unarchive! + expect(lesson.archived?).to be(false) + end + + it 'clears archived_at' do + lesson.unarchive! + expect(lesson.archived_at).to be_nil + end + + it 'saves the record' do + lesson.unarchive! + expect(lesson).to be_persisted + end + + it 'is infallible to other validation errors' do + lesson.archive! + lesson.name = ' ' + lesson.save!(validate: false) + + lesson.unarchive! + expect(lesson.archived?).to be(false) + end + end end From e25c3f4c982f4fb6fdd290907cdfb5f9ce882b2c Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 17:59:04 +0000 Subject: [PATCH 105/124] Add a Lessons#destroy controller action --- app/controllers/api/lessons_controller.rb | 11 +++ app/models/ability.rb | 9 +- app/views/api/lessons/index.json.jbuilder | 1 + app/views/api/lessons/show.json.jbuilder | 1 + config/routes.rb | 2 +- lib/concepts/lesson/operations/archive.rb | 17 ++++ lib/concepts/lesson/operations/unarchive.rb | 17 ++++ spec/concepts/lesson/archive_spec.rb | 21 +++++ spec/concepts/lesson/unarchive_spec.rb | 21 +++++ .../lesson/archiving_a_lesson_spec.rb | 83 +++++++++++++++++++ 10 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 lib/concepts/lesson/operations/archive.rb create mode 100644 lib/concepts/lesson/operations/unarchive.rb create mode 100644 spec/concepts/lesson/archive_spec.rb create mode 100644 spec/concepts/lesson/unarchive_spec.rb create mode 100644 spec/features/lesson/archiving_a_lesson_spec.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 2658a5818..203cd5843 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -38,6 +38,17 @@ def update end end + def destroy + operation = params[:undo] == 'true' ? Lesson::Unarchive : Lesson::Archive + result = operation.call(lesson: @lesson) + + if result.success? + head :no_content + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + private def verify_school_class_belongs_to_school diff --git a/app/models/ability.rb b/app/models/ability.rb index 42837dfde..39888b609 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -16,7 +16,7 @@ def initialize(user) can :create, School # The user agrees to become a school-owner by creating a school. can :create, Lesson, school_id: nil, school_class_id: nil - can %i[read update], Lesson, user_id: user.id + can %i[read update destroy], Lesson, user_id: user.id user.organisation_ids.each do |organisation_id| define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:) @@ -36,8 +36,7 @@ def define_school_owner_abilities(organisation_id:) can(%i[read create destroy], :school_teacher) can(%i[read create create_batch update destroy], :school_student) can(%i[create], Lesson, school_id: organisation_id) - can(%i[read update], Lesson, school_id: organisation_id, visibility: %w[teachers students]) - can(%i[update], Lesson, school_id: organisation_id, visibility: 'public') + can(%i[read update destroy], Lesson, school_id: organisation_id, visibility: %w[teachers students public]) end def define_school_teacher_abilities(user:, organisation_id:) @@ -48,7 +47,7 @@ def define_school_teacher_abilities(user:, organisation_id:) can(%i[read], :school_owner) can(%i[read], :school_teacher) can(%i[read create create_batch update], :school_student) - can(%i[create], Lesson) { |lesson| school_teacher_can_create_lesson?(user:, organisation_id:, lesson:) } + can(%i[create destroy], Lesson) { |lesson| school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) } can(%i[read], Lesson, school_id: organisation_id, visibility: %w[teachers students]) end @@ -60,7 +59,7 @@ def define_school_student_abilities(user:, organisation_id:) end # rubocop:enable Layout/LineLength - def school_teacher_can_create_lesson?(user:, organisation_id:, lesson:) + def school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) is_my_lesson = lesson.school_id == organisation_id && lesson.user_id == user.id is_my_class = lesson.school_class && lesson.school_class.teacher_id == user.id diff --git a/app/views/api/lessons/index.json.jbuilder b/app/views/api/lessons/index.json.jbuilder index 32d4e0fdb..c177e181d 100644 --- a/app/views/api/lessons/index.json.jbuilder +++ b/app/views/api/lessons/index.json.jbuilder @@ -10,6 +10,7 @@ json.array!(@lessons_with_users) do |lesson, user| :name, :visibility, :due_date, + :archived_at, :created_at, :updated_at ) diff --git a/app/views/api/lessons/show.json.jbuilder b/app/views/api/lessons/show.json.jbuilder index 7e3684cd6..4814a45bd 100644 --- a/app/views/api/lessons/show.json.jbuilder +++ b/app/views/api/lessons/show.json.jbuilder @@ -12,6 +12,7 @@ json.call( :visibility, :due_date, :created_at, + :archived_at, :updated_at ) diff --git a/config/routes.rb b/config/routes.rb index f204a13a8..e33525830 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,7 +29,7 @@ end end - resources :lessons, only: %i[index create show update] + resources :lessons, only: %i[index create show update destroy] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/lib/concepts/lesson/operations/archive.rb b/lib/concepts/lesson/operations/archive.rb new file mode 100644 index 000000000..3bd8bd547 --- /dev/null +++ b/lib/concepts/lesson/operations/archive.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Lesson + class Archive + class << self + def call(lesson:) + response = OperationResponse.new + lesson.archive! + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error archiving lesson: #{e}" + response + end + end + end +end diff --git a/lib/concepts/lesson/operations/unarchive.rb b/lib/concepts/lesson/operations/unarchive.rb new file mode 100644 index 000000000..f7c3170dc --- /dev/null +++ b/lib/concepts/lesson/operations/unarchive.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Lesson + class Unarchive + class << self + def call(lesson:) + response = OperationResponse.new + lesson.unarchive! + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error unarchiving lesson: #{e}" + response + end + end + end +end diff --git a/spec/concepts/lesson/archive_spec.rb b/spec/concepts/lesson/archive_spec.rb new file mode 100644 index 000000000..09bc40e2c --- /dev/null +++ b/spec/concepts/lesson/archive_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Archive, type: :unit do + before do + stub_user_info_api + end + + let(:lesson) { create(:lesson) } + + it 'returns a successful operation response' do + response = described_class.call(lesson:) + expect(response.success?).to be(true) + end + + it 'archives the lesson' do + described_class.call(lesson:) + expect(lesson.reload.archived?).to be(true) + end +end diff --git a/spec/concepts/lesson/unarchive_spec.rb b/spec/concepts/lesson/unarchive_spec.rb new file mode 100644 index 000000000..31ade2c8b --- /dev/null +++ b/spec/concepts/lesson/unarchive_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::Unarchive, type: :unit do + before do + stub_user_info_api + end + + let(:lesson) { create(:lesson, archived_at: Time.now.utc) } + + it 'returns a successful operation response' do + response = described_class.call(lesson:) + expect(response.success?).to be(true) + end + + it 'unarchives the lesson' do + described_class.call(lesson:) + expect(lesson.reload.archived?).to be(false) + end +end diff --git a/spec/features/lesson/archiving_a_lesson_spec.rb b/spec/features/lesson/archiving_a_lesson_spec.rb new file mode 100644 index 000000000..ab6f5c950 --- /dev/null +++ b/spec/features/lesson/archiving_a_lesson_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Archiving a lesson', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:lesson) { create(:lesson, user_id: owner_id) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'responds 204 No Content' do + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'responds 204 No Content if the lesson is already archived' do + lesson.archive! + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it 'archives the lesson' do + delete("/api/lessons/#{lesson.id}", headers:) + expect(lesson.reload.archived?).to be(true) + end + + it 'unarchives the lesson when the ?undo=true query parameter is set' do + lesson.archive! + + delete("/api/lessons/#{lesson.id}?undo=true", headers:) + expect(lesson.reload.archived?).to be(false) + end + + it 'responds 401 Unauthorized when no token is given' do + delete "/api/lessons/#{lesson.id}" + expect(response).to have_http_status(:unauthorized) + end + + it "responds 403 Forbidden when the user is not the lesson's owner" do + lesson.update!(user_id: SecureRandom.uuid) + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'when the lesson is associated with a school (library)' do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, visibility: 'teachers') } + + it 'responds 204 No Content when the user is a school-owner' do + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:no_content) + end + + it "responds 403 Forbidden when the user a school-owner but visibility is 'private'" do + lesson.update!(visibility: 'private') + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is another school-teacher in the school' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + lesson.update!(user_id: SecureRandom.uuid) + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + delete("/api/lessons/#{lesson.id}", headers:) + expect(response).to have_http_status(:forbidden) + end + end +end From 2f64dfa6221b06940436adcbdc2d4e9a839b5bb6 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 18:15:56 +0000 Subject: [PATCH 106/124] Add Lesson.archived and Lesson.unarchived scopes --- app/models/lesson.rb | 4 +++- spec/models/lesson_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index bcac19796..2d10a84bd 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -5,6 +5,7 @@ class Lesson < ApplicationRecord belongs_to :school_class, optional: true before_validation :assign_school_from_school_class + before_destroy -> { throw :abort } validates :user_id, presence: true validates :name, presence: true @@ -13,7 +14,8 @@ class Lesson < ApplicationRecord validate :user_has_the_school_owner_or_school_teacher_role_for_the_school validate :user_is_the_school_teacher_for_the_school_class - before_destroy -> { throw :abort } + scope :archived, -> { where.not(archived_at: nil) } + scope :unarchived, -> { where(archived_at: nil) } def self.users User.from_userinfo(ids: pluck(:user_id)) diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index f52fa485f..6de55c1b1 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -87,6 +87,32 @@ end end + describe '.archived' do + let!(:archived_lesson) { create(:lesson, archived_at: Time.now.utc) } + let!(:unarchived_lesson) { create(:lesson) } + + it 'includes archived lessons' do + expect(described_class.archived).to include(archived_lesson) + end + + it 'excludes unarchived lessons' do + expect(described_class.archived).not_to include(unarchived_lesson) + end + end + + describe '.unarchived' do + let!(:archived_lesson) { create(:lesson, archived_at: Time.now.utc) } + let!(:unarchived_lesson) { create(:lesson) } + + it 'includes unarchived lessons' do + expect(described_class.unarchived).to include(unarchived_lesson) + end + + it 'excludes archived lessons' do + expect(described_class.unarchived).not_to include(archived_lesson) + end + end + describe '#school' do it 'is set from the school_class' do lesson = create(:lesson, school_class: build(:school_class)) From b60eaae4fa3a22ae8a8939e0e74009f7634c4ed5 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 18:18:23 +0000 Subject: [PATCH 107/124] Take archived_at into account when listing lessons --- app/controllers/api/lessons_controller.rb | 3 ++- spec/features/lesson/listing_lessons_spec.rb | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 203cd5843..925bb37a8 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -7,7 +7,8 @@ class LessonsController < ApiController load_and_authorize_resource :lesson def index - @lessons_with_users = Lesson.accessible_by(current_ability).with_users + scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived + @lessons_with_users = scope.accessible_by(current_ability).with_users render :index, formats: [:json], status: :ok end diff --git a/spec/features/lesson/listing_lessons_spec.rb b/spec/features/lesson/listing_lessons_spec.rb index ad139e2af..bae920b85 100644 --- a/spec/features/lesson/listing_lessons_spec.rb +++ b/spec/features/lesson/listing_lessons_spec.rb @@ -44,6 +44,24 @@ expect(data.first[:user_name]).to be_nil end + it 'does not include archived lessons' do + lesson.archive! + + get('/api/lessons', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(0) + end + + it 'includes archived lessons if ?include_archived=true is set' do + lesson.archive! + + get('/api/lessons?include_archived=true', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + context "when the lesson's visibility is 'private'" do let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'private') } let(:owner_index) { user_index_by_role('school-owner') } From 0d2280a96bab49d6de8cf115b5121c1bb868128f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Wed, 21 Feb 2024 18:39:32 +0000 Subject: [PATCH 108/124] Add a lessons.copied_from column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The naming follows the ‘remixed_from’ convention on projects. --- db/migrate/20240217144009_create_lessons.rb | 6 +++++- db/schema.rb | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/db/migrate/20240217144009_create_lessons.rb b/db/migrate/20240217144009_create_lessons.rb index adeaed5cc..09c65717f 100644 --- a/db/migrate/20240217144009_create_lessons.rb +++ b/db/migrate/20240217144009_create_lessons.rb @@ -3,8 +3,9 @@ def change create_table :lessons, id: :uuid do |t| t.references :school, type: :uuid, foreign_key: true, index: true t.references :school_class, type: :uuid, foreign_key: true, index: true - t.uuid :user_id, null: false + t.uuid :copied_from + t.uuid :user_id, null: false t.string :name, null: false t.string :description t.string :visibility, null: false, default: 'private' @@ -14,6 +15,9 @@ def change t.timestamps end + add_foreign_key :lessons, :lessons, column: :copied_from + + add_index :lessons, :copied_from add_index :lessons, :user_id add_index :lessons, :name add_index :lessons, :visibility diff --git a/db/schema.rb b/db/schema.rb index 8c4bd621c..397c20c3d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -127,6 +127,7 @@ create_table "lessons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "school_id" t.uuid "school_class_id" + t.uuid "copied_from" t.uuid "user_id", null: false t.string "name", null: false t.string "description" @@ -136,6 +137,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["archived_at"], name: "index_lessons_on_archived_at" + t.index ["copied_from"], name: "index_lessons_on_copied_from" t.index ["name"], name: "index_lessons_on_name" t.index ["school_class_id"], name: "index_lessons_on_school_class_id" t.index ["school_id"], name: "index_lessons_on_school_id" @@ -202,6 +204,7 @@ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "class_members", "school_classes" add_foreign_key "components", "projects" + add_foreign_key "lessons", "lessons", column: "copied_from" add_foreign_key "lessons", "school_classes" add_foreign_key "lessons", "schools" add_foreign_key "project_errors", "projects" From cb94b98209baecc7be0e3cbceff5b8847220b671 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 22 Feb 2024 12:07:01 +0000 Subject: [PATCH 109/124] Rename lessons.copied_from -> lessons.copied_from_id For consistency with projects.remixed_from_id --- db/migrate/20240217144009_create_lessons.rb | 5 +---- db/schema.rb | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/db/migrate/20240217144009_create_lessons.rb b/db/migrate/20240217144009_create_lessons.rb index 09c65717f..7de6e4207 100644 --- a/db/migrate/20240217144009_create_lessons.rb +++ b/db/migrate/20240217144009_create_lessons.rb @@ -3,8 +3,8 @@ def change create_table :lessons, id: :uuid do |t| t.references :school, type: :uuid, foreign_key: true, index: true t.references :school_class, type: :uuid, foreign_key: true, index: true + t.references :copied_from, type: :uuid, foreign_key: { to_table: :lessons }, index: true - t.uuid :copied_from t.uuid :user_id, null: false t.string :name, null: false t.string :description @@ -15,9 +15,6 @@ def change t.timestamps end - add_foreign_key :lessons, :lessons, column: :copied_from - - add_index :lessons, :copied_from add_index :lessons, :user_id add_index :lessons, :name add_index :lessons, :visibility diff --git a/db/schema.rb b/db/schema.rb index 397c20c3d..81eed86fa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -127,7 +127,7 @@ create_table "lessons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "school_id" t.uuid "school_class_id" - t.uuid "copied_from" + t.uuid "copied_from_id" t.uuid "user_id", null: false t.string "name", null: false t.string "description" @@ -137,7 +137,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["archived_at"], name: "index_lessons_on_archived_at" - t.index ["copied_from"], name: "index_lessons_on_copied_from" + t.index ["copied_from_id"], name: "index_lessons_on_copied_from_id" t.index ["name"], name: "index_lessons_on_name" t.index ["school_class_id"], name: "index_lessons_on_school_class_id" t.index ["school_id"], name: "index_lessons_on_school_id" @@ -204,7 +204,7 @@ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "class_members", "school_classes" add_foreign_key "components", "projects" - add_foreign_key "lessons", "lessons", column: "copied_from" + add_foreign_key "lessons", "lessons", column: "copied_from_id" add_foreign_key "lessons", "school_classes" add_foreign_key "lessons", "schools" add_foreign_key "project_errors", "projects" From 79e4153644724b35c2cc80d099d679a52107379b Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 22 Feb 2024 12:12:17 +0000 Subject: [PATCH 110/124] Add Lesson#parent and Lesson#copies associations --- app/models/lesson.rb | 2 ++ spec/models/lesson_spec.rb | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 2d10a84bd..57ba03237 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -3,6 +3,8 @@ class Lesson < ApplicationRecord belongs_to :school, optional: true belongs_to :school_class, optional: true + belongs_to :parent, optional: true, class_name: :Lesson, foreign_key: :copied_from_id, inverse_of: :copies + has_many :copies, dependent: :nullify, class_name: :Lesson, foreign_key: :copied_from_id, inverse_of: :parent before_validation :assign_school_from_school_class before_destroy -> { throw :abort } diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 6de55c1b1..1386b069d 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -19,6 +19,16 @@ lesson = create(:lesson, school_class:, school: school_class.school) expect(lesson.school_class).to be_a(SchoolClass) end + + it 'optionally belongs to a parent' do + lesson = create(:lesson, parent: build(:lesson)) + expect(lesson.parent).to be_a(described_class) + end + + it 'has many copies' do + lesson = create(:lesson, copies: [build(:lesson), build(:lesson)]) + expect(lesson.copies.size).to eq(2) + end end describe 'callbacks' do From e171b58df0fe67d600551b41e48642b96b1b459d Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Thu, 22 Feb 2024 12:51:00 +0000 Subject: [PATCH 111/124] Add a Lessons#create_copy controller action --- app/controllers/api/lessons_controller.rb | 21 ++- app/models/ability.rb | 9 +- app/views/api/lessons/index.json.jbuilder | 2 + app/views/api/lessons/show.json.jbuilder | 4 +- config/routes.rb | 6 +- lib/concepts/lesson/operations/create_copy.rb | 28 ++++ spec/concepts/lesson/create_copy_spec.rb | 84 ++++++++++++ .../creating_a_copy_of_a_lesson_spec.rb | 123 ++++++++++++++++++ .../features/lesson/creating_a_lesson_spec.rb | 5 - .../features/lesson/updating_a_lesson_spec.rb | 5 - 10 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 lib/concepts/lesson/operations/create_copy.rb create mode 100644 spec/concepts/lesson/create_copy_spec.rb create mode 100644 spec/features/lesson/creating_a_copy_of_a_lesson_spec.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 925bb37a8..c01a3e8ea 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -28,6 +28,17 @@ def create end end + def create_copy + result = Lesson::CreateCopy.call(lesson: @lesson, lesson_params:) + + if result.success? + @lesson_with_user = result[:lesson].with_user + render :show, formats: [:json], status: :created + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + def update result = Lesson::Update.call(lesson: @lesson, lesson_params:) @@ -70,7 +81,15 @@ def lesson_params end def base_params - params.require(:lesson).permit(:school_id, :school_class_id, :user_id, :name) + params.fetch(:lesson, {}).permit( + :school_id, + :school_class_id, + :user_id, + :name, + :description, + :visibility, + :due_date + ) end def school_owner? diff --git a/app/models/ability.rb b/app/models/ability.rb index 39888b609..705f06b8c 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -15,8 +15,9 @@ def initialize(user) can %i[read create update destroy], Component, project: { user_id: user.id } can :create, School # The user agrees to become a school-owner by creating a school. - can :create, Lesson, school_id: nil, school_class_id: nil - can %i[read update destroy], Lesson, user_id: user.id + can :create, Lesson, school_id: nil, school_class_id: nil # Users can create shared lessons. + can :create_copy, Lesson, visibility: 'public' # Users can create a copy of any public lesson. + can %i[read create_copy update destroy], Lesson, user_id: user.id # Users can manage their own lessons. user.organisation_ids.each do |organisation_id| define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:) @@ -35,7 +36,7 @@ def define_school_owner_abilities(organisation_id:) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) can(%i[read create create_batch update destroy], :school_student) - can(%i[create], Lesson, school_id: organisation_id) + can(%i[create create_copy], Lesson, school_id: organisation_id) can(%i[read update destroy], Lesson, school_id: organisation_id, visibility: %w[teachers students public]) end @@ -48,7 +49,7 @@ def define_school_teacher_abilities(user:, organisation_id:) can(%i[read], :school_teacher) can(%i[read create create_batch update], :school_student) can(%i[create destroy], Lesson) { |lesson| school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) } - can(%i[read], Lesson, school_id: organisation_id, visibility: %w[teachers students]) + can(%i[read create_copy], Lesson, school_id: organisation_id, visibility: %w[teachers students]) end # rubocop:disable Layout/LineLength diff --git a/app/views/api/lessons/index.json.jbuilder b/app/views/api/lessons/index.json.jbuilder index c177e181d..db5d83d18 100644 --- a/app/views/api/lessons/index.json.jbuilder +++ b/app/views/api/lessons/index.json.jbuilder @@ -6,8 +6,10 @@ json.array!(@lessons_with_users) do |lesson, user| :id, :school_id, :school_class_id, + :copied_from_id, :user_id, :name, + :description, :visibility, :due_date, :archived_at, diff --git a/app/views/api/lessons/show.json.jbuilder b/app/views/api/lessons/show.json.jbuilder index 4814a45bd..d8cbddd5d 100644 --- a/app/views/api/lessons/show.json.jbuilder +++ b/app/views/api/lessons/show.json.jbuilder @@ -7,12 +7,14 @@ json.call( :id, :school_id, :school_class_id, + :copied_from_id, :user_id, :name, + :description, :visibility, :due_date, - :created_at, :archived_at, + :created_at, :updated_at ) diff --git a/config/routes.rb b/config/routes.rb index e33525830..f2654e0d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Metrics/BlockLength Rails.application.routes.draw do post '/graphql', to: 'graphql#execute' mount GraphiQL::Rails::Engine, at: '/', graphql_path: '/graphql#execute' unless Rails.env.production? @@ -29,8 +30,11 @@ end end - resources :lessons, only: %i[index create show update destroy] + resources :lessons, only: %i[index create show update destroy] do + post :copy, on: :member, to: 'lessons#create_copy' + end end resource :github_webhooks, only: :create, defaults: { formats: :json } end +# rubocop:enable Metrics/BlockLength diff --git a/lib/concepts/lesson/operations/create_copy.rb b/lib/concepts/lesson/operations/create_copy.rb new file mode 100644 index 000000000..0906bf24c --- /dev/null +++ b/lib/concepts/lesson/operations/create_copy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Lesson + class CreateCopy + class << self + def call(lesson:, lesson_params:) + response = OperationResponse.new + response[:lesson] = build_copy(lesson, lesson_params) + response[:lesson].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + errors = response[:lesson].errors.full_messages.join(',') + response[:error] = "Error creating copy of lesson: #{errors}" + response + end + + private + + # TODO: copy projects + def build_copy(lesson, lesson_params) + copy = Lesson.new(parent: lesson, name: lesson.name, description: lesson.description) + copy.assign_attributes(lesson_params) + copy + end + end + end +end diff --git a/spec/concepts/lesson/create_copy_spec.rb b/spec/concepts/lesson/create_copy_spec.rb new file mode 100644 index 000000000..5fff2b43d --- /dev/null +++ b/spec/concepts/lesson/create_copy_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Lesson::CreateCopy, type: :unit do + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let!(:lesson) do + create(:lesson, name: 'Test Lesson', description: 'Description', user_id: teacher_id) + end + + let(:lesson_params) do + { user_id: teacher_id } + end + + it 'returns a successful operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.success?).to be(true) + end + + it 'creates a lesson' do + expect { described_class.call(lesson:, lesson_params:) }.to change(Lesson, :count).by(1) + end + + it 'returns the new lesson in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson]).to be_a(Lesson) + end + + it 'assigns the parent' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].parent).to eq(lesson) + end + + it 'assigns the name from the parent lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].name).to eq('Test Lesson') + end + + it 'assigns the description from the parent lesson' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:lesson].description).to eq('Description') + end + + it 'can specify the name of the new copy' do + new_params = lesson_params.merge(name: 'New Name') + response = described_class.call(lesson:, lesson_params: new_params) + expect(response[:lesson].name).to eq('New Name') + end + + it 'can specify the description of the new copy' do + new_params = lesson_params.merge(description: 'New Description') + response = described_class.call(lesson:, lesson_params: new_params) + expect(response[:lesson].description).to eq('New Description') + end + + context 'when creating a copy fails' do + let(:lesson_params) { { name: ' ' } } + + before do + allow(Sentry).to receive(:capture_exception) + end + + it 'does not create a lesson' do + expect { described_class.call(lesson:, lesson_params:) }.not_to change(Lesson, :count) + end + + it 'returns a failed operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response.failure?).to be(true) + end + + it 'returns the error message in the operation response' do + response = described_class.call(lesson:, lesson_params:) + expect(response[:error]).to match(/Error creating copy of lesson/) + end + + it 'sent the exception to Sentry' do + described_class.call(lesson:, lesson_params:) + expect(Sentry).to have_received(:capture_exception).with(kind_of(StandardError)) + end + end +end diff --git a/spec/features/lesson/creating_a_copy_of_a_lesson_spec.rb b/spec/features/lesson/creating_a_copy_of_a_lesson_spec.rb new file mode 100644 index 000000000..5f53e7a69 --- /dev/null +++ b/spec/features/lesson/creating_a_copy_of_a_lesson_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a copy of a lesson', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'public') } + let(:params) { {} } + + it 'responds 201 Created' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the lesson JSON' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Lesson') + end + + it 'responds with the user JSON which is set from the current user' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_name]).to eq('School Owner') + end + + # See spec/concepts/lesson/create_copy_spec.rb for more examples. + it 'only copies a subset of fields from the lesson' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + + data = JSON.parse(response.body, symbolize_names: true) + values = data.slice(:copied_from_id, :name, :visibility).values + + expect(values).to eq [lesson.id, 'Test Lesson', 'private'] + end + + it 'can override fields from the request params' do + new_params = { lesson: { name: 'New Name', visibility: 'public' } } + post("/api/lessons/#{lesson.id}/copy", headers:, params: new_params) + + data = JSON.parse(response.body, symbolize_names: true) + values = data.slice(:name, :visibility).values + + expect(values).to eq ['New Name', 'public'] + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post("/api/lessons/#{lesson.id}/copy", headers:, params: { lesson: { name: ' ' } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/lessons/#{lesson.id}/copy", params:) + expect(response).to have_http_status(:unauthorized) + end + + context "when the lesson's visibility is 'private'" do + let!(:lesson) { create(:lesson, name: 'Test Lesson', visibility: 'private') } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + it 'responds 201 Created when the user owns the lesson' do + lesson.update!(user_id: owner_id) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 403 Forbidden when the user does not own the lesson' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end + + context "when the lesson's visibility is 'teachers'" do + let(:school) { create(:school) } + let!(:lesson) { create(:lesson, school:, name: 'Test Lesson', visibility: 'teachers') } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + let(:params) do + { + lesson: { + user_id: owner_id + } + } + end + + it 'responds 201 Created when the user owns the lesson' do + lesson.update!(user_id: owner_id) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-owner or school-teacher within the school' do + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school = create(:school, id: SecureRandom.uuid) + lesson.update!(school_id: school.id) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post("/api/lessons/#{lesson.id}/copy", headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 2922196c7..bebbdd58f 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -37,11 +37,6 @@ expect(data[:user_name]).to eq('School Owner') end - it 'responds 400 Bad Request when params are missing' do - post('/api/lessons', headers:) - expect(response).to have_http_status(:bad_request) - end - it 'responds 422 Unprocessable Entity when params are invalid' do post('/api/lessons', headers:, params: { lesson: { name: ' ' } }) expect(response).to have_http_status(:unprocessable_entity) diff --git a/spec/features/lesson/updating_a_lesson_spec.rb b/spec/features/lesson/updating_a_lesson_spec.rb index ead0f4619..3eb0723cc 100644 --- a/spec/features/lesson/updating_a_lesson_spec.rb +++ b/spec/features/lesson/updating_a_lesson_spec.rb @@ -40,11 +40,6 @@ expect(data[:user_name]).to eq('School Owner') end - it 'responds 400 Bad Request when params are missing' do - put("/api/lessons/#{lesson.id}", headers:) - expect(response).to have_http_status(:bad_request) - end - it 'responds 422 Unprocessable Entity when params are invalid' do put("/api/lessons/#{lesson.id}", headers:, params: { lesson: { name: ' ' } }) expect(response).to have_http_status(:unprocessable_entity) From 7e6e30e8f498615f8d26330a796f1db0f0375719 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 11:24:36 +0000 Subject: [PATCH 112/124] Reorder lines in the Project model for consistency with other models --- app/models/project.rb | 15 +++++++++------ spec/models/project_spec.rb | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 4500590d0..d23b55714 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1,17 +1,20 @@ # frozen_string_literal: true class Project < ApplicationRecord - before_validation :check_unique_not_null, on: :create - validates :identifier, presence: true, uniqueness: { scope: :locale } - validate :identifier_cannot_be_taken_by_another_user - validates :locale, presence: true, unless: :user_id - belongs_to :parent, class_name: 'Project', foreign_key: 'remixed_from_id', optional: true, inverse_of: :remixes + belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes + has_many :remixes, dependent: :nullify, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :parent has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project - has_many :remixes, class_name: 'Project', foreign_key: 'remixed_from_id', dependent: :nullify, inverse_of: :parent has_many :project_errors, dependent: :nullify has_many_attached :images + accepts_nested_attributes_for :components + before_validation :check_unique_not_null, on: :create + + validates :identifier, presence: true, uniqueness: { scope: :locale } + validate :identifier_cannot_be_taken_by_another_user + validates :locale, presence: true, unless: :user_id + private def check_unique_not_null diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 8c9e149a2..b13454377 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -4,10 +4,10 @@ RSpec.describe Project do describe 'associations', :sample_words do - it { is_expected.to have_many(:components) } + it { is_expected.to belong_to(:parent).optional(true) } it { is_expected.to have_many(:remixes).dependent(:nullify) } + it { is_expected.to have_many(:components) } it { is_expected.to have_many(:project_errors).dependent(:nullify) } - it { is_expected.to belong_to(:parent).optional(true) } it { is_expected.to have_many_attached(:images) } it 'purges attached images' do From d63e5459f19c9458369194f5436fb2c0df00678f Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 11:33:36 +0000 Subject: [PATCH 113/124] Add a projects.school_id column --- db/migrate/20240223113155_add_school_id_to_projects.rb | 5 +++++ db/schema.rb | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240223113155_add_school_id_to_projects.rb diff --git a/db/migrate/20240223113155_add_school_id_to_projects.rb b/db/migrate/20240223113155_add_school_id_to_projects.rb new file mode 100644 index 000000000..7af93cefd --- /dev/null +++ b/db/migrate/20240223113155_add_school_id_to_projects.rb @@ -0,0 +1,5 @@ +class AddSchoolIdToProjects < ActiveRecord::Migration[7.0] + def change + add_reference :projects, :school, type: :uuid, foreign_key: true, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 81eed86fa..cbcc3b335 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[7.0].define(version: 2024_02_17_144009) do +ActiveRecord::Schema[7.0].define(version: 2024_02_23_113155) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -165,9 +165,11 @@ t.uuid "remixed_from_id" t.string "locale" t.string "remix_origin" + t.uuid "school_id" t.index ["identifier", "locale"], name: "index_projects_on_identifier_and_locale", unique: true t.index ["identifier"], name: "index_projects_on_identifier" t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" + t.index ["school_id"], name: "index_projects_on_school_id" end create_table "school_classes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -208,5 +210,6 @@ add_foreign_key "lessons", "school_classes" add_foreign_key "lessons", "schools" add_foreign_key "project_errors", "projects" + add_foreign_key "projects", "schools" add_foreign_key "school_classes", "schools" end From a4655efae2499c1158b1c8bf6542db39d7d60081 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 11:44:05 +0000 Subject: [PATCH 114/124] Add School#projects and Project#school associations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project can have an optional school association, e.g. if it is a member of a school’s library of resources or if it is assigned to a lesson. --- app/models/project.rb | 1 + app/models/school.rb | 1 + spec/models/project_spec.rb | 1 + spec/models/school_spec.rb | 17 ++++++++++++++++- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index d23b55714..94e2e0063 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Project < ApplicationRecord + belongs_to :school, optional: true belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes has_many :remixes, dependent: :nullify, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :parent has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project diff --git a/app/models/school.rb b/app/models/school.rb index e3e3680c3..0014cb1f6 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -3,6 +3,7 @@ class School < ApplicationRecord has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy has_many :lessons, dependent: :nullify + has_many :projects, dependent: :nullify validates :id, presence: true, uniqueness: { case_sensitive: false } validates :name, presence: true diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index b13454377..8b9582828 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -4,6 +4,7 @@ RSpec.describe Project do describe 'associations', :sample_words do + it { is_expected.to belong_to(:school).optional(true) } it { is_expected.to belong_to(:parent).optional(true) } it { is_expected.to have_many(:remixes).dependent(:nullify) } it { is_expected.to have_many(:components) } diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 3aba95660..f27932529 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -18,12 +18,18 @@ expect(school.lessons.size).to eq(2) end + it 'has many projects' do + school = create(:school, projects: [build(:project), build(:project)]) + expect(school.projects.size).to eq(2) + end + context 'when a school is destroyed' do let(:lesson1) { build(:lesson) } let(:lesson2) { build(:lesson) } + let(:project) { build(:project) } let!(:school_class) { build(:school_class, members: [build(:class_member)], lessons: [lesson1]) } - let!(:school) { create(:school, classes: [school_class], lessons: [lesson2]) } + let!(:school) { create(:school, classes: [school_class], lessons: [lesson2], projects: [project]) } it 'also destroys school classes to avoid making them invalid' do expect { school.destroy! }.to change(SchoolClass, :count).by(-1) @@ -45,6 +51,15 @@ expect(values).to eq [nil, nil, nil, nil] end + + it 'does not destroy projects' do + expect { school.destroy! }.not_to change(Project, :count) + end + + it 'nullifies the school_id field on projects' do + school.destroy! + expect(project.reload.school_id).to be_nil + end end end From 160df23a5c0a4f2394347cb77c4fa7d9ac049374 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 11:51:03 +0000 Subject: [PATCH 115/124] Make the project jbuilder views more consistent with other views --- app/views/api/projects/index.json.jbuilder | 9 ++++++- app/views/api/projects/show.json.jbuilder | 31 +++++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/app/views/api/projects/index.json.jbuilder b/app/views/api/projects/index.json.jbuilder index f29fe2640..654cb5763 100644 --- a/app/views/api/projects/index.json.jbuilder +++ b/app/views/api/projects/index.json.jbuilder @@ -1,3 +1,10 @@ # frozen_string_literal: true -json.array! @paginated_projects, :identifier, :project_type, :name, :user_id, :updated_at +json.array!( + @paginated_projects, + :identifier, + :project_type, + :name, + :user_id, + :updated_at +) diff --git a/app/views/api/projects/show.json.jbuilder b/app/views/api/projects/show.json.jbuilder index 4f1803aa6..fc663d076 100644 --- a/app/views/api/projects/show.json.jbuilder +++ b/app/views/api/projects/show.json.jbuilder @@ -1,12 +1,31 @@ # frozen_string_literal: true -json.call(@project, :identifier, :project_type, :locale, :name, :user_id) +json.call( + @project, + :identifier, + :project_type, + :locale, + :name, + :user_id +) -json.parent(@project.parent, :name, :identifier) if @project.parent +if @project.parent + json.parent( + @project.parent, + :name, + :identifier + ) +end -json.components @project.components, :id, :name, :extension, :content +json.components( + @project.components, + :id, + :name, + :extension, + :content +) -json.image_list @project.images do |image| - json.filename image.filename - json.url rails_blob_url(https://github.com/image) +json.image_list(@project.images) do |image| + json.filename(image.filename) + json.url(https://github.com/rails_blob_url(image)) end From 73649e07f808e524c22f4efc9f9dad33dfe782a9 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 14:55:18 +0000 Subject: [PATCH 116/124] =?UTF-8?q?Add=20support=20for=20creating=20projec?= =?UTF-8?q?ts=20within=20a=20school=E2=80=99s=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the user has the school-owner or school-teacher role, they can create a project that is associated with a school (but not a school class). If the user is a school-owner they can assign a teacher to the project in the same was as lessons, otherwise the school-teacher is always set as the project’s owner (user). --- app/controllers/api/lessons_controller.rb | 2 +- app/controllers/api/projects_controller.rb | 35 ++++-- app/models/ability.rb | 33 ++++-- app/models/project.rb | 6 + .../features/lesson/creating_a_lesson_spec.rb | 2 +- spec/features/lesson/showing_a_lesson_spec.rb | 2 +- .../project/creating_a_project_spec.rb | 105 ++++++++++++++++++ spec/requests/projects/create_spec.rb | 4 +- spec/requests/projects/update_spec.rb | 2 +- 9 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 spec/features/project/creating_a_project_spec.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index c01a3e8ea..244ef1214 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -75,7 +75,7 @@ def lesson_params # A school owner must specify who the lesson user is. base_params else - # A school teacher may only create classes they own. + # A school teacher may only create lessons they own. base_params.merge(user_id: current_user.id) end end diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb index 9e2dd6995..9372d552c 100644 --- a/app/controllers/api/projects_controller.rb +++ b/app/controllers/api/projects_controller.rb @@ -7,9 +7,8 @@ class ProjectsController < ApiController before_action :authorize_user, only: %i[create update index destroy] before_action :load_project, only: %i[show update destroy] before_action :load_projects, only: %i[index] - after_action :pagination_link_header, only: [:index] + after_action :pagination_link_header, only: %i[index] load_and_authorize_resource - skip_load_resource only: :create def index @paginated_projects = @projects.page(params[:page]) @@ -21,25 +20,23 @@ def show end def create - project_hash = project_params.merge(user_id: current_user&.id) - result = Project::Create.call(project_hash:) + result = Project::Create.call(project_hash: project_params) if result.success? @project = result[:project] - render :show, formats: [:json] + render :show, formats: [:json], status: :created else - render json: { error: result[:error] }, status: :internal_server_error + render json: { error: result[:error] }, status: :unprocessable_entity end end def update - update_hash = project_params.merge(user_id: current_user&.id) - result = Project::Update.call(project: @project, update_hash:) + result = Project::Update.call(project: @project, update_hash: project_params) if result.success? render :show, formats: [:json] else - render json: { error: result[:error] }, status: :bad_request + render json: { error: result[:error] }, status: :unprocessable_entity end end @@ -60,7 +57,19 @@ def load_projects end def project_params + if school_owner? + # A school owner must specify who the project user is. + base_params + else + # A school teacher may only create projects they own. + base_params.merge(user_id: current_user&.id) + end + end + + def base_params params.fetch(:project, {}).permit( + :school_id, + :user_id, :identifier, :name, :project_type, @@ -72,6 +81,14 @@ def project_params ) end + def school_owner? + school && current_user.school_owner?(organisation_id: school.id) + end + + def school + @school ||= @project&.school || School.find_by(id: base_params[:school_id]) + end + def pagination_link_header pagination_links = [] pagination_links << page_links(first_page, 'first') diff --git a/app/models/ability.rb b/app/models/ability.rb index 705f06b8c..c0693a16e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -5,19 +5,34 @@ class Ability # rubocop:disable Metrics/AbcSize def initialize(user) - can :show, Project, user_id: nil - can :show, Component, project: { user_id: nil } + # Anyone can view projects not owner by a user or a school. + can :show, Project, user_id: nil, school_id: nil + can :show, Component, project: { user_id: nil, school_id: nil } + + # Anyone can read publicly shared lessons. can :read, Lesson, visibility: 'public' return unless user - can %i[read create update destroy], Project, user_id: user.id - can %i[read create update destroy], Component, project: { user_id: user.id } + # Any authenticated user can create projects not owned by a school. + can :create, Project, user_id: user.id, school_id: nil + can :create, Component, project: { user_id: user.id, school_id: nil } + + # Any authenticated user can manage their own projects. + can %i[read update destroy], Project, user_id: user.id + can %i[read update destroy], Component, project: { user_id: user.id } + + # Any authenticated user can create a school. They agree to become the school-owner. + can :create, School + + # Any authenticated user can create a lesson, to support a RPF library of public lessons. + can :create, Lesson, school_id: nil, school_class_id: nil + + # Any authenticated user can create a copy of a publicly shared lesson. + can :create_copy, Lesson, visibility: 'public' - can :create, School # The user agrees to become a school-owner by creating a school. - can :create, Lesson, school_id: nil, school_class_id: nil # Users can create shared lessons. - can :create_copy, Lesson, visibility: 'public' # Users can create a copy of any public lesson. - can %i[read create_copy update destroy], Lesson, user_id: user.id # Users can manage their own lessons. + # Any authenticated user can manage their own lessons. + can %i[read create_copy update destroy], Lesson, user_id: user.id user.organisation_ids.each do |organisation_id| define_school_owner_abilities(organisation_id:) if user.school_owner?(organisation_id:) @@ -38,6 +53,7 @@ def define_school_owner_abilities(organisation_id:) can(%i[read create create_batch update destroy], :school_student) can(%i[create create_copy], Lesson, school_id: organisation_id) can(%i[read update destroy], Lesson, school_id: organisation_id, visibility: %w[teachers students public]) + can(%i[create], Project, school_id: organisation_id) end def define_school_teacher_abilities(user:, organisation_id:) @@ -50,6 +66,7 @@ def define_school_teacher_abilities(user:, organisation_id:) can(%i[read create create_batch update], :school_student) can(%i[create destroy], Lesson) { |lesson| school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) } can(%i[read create_copy], Lesson, school_id: organisation_id, visibility: %w[teachers students]) + can(%i[create], Project, school_id: organisation_id, user_id: user.id) end # rubocop:disable Layout/LineLength diff --git a/app/models/project.rb b/app/models/project.rb index 94e2e0063..8d6655d0b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -16,6 +16,12 @@ class Project < ApplicationRecord validate :identifier_cannot_be_taken_by_another_user validates :locale, presence: true, unless: :user_id + # Work around a CanCanCan issue with accepts_nested_attributes_for. + # https://github.com/CanCanCommunity/cancancan/issues/774 + def components=(array) + super(array.map { |o| o.is_a?(Hash) ? Component.new(o) : o }) + end + private def check_unique_not_null diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index bebbdd58f..935665919 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe 'Creating a public lesson', type: :request do +RSpec.describe 'Creating a lesson', type: :request do before do stub_hydra_public_api stub_user_info_api diff --git a/spec/features/lesson/showing_a_lesson_spec.rb b/spec/features/lesson/showing_a_lesson_spec.rb index a862ee655..07a4d0e7b 100644 --- a/spec/features/lesson/showing_a_lesson_spec.rb +++ b/spec/features/lesson/showing_a_lesson_spec.rb @@ -123,7 +123,7 @@ expect(response).to have_http_status(:ok) end - it "responds 403 Forbidden when the user is not a school-student within the lesson's class" do + it "responds 403 Forbidden when the user is a school-student but isn't within the lesson's class" do stub_hydra_public_api(user_index: user_index_by_role('school-student')) get("/api/lessons/#{lesson.id}", headers:) diff --git a/spec/features/project/creating_a_project_spec.rb b/spec/features/project/creating_a_project_spec.rb new file mode 100644 index 000000000..2d1982c0d --- /dev/null +++ b/spec/features/project/creating_a_project_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a project', type: :request do + before do + stub_hydra_public_api + mock_phrase_generation + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + let(:params) do + { + project: { + name: 'Test Project', + components: [ + { name: 'main', extension: 'py', content: 'print("hi")' } + ] + } + } + end + + it 'responds 201 Created' do + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds with the project JSON' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('Test Project') + end + + it 'responds with the components JSON' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:components].first[:content]).to eq('print("hi")') + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + post('/api/projects', headers:, params: { project: { components: [{ name: ' ' }] } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + post('/api/projects', params:) + expect(response).to have_http_status(:unauthorized) + end + + context 'when the project is associated with a school (library)' do + let(:school) { create(:school) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let(:params) do + { + project: { + name: 'Test Project', + components: [], + school_id: school.id, + user_id: teacher_id + } + } + end + + it 'responds 201 Created' do + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the user is a school-teacher for the school' do + stub_hydra_public_api(user_index: user_index_by_role('school-teacher')) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'sets the lesson user to the specified user for school-owner users' do + post('/api/projects', headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to eq(teacher_id) + end + + it 'sets the project user to the current user for school-teacher users' do + stub_hydra_public_api(user_index: teacher_index) + new_params = { project: params[:project].merge(user_id: 'ignored') } + + post('/api/projects', headers:, params: new_params) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:user_id]).to eq(teacher_id) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + school.update!(id: SecureRandom.uuid) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/spec/requests/projects/create_spec.rb b/spec/requests/projects/create_spec.rb index 184e53d05..81cb64318 100644 --- a/spec/requests/projects/create_spec.rb +++ b/spec/requests/projects/create_spec.rb @@ -20,7 +20,7 @@ it 'returns success' do post('/api/projects', headers:) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:created) end end @@ -36,7 +36,7 @@ it 'returns error' do post('/api/projects', headers:) - expect(response).to have_http_status(:internal_server_error) + expect(response).to have_http_status(:unprocessable_entity) end end end diff --git a/spec/requests/projects/update_spec.rb b/spec/requests/projects/update_spec.rb index 638eb3e09..f6792ef18 100644 --- a/spec/requests/projects/update_spec.rb +++ b/spec/requests/projects/update_spec.rb @@ -71,7 +71,7 @@ it 'returns error response' do put("/api/projects/#{project.identifier}", params:, headers:) - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:unprocessable_entity) end end end From c4e623976c961cc52b3d4789c60cc5d56266fa98 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 15:04:31 +0000 Subject: [PATCH 117/124] Add a projects.lesson_id column --- db/migrate/20240223150228_add_lesson_id_to_projects.rb | 5 +++++ db/schema.rb | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240223150228_add_lesson_id_to_projects.rb diff --git a/db/migrate/20240223150228_add_lesson_id_to_projects.rb b/db/migrate/20240223150228_add_lesson_id_to_projects.rb new file mode 100644 index 000000000..85be34945 --- /dev/null +++ b/db/migrate/20240223150228_add_lesson_id_to_projects.rb @@ -0,0 +1,5 @@ +class AddLessonIdToProjects < ActiveRecord::Migration[7.0] + def change + add_reference :projects, :lesson, type: :uuid, foreign_key: true, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index cbcc3b335..f20be65b4 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[7.0].define(version: 2024_02_23_113155) do +ActiveRecord::Schema[7.0].define(version: 2024_02_23_150228) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -166,8 +166,10 @@ t.string "locale" t.string "remix_origin" t.uuid "school_id" + t.uuid "lesson_id" t.index ["identifier", "locale"], name: "index_projects_on_identifier_and_locale", unique: true t.index ["identifier"], name: "index_projects_on_identifier" + t.index ["lesson_id"], name: "index_projects_on_lesson_id" t.index ["remixed_from_id"], name: "index_projects_on_remixed_from_id" t.index ["school_id"], name: "index_projects_on_school_id" end @@ -210,6 +212,7 @@ add_foreign_key "lessons", "school_classes" add_foreign_key "lessons", "schools" add_foreign_key "project_errors", "projects" + add_foreign_key "projects", "lessons" add_foreign_key "projects", "schools" add_foreign_key "school_classes", "schools" end From 017f42701d09ef45af3bb491931ac08d93e76d32 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 15:06:26 +0000 Subject: [PATCH 118/124] Add Lesson#projects and Project#lesson associations --- app/models/lesson.rb | 1 + app/models/project.rb | 1 + spec/models/lesson_spec.rb | 5 +++++ spec/models/project_spec.rb | 1 + 4 files changed, 8 insertions(+) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 57ba03237..553728bbc 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -5,6 +5,7 @@ class Lesson < ApplicationRecord belongs_to :school_class, optional: true belongs_to :parent, optional: true, class_name: :Lesson, foreign_key: :copied_from_id, inverse_of: :copies has_many :copies, dependent: :nullify, class_name: :Lesson, foreign_key: :copied_from_id, inverse_of: :parent + has_many :projects, dependent: :nullify before_validation :assign_school_from_school_class before_destroy -> { throw :abort } diff --git a/app/models/project.rb b/app/models/project.rb index 8d6655d0b..d307133b6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2,6 +2,7 @@ class Project < ApplicationRecord belongs_to :school, optional: true + belongs_to :lesson, optional: true belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes has_many :remixes, dependent: :nullify, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :parent has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 1386b069d..f1f39a564 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -29,6 +29,11 @@ lesson = create(:lesson, copies: [build(:lesson), build(:lesson)]) expect(lesson.copies.size).to eq(2) end + + it 'has many projects' do + lesson = create(:lesson, projects: [build(:project), build(:project)]) + expect(lesson.projects.size).to eq(2) + end end describe 'callbacks' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 8b9582828..54c3f8bd0 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -5,6 +5,7 @@ RSpec.describe Project do describe 'associations', :sample_words do it { is_expected.to belong_to(:school).optional(true) } + it { is_expected.to belong_to(:lesson).optional(true) } it { is_expected.to belong_to(:parent).optional(true) } it { is_expected.to have_many(:remixes).dependent(:nullify) } it { is_expected.to have_many(:components) } From 298a5061a20fb468bd882bd65267a42cb43e4c52 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 15:14:01 +0000 Subject: [PATCH 119/124] Add Project.users, .with_users and #with_user methods --- app/models/project.rb | 13 +++++++ spec/factories/project.rb | 2 +- spec/models/project_spec.rb | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/app/models/project.rb b/app/models/project.rb index d307133b6..6c31f30d8 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -17,6 +17,19 @@ class Project < ApplicationRecord validate :identifier_cannot_be_taken_by_another_user validates :locale, presence: true, unless: :user_id + def self.users + User.from_userinfo(ids: pluck(:user_id)) + end + + def self.with_users + by_id = users.index_by(&:id) + all.map { |instance| [instance, by_id[instance.user_id]] } + end + + def with_user + [self, User.from_userinfo(ids: user_id).first] + end + # Work around a CanCanCan issue with accepts_nested_attributes_for. # https://github.com/CanCanCommunity/cancancan/issues/774 def components=(array) diff --git a/spec/factories/project.rb b/spec/factories/project.rb index e1da44760..72d067681 100644 --- a/spec/factories/project.rb +++ b/spec/factories/project.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :project do - user_id { SecureRandom.uuid } + user_id { '22222222-2222-2222-2222-222222222222' } # Matches users.json. name { Faker::Book.title } identifier { "#{Faker::Verb.base}-#{Faker::Verb.base}-#{Faker::Verb.base}" } project_type { 'python' } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 54c3f8bd0..f095b6f9c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -3,6 +3,10 @@ require 'rails_helper' RSpec.describe Project do + before do + stub_user_info_api + end + describe 'associations', :sample_words do it { is_expected.to belong_to(:school).optional(true) } it { is_expected.to belong_to(:lesson).optional(true) } @@ -68,4 +72,70 @@ expect { unsaved_project.valid? }.to change { unsaved_project.identifier.nil? }.from(true).to(false) end end + + describe '.users' do + it 'returns User instances for the current scope' do + create(:project) + + user = described_class.all.users.first + expect(user.name).to eq('School Student') + end + + it 'ignores members where no profile account exists' do + create(:project, user_id: SecureRandom.uuid) + + user = described_class.all.users.first + expect(user).to be_nil + end + + it 'ignores members not included in the current scope' do + create(:project) + + user = described_class.none.users.first + expect(user).to be_nil + end + end + + describe '.with_users' do + it 'returns an array of class members paired with their User instance' do + project = create(:project) + + pair = described_class.all.with_users.first + user = described_class.all.users.first + + expect(pair).to eq([project, user]) + end + + it 'returns nil values for members where no profile account exists' do + project = create(:project, user_id: SecureRandom.uuid) + + pair = described_class.all.with_users.first + expect(pair).to eq([project, nil]) + end + + it 'ignores members not included in the current scope' do + create(:project) + + pair = described_class.none.with_users.first + expect(pair).to be_nil + end + end + + describe '#with_user' do + it 'returns the class member paired with their User instance' do + project = create(:project) + + pair = project.with_user + user = described_class.all.users.first + + expect(pair).to eq([project, user]) + end + + it 'returns a nil value if the member has no profile account' do + project = create(:project, user_id: SecureRandom.uuid) + + pair = project.with_user + expect(pair).to eq([project, nil]) + end + end end From e17f1e2dcb5cfdb3fa0806da2e92d68485a00840 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Fri, 23 Feb 2024 15:23:37 +0000 Subject: [PATCH 120/124] Validate that the Project#user belongs to the same school as the project --- app/models/project.rb | 13 +++++++++++++ app/models/user.rb | 7 +++++-- spec/models/project_spec.rb | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 6c31f30d8..984f94636 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -16,6 +16,7 @@ class Project < ApplicationRecord validates :identifier, presence: true, uniqueness: { scope: :locale } validate :identifier_cannot_be_taken_by_another_user validates :locale, presence: true, unless: :user_id + validate :user_has_a_role_within_the_school def self.users User.from_userinfo(ids: pluck(:user_id)) @@ -47,4 +48,16 @@ def identifier_cannot_be_taken_by_another_user errors.add(:identifier, "can't be taken by another user") end + + def user_has_a_role_within_the_school + return unless user_id_changed? && errors.blank? && school + + _, user = with_user + + return if user.blank? + return if user.roles(organisation_id: school.id).any? + + msg = "'#{user_id}' does not have any roles for for organisation '#{school.id}'" + errors.add(:user, msg) + end end diff --git a/app/models/user.rb b/app/models/user.rb index e34b94839..788567de2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,9 +29,12 @@ def organisation_ids organisations.keys end + def roles(organisation_id:) + organisations[organisation_id.to_s]&.to_s&.split(',')&.map(&:strip) || [] + end + def role?(organisation_id:, role:) - roles = organisations[organisation_id.to_s] - roles.to_s.split(',').map(&:strip).include?(role.to_s) if roles + roles(organisation_id:).include?(role.to_s) end def school_owner?(organisation_id:) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index f095b6f9c..e822cbb9a 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -25,6 +25,14 @@ let(:project) { create(:project) } let(:identifier) { project.identifier } + it 'has a valid default factory' do + expect(build(:project)).to be_valid + end + + it 'can save the default factory' do + expect { build(:project).save! }.not_to raise_error + end + it 'is invalid if no user or locale' do invalid_project = build(:project, locale: nil, user_id: nil) expect(invalid_project).to be_invalid @@ -62,6 +70,17 @@ expect(new_project).to be_invalid end end + + context 'when the project has a school' do + before do + project.update!(school: create(:school)) + end + + it 'requires a user that has a role within the school' do + project.user_id = SecureRandom.uuid + expect(project).to be_invalid + end + end end describe 'check_unique_not_null', :sample_words do From 3162671e30fea68d23168cd15233ff7dd9c554bd Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 25 Feb 2024 18:19:19 +0000 Subject: [PATCH 121/124] Validate that the user association with a project matches the lesson (if present) With regards to students creating projects relating to a lesson, I think they should just remix the project created by the teacher and not associate it with the lesson directly to make it easier to distinguish which is the actual project for a lesson, rather than a version created by a student. --- app/models/project.rb | 11 +++++++++-- spec/models/lesson_spec.rb | 5 +++-- spec/models/project_spec.rb | 14 +++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 984f94636..cc39a48e7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -17,6 +17,7 @@ class Project < ApplicationRecord validate :identifier_cannot_be_taken_by_another_user validates :locale, presence: true, unless: :user_id validate :user_has_a_role_within_the_school + validate :user_is_the_owner_of_the_lesson def self.users User.from_userinfo(ids: pluck(:user_id)) @@ -55,9 +56,15 @@ def user_has_a_role_within_the_school _, user = with_user return if user.blank? - return if user.roles(organisation_id: school.id).any? + return if user.roles(organisation_id: school_id).any? - msg = "'#{user_id}' does not have any roles for for organisation '#{school.id}'" + msg = "'#{user_id}' does not have any roles for for organisation '#{school_id}'" errors.add(:user, msg) end + + def user_is_the_owner_of_the_lesson + return if !lesson || user_id == lesson.user_id + + errors.add(:user, "'#{user_id}' is not the owner for lesson '#{lesson_id}'") + end end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index f1f39a564..777d70682 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -31,8 +31,9 @@ end it 'has many projects' do - lesson = create(:lesson, projects: [build(:project), build(:project)]) - expect(lesson.projects.size).to eq(2) + user_id = SecureRandom.uuid + lesson = create(:lesson, user_id:, projects: [build(:project, user_id:)]) + expect(lesson.projects.size).to eq(1) end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index e822cbb9a..480eb596d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -76,7 +76,19 @@ project.update!(school: create(:school)) end - it 'requires a user that has a role within the school' do + it 'requires that the user that has a role within the school' do + project.user_id = SecureRandom.uuid + expect(project).to be_invalid + end + end + + context 'when the project has a lesson' do + before do + lesson = create(:lesson) + project.update!(lesson:, user_id: lesson.user_id, identifier: 'something') + end + + it 'requires that the user be the owner of the lesson' do project.user_id = SecureRandom.uuid expect(project).to be_invalid end From 6393df4cff35b8aec8399b48ee73eca62ef4f715 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 25 Feb 2024 18:44:30 +0000 Subject: [PATCH 122/124] Add authorisation logic for creating projects in a school/lesson --- app/controllers/api/projects_controller.rb | 11 ++- app/models/ability.rb | 10 ++- .../features/lesson/creating_a_lesson_spec.rb | 2 +- .../project/creating_a_project_spec.rb | 83 +++++++++++++++++++ spec/models/lesson_spec.rb | 4 +- 5 files changed, 105 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb index 9372d552c..2d9807a26 100644 --- a/app/controllers/api/projects_controller.rb +++ b/app/controllers/api/projects_controller.rb @@ -7,8 +7,9 @@ class ProjectsController < ApiController before_action :authorize_user, only: %i[create update index destroy] before_action :load_project, only: %i[show update destroy] before_action :load_projects, only: %i[index] - after_action :pagination_link_header, only: %i[index] load_and_authorize_resource + before_action :verify_lesson_belongs_to_school, only: :create + after_action :pagination_link_header, only: %i[index] def index @paginated_projects = @projects.page(params[:page]) @@ -47,6 +48,13 @@ def destroy private + def verify_lesson_belongs_to_school + return if base_params[:lesson_id].blank? + return if school&.lessons&.pluck(:id)&.include?(base_params[:lesson_id]) + + raise ParameterError, 'lesson_id does not correspond to school_id' + end + def load_project project_loader = ProjectLoader.new(params[:id], [params[:locale]]) @project = project_loader.load @@ -69,6 +77,7 @@ def project_params def base_params params.fetch(:project, {}).permit( :school_id, + :lesson_id, :user_id, :identifier, :name, diff --git a/app/models/ability.rb b/app/models/ability.rb index c0693a16e..504b050a4 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -66,7 +66,7 @@ def define_school_teacher_abilities(user:, organisation_id:) can(%i[read create create_batch update], :school_student) can(%i[create destroy], Lesson) { |lesson| school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) } can(%i[read create_copy], Lesson, school_id: organisation_id, visibility: %w[teachers students]) - can(%i[create], Project, school_id: organisation_id, user_id: user.id) + can(%i[create], Project) { |project| school_teacher_can_manage_project?(user:, organisation_id:, project:) } end # rubocop:disable Layout/LineLength @@ -74,6 +74,7 @@ def define_school_student_abilities(user:, organisation_id:) can(%i[read], School, id: organisation_id) can(%i[read], SchoolClass, school: { id: organisation_id }, members: { student_id: user.id }) can(%i[read], Lesson, school_id: organisation_id, visibility: 'students', school_class: { members: { student_id: user.id } }) + can(%i[create], Project, school_id: organisation_id, user_id: user.id, lesson_id: nil) end # rubocop:enable Layout/LineLength @@ -83,4 +84,11 @@ def school_teacher_can_manage_lesson?(user:, organisation_id:, lesson:) is_my_lesson && (is_my_class || !lesson.school_class) end + + def school_teacher_can_manage_project?(user:, organisation_id:, project:) + is_my_project = project.school_id == organisation_id && project.user_id == user.id + is_my_lesson = project.lesson && project.lesson.user_id == user.id + + is_my_project && (is_my_lesson || !project.lesson) + end end diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 935665919..3612f9e76 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -145,7 +145,7 @@ expect(response).to have_http_status(:unprocessable_entity) end - it 'responds 422 Unprocessable if school_id does not correspond to school_class_id' do + it 'responds 422 Unprocessable if school_class_id does not correspond to school_id' do new_params = { lesson: params[:lesson].merge(school_id: SecureRandom.uuid) } post('/api/lessons', headers:, params: new_params) diff --git a/spec/features/project/creating_a_project_spec.rb b/spec/features/project/creating_a_project_spec.rb index 2d1982c0d..5b9bc1a38 100644 --- a/spec/features/project/creating_a_project_spec.rb +++ b/spec/features/project/creating_a_project_spec.rb @@ -5,6 +5,7 @@ RSpec.describe 'Creating a project', type: :request do before do stub_hydra_public_api + stub_user_info_api mock_phrase_generation end @@ -78,6 +79,13 @@ expect(response).to have_http_status(:created) end + it 'responds 201 Created when the user is a school-student for the school' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + it 'sets the lesson user to the specified user for school-owner users' do post('/api/projects', headers:, params:) data = JSON.parse(response.body, symbolize_names: true) @@ -102,4 +110,79 @@ expect(response).to have_http_status(:forbidden) end end + + context 'when the project is associated with a lesson' do + let(:school) { create(:school) } + let(:lesson) { create(:lesson, school:) } + let(:teacher_index) { user_index_by_role('school-teacher') } + let(:teacher_id) { user_id_by_index(teacher_index) } + + let(:params) do + { + project: { + name: 'Test Project', + components: [], + school_id: school.id, + lesson_id: lesson.id, + user_id: teacher_id + } + } + end + + it 'responds 201 Created' do + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 201 Created when the current user is the owner of the lesson' do + stub_hydra_public_api(user_index: teacher_index) + lesson.update!(user_id: user_id_by_index(teacher_index)) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:created) + end + + it 'responds 422 Unprocessable when when the user_id is not the owner of the lesson' do + new_params = { project: params[:project].merge(user_id: SecureRandom.uuid) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 422 Unprocessable when lesson_id is provided but school_id is missing' do + new_params = { project: params[:project].without(:school_id) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 422 Unprocessable when lesson_id does not correspond to school_id' do + new_params = { project: params[:project].merge(lesson_id: SecureRandom.uuid) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + new_params = { project: params[:project].without(:lesson_id).merge(school_id: SecureRandom.uuid) } + + post('/api/projects', headers:, params: new_params) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the current user is not the owner of the lesson' do + stub_hydra_public_api(user_index: teacher_index) + lesson.update!(user_id: SecureRandom.uuid) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + stub_hydra_public_api(user_index: user_index_by_role('school-student')) + + post('/api/projects', headers:, params:) + expect(response).to have_http_status(:forbidden) + end + end end diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 777d70682..b9ee0ab56 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -70,7 +70,7 @@ lesson.update!(school: create(:school)) end - it 'requires a user that has the school-owner or school-teacher role for the school' do + it 'requires that the user that has the school-owner or school-teacher role for the school' do lesson.user_id = '22222222-2222-2222-2222-222222222222' # school-student expect(lesson).to be_invalid end @@ -81,7 +81,7 @@ lesson.update!(school_class: create(:school_class)) end - it 'requires a user that is the school-teacher for the school_class' do + it 'requires that the user that is the school-teacher for the school_class' do lesson.user_id = '00000000-0000-0000-0000-000000000000' # school-owner expect(lesson).to be_invalid end From cc13ea8f7cce810961d50aa80b57226136dcda65 Mon Sep 17 00:00:00 2001 From: Chris Patuzzo Date: Sun, 25 Feb 2024 19:28:46 +0000 Subject: [PATCH 123/124] Add partial feature tests for updating a project --- .../project/updating_a_project_spec.rb | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 spec/features/project/updating_a_project_spec.rb diff --git a/spec/features/project/updating_a_project_spec.rb b/spec/features/project/updating_a_project_spec.rb new file mode 100644 index 000000000..eede8b5cf --- /dev/null +++ b/spec/features/project/updating_a_project_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Updating a project', type: :request do + before do + stub_hydra_public_api + stub_user_info_api + + create(:component, project:, name: 'main', extension: 'py', content: 'print("hi")') + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:project) { create(:project, name: 'Test Project', user_id: owner_id) } + let(:owner_index) { user_index_by_role('school-owner') } + let(:owner_id) { user_id_by_index(owner_index) } + + let(:params) do + { + project: { + name: 'New Name', + components: [ + { name: 'main', extension: 'py', content: 'print("hello")' } + ] + } + } + end + + it 'responds 200 OK' do + put("/api/projects/#{project.id}", headers:, params:) + expect(response).to have_http_status(:ok) + end + + it 'responds with the project JSON' do + put("/api/projects/#{project.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:name]).to eq('New Name') + end + + it 'responds with the components JSON' do + put("/api/projects/#{project.id}", headers:, params:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:components].first[:content]).to eq('print("hello")') + end + + it 'responds 422 Unprocessable Entity when params are invalid' do + put("/api/projects/#{project.id}", headers:, params: { project: { components: [{ name: ' ' }] } }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'responds 401 Unauthorized when no token is given' do + put("/api/projects/#{project.id}", params:) + expect(response).to have_http_status(:unauthorized) + end +end From ee5192f9d7bd2cc37d08db3b922d7617eefcc07e Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 5 Mar 2024 16:40:34 +0000 Subject: [PATCH 124/124] fix(ability.rb): rubocop'd --- app/models/ability.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index 2051f16fe..504b050a4 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -91,5 +91,4 @@ def school_teacher_can_manage_project?(user:, organisation_id:, project:) is_my_project && (is_my_lesson || !project.lesson) end - # rubocop:enable Metrics/AbcSize, Layout/LineLength end