From 8f0b20848e5f27e926718fb5fb3918642f93b4b8 Mon Sep 17 00:00:00 2001 From: Lois Wells Date: Thu, 29 May 2025 15:32:38 +0100 Subject: [PATCH 1/6] add create_from_project for lesson --- app/controllers/api/lessons_controller.rb | 18 ++++++- app/models/ability.rb | 4 +- app/models/project.rb | 7 +++ app/views/api/lessons/index.json.jbuilder | 2 +- config/routes.rb | 1 + .../lesson/operations/create_from_project.rb | 51 +++++++++++++++++++ 6 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 lib/concepts/lesson/operations/create_from_project.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 305b2a632..7a18ed00a 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -4,7 +4,7 @@ module Api class LessonsController < ApiController before_action :authorize_user, except: %i[index show] before_action :verify_school_class_belongs_to_school, only: :create - load_and_authorize_resource :lesson + load_and_authorize_resource :lesson, except: [:create_from_project] def index archive_scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived @@ -41,6 +41,21 @@ def create_copy end end + def create_from_project + remix_origin = request.origin || request.referer + + puts("lesson_params: #{lesson_params}") + + result = Lesson::CreateFromProject.call(lesson_params: lesson_params, remix_origin:) + + 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 # TODO: Consider removing user_id from the lesson_params for update so users can update other users' lessons without changing ownership # OR consider dropping user_id on lessons and using teacher id/ids on the class instead @@ -86,6 +101,7 @@ def base_params :description, :visibility, :due_date, + :project_identifier, { project_attributes: [ :name, diff --git a/app/models/ability.rb b/app/models/ability.rb index a7b65202e..bd65d52a8 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -67,6 +67,7 @@ def define_school_owner_abilities(school:) 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_from_project], Lesson) can(%i[create create_copy], Lesson, school_id: school.id) can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public]) end @@ -83,13 +84,14 @@ def define_school_teacher_abilities(user:, school:) can(%i[create update destroy], Lesson) do |lesson| school_teacher_can_manage_lesson?(user:, school:, lesson:) end + can(%i[create_from_project], Lesson) can(%i[read create_copy], Lesson, school_id: school.id, visibility: %w[teachers students]) can(%i[create], Project) do |project| school_teacher_can_manage_project?(user:, school:, project:) end can(%i[read update show_context], Project, school_id: school.id, lesson: { visibility: %w[teachers students] }) can(%i[read], Project, - remixed_from_id: Project.where(school_id: school.id, remixed_from_id: nil, lesson_id: Lesson.where(school_class_id: ClassTeacher.where(teacher_id: user.id).select(:school_class_id))).pluck(:id)) + remixed_from_id: Project.where(school_id: school.id, lesson_id: Lesson.where(school_class_id: ClassTeacher.where(teacher_id: user.id).select(:school_class_id))).pluck(:id)) end def define_school_student_abilities(user:, school:) diff --git a/app/models/project.rb b/app/models/project.rb index ebe956559..7db18960b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -31,6 +31,7 @@ module Types validate :project_with_instructions_must_belong_to_school validate :project_with_school_id_has_school_project validate :school_project_school_matches_project_school + validate :lesson_id_cannot_be_changed default_scope -> { where.not(project_type: Types::SCRATCH) } @@ -142,4 +143,10 @@ def school_project_school_matches_project_school errors.add(:school_project, 'School project school_id must match project school_id') end + + def lesson_id_cannot_be_changed + return unless lesson_id_changed? && lesson_id_was.present? + + errors.add(:lesson_id, 'cannot be changed once set') + end end diff --git a/app/views/api/lessons/index.json.jbuilder b/app/views/api/lessons/index.json.jbuilder index 6429df43c..2f0fb6b43 100644 --- a/app/views/api/lessons/index.json.jbuilder +++ b/app/views/api/lessons/index.json.jbuilder @@ -23,7 +23,7 @@ json.array!(@lessons_with_users) do |lesson, user| :identifier, :project_type ) - json.project.finished(lesson.project.finished) if lesson.project.remixed_from_id.present? + # json.project.finished(lesson.project.finished) if lesson.project.remixed_from_id.present? end json.user_name(user&.name) diff --git a/config/routes.rb b/config/routes.rb index f48edf7f1..939f3ba5c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,7 @@ resources :lessons, only: %i[index create show update destroy] do post :copy, on: :member, to: 'lessons#create_copy' + post :create_from_project, on: :collection, to: 'lessons#create_from_project' end resources :teacher_invitations, param: :token, only: :show do diff --git a/lib/concepts/lesson/operations/create_from_project.rb b/lib/concepts/lesson/operations/create_from_project.rb new file mode 100644 index 000000000..2a6cca513 --- /dev/null +++ b/lib/concepts/lesson/operations/create_from_project.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Lesson + class CreateFromProject + class << self + def call(lesson_params:, remix_origin:) + pp 'creating lesson from project!' + # ActiveRecord::Base.transaction do + response = OperationResponse.new + response[:lesson] = build_lesson_from_project(lesson_params, remix_origin) + response[:lesson].save! + response + rescue StandardError => e + Sentry.capture_exception(e) + pp(e) + errors = response[:lesson].errors.full_messages.join(',') + response[:error] = "Error creating remix of lesson: #{errors}" + response + # end + end + + private + + def build_lesson_from_project(lesson_params, remix_origin) + # original_project = Project.find_by(identifier: lesson_params[:project_identifier]) + # lesson_copy = Lesson.new(name: original_project.name) + # filtered_params = lesson_params.except(:project_identifier) + # lesson_copy.assign_attributes(filtered_params) + # lesson_copy.project = build_project_remix(original_project, lesson_params, remix_origin) + + # lesson_copy + project = Project.find_by(identifier: lesson_params[:project_identifier]) + lesson = Lesson.new( + name: project.name) + lesson.assign_attributes(lesson_params.except(:project_identifier)) + lesson.project = project + lesson + end + + # def build_project_remix(original_project, lesson_params, remix_origin) + # response = Project::CreateRemix.call( + # params: {school_id: lesson_params[:school_id]}, + # user_id: lesson_params[:user_id], + # original_project: original_project, + # remix_origin: remix_origin + # ) + # response[:project] + # end + end + end +end \ No newline at end of file From 3a5872dcdfb8d7b394d21d5ae9274fb6d78cfca1 Mon Sep 17 00:00:00 2001 From: Lois Wells Date: Thu, 29 May 2025 15:36:22 +0100 Subject: [PATCH 2/6] tidying --- app/controllers/api/lessons_controller.rb | 4 +-- .../lesson/operations/create_from_project.rb | 28 +++---------------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 7a18ed00a..582306236 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -44,9 +44,7 @@ def create_copy def create_from_project remix_origin = request.origin || request.referer - puts("lesson_params: #{lesson_params}") - - result = Lesson::CreateFromProject.call(lesson_params: lesson_params, remix_origin:) + result = Lesson::CreateFromProject.call(lesson_params:, remix_origin:) if result.success? @lesson_with_user = result[:lesson].with_user diff --git a/lib/concepts/lesson/operations/create_from_project.rb b/lib/concepts/lesson/operations/create_from_project.rb index 2a6cca513..bcab1527f 100644 --- a/lib/concepts/lesson/operations/create_from_project.rb +++ b/lib/concepts/lesson/operations/create_from_project.rb @@ -4,48 +4,28 @@ class Lesson class CreateFromProject class << self def call(lesson_params:, remix_origin:) - pp 'creating lesson from project!' - # ActiveRecord::Base.transaction do response = OperationResponse.new response[:lesson] = build_lesson_from_project(lesson_params, remix_origin) response[:lesson].save! response rescue StandardError => e Sentry.capture_exception(e) - pp(e) errors = response[:lesson].errors.full_messages.join(',') response[:error] = "Error creating remix of lesson: #{errors}" response - # end end private - def build_lesson_from_project(lesson_params, remix_origin) - # original_project = Project.find_by(identifier: lesson_params[:project_identifier]) - # lesson_copy = Lesson.new(name: original_project.name) - # filtered_params = lesson_params.except(:project_identifier) - # lesson_copy.assign_attributes(filtered_params) - # lesson_copy.project = build_project_remix(original_project, lesson_params, remix_origin) - - # lesson_copy + def build_lesson_from_project(lesson_params, _remix_origin) project = Project.find_by(identifier: lesson_params[:project_identifier]) lesson = Lesson.new( - name: project.name) + name: project.name + ) lesson.assign_attributes(lesson_params.except(:project_identifier)) lesson.project = project lesson end - - # def build_project_remix(original_project, lesson_params, remix_origin) - # response = Project::CreateRemix.call( - # params: {school_id: lesson_params[:school_id]}, - # user_id: lesson_params[:user_id], - # original_project: original_project, - # remix_origin: remix_origin - # ) - # response[:project] - # end end end -end \ No newline at end of file +end From dfc1f0b278fff7739cddb9f56e189915e51f7c9f Mon Sep 17 00:00:00 2001 From: Lois Wells Date: Fri, 30 May 2025 10:51:20 +0100 Subject: [PATCH 3/6] refining permissions and validations --- app/controllers/api/lessons_controller.rb | 8 +++++++- app/models/ability.rb | 5 +++-- app/models/lesson.rb | 14 ++++++++++++++ .../lesson/operations/create_from_project.rb | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 582306236..af987ce74 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -4,7 +4,7 @@ module Api class LessonsController < ApiController before_action :authorize_user, except: %i[index show] before_action :verify_school_class_belongs_to_school, only: :create - load_and_authorize_resource :lesson, except: [:create_from_project] + load_and_authorize_resource :lesson def index archive_scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived @@ -44,6 +44,12 @@ def create_copy def create_from_project remix_origin = request.origin || request.referer + # authorize the project if it exists + if lesson_params[:project_identifier].present? + project = Project.find_by(identifier: lesson_params[:project_identifier]) + authorize! :update, project if project + end + result = Lesson::CreateFromProject.call(lesson_params:, remix_origin:) if result.success? diff --git a/app/models/ability.rb b/app/models/ability.rb index bd65d52a8..b2f62a95c 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -67,7 +67,6 @@ def define_school_owner_abilities(school:) 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_from_project], Lesson) can(%i[create create_copy], Lesson, school_id: school.id) can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public]) end @@ -84,7 +83,9 @@ def define_school_teacher_abilities(user:, school:) can(%i[create update destroy], Lesson) do |lesson| school_teacher_can_manage_lesson?(user:, school:, lesson:) end - can(%i[create_from_project], Lesson) + can(%i[create_from_project], Lesson) do |lesson| + school_teacher_can_manage_lesson?(user:, school:, lesson:) && school_teacher_can_manage_project?(user:, school:, project: lesson.project) + end can(%i[read create_copy], Lesson, school_id: school.id, visibility: %w[teachers students]) can(%i[create], Project) do |project| school_teacher_can_manage_project?(user:, school:, project:) diff --git a/app/models/lesson.rb b/app/models/lesson.rb index ae0f0dfde..086610009 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -17,6 +17,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 + validate :project_belongs_to_the_same_school + validate :project_belongs_to_the_same_user scope :archived, -> { where.not(archived_at: nil) } scope :unarchived, -> { where(archived_at: nil) } @@ -74,4 +76,16 @@ def user_is_the_school_teacher_for_the_school_class errors.add(:user, "'#{user_id}' is not the 'school-teacher' for school_class '#{school_class.id}'") end + + def project_belongs_to_the_same_school + return unless project && school && project.school_id != school.id + + errors.add(:project, "must belong to the same school (#{school.id}) as the lesson (#{id})") + end + + def project_belongs_to_the_same_user + return unless project && user_id && project.user_id != user_id + + errors.add(:project, "must belong to the same user (#{user_id}) as the lesson (#{id})") + end end diff --git a/lib/concepts/lesson/operations/create_from_project.rb b/lib/concepts/lesson/operations/create_from_project.rb index bcab1527f..95cd4c5ce 100644 --- a/lib/concepts/lesson/operations/create_from_project.rb +++ b/lib/concepts/lesson/operations/create_from_project.rb @@ -11,7 +11,7 @@ def call(lesson_params:, remix_origin:) rescue StandardError => e Sentry.capture_exception(e) errors = response[:lesson].errors.full_messages.join(',') - response[:error] = "Error creating remix of lesson: #{errors}" + response[:error] = "Error creating lesson from project: #{errors}" response end From 41aacda0de9cd2d0ad69ac3692798928a2e37574 Mon Sep 17 00:00:00 2001 From: Lois Wells Date: Fri, 30 May 2025 11:13:07 +0100 Subject: [PATCH 4/6] tidying --- app/views/api/lessons/index.json.jbuilder | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/api/lessons/index.json.jbuilder b/app/views/api/lessons/index.json.jbuilder index 2f0fb6b43..74d8d0a43 100644 --- a/app/views/api/lessons/index.json.jbuilder +++ b/app/views/api/lessons/index.json.jbuilder @@ -23,7 +23,6 @@ json.array!(@lessons_with_users) do |lesson, user| :identifier, :project_type ) - # json.project.finished(lesson.project.finished) if lesson.project.remixed_from_id.present? end json.user_name(user&.name) From b11888326950741a111dfabcf054169a38d8ab6e Mon Sep 17 00:00:00 2001 From: Lois Wells Date: Fri, 30 May 2025 15:35:16 +0100 Subject: [PATCH 5/6] initial tests and fixing --- app/controllers/api/lessons_controller.rb | 6 ++---- app/models/lesson.rb | 4 ++-- .../lesson/operations/create_from_project.rb | 6 +++--- spec/factories/lesson.rb | 2 +- spec/models/lesson_spec.rb | 12 ++++++++++++ 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index af987ce74..9dc1659d6 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -42,15 +42,13 @@ def create_copy end def create_from_project - remix_origin = request.origin || request.referer - - # authorize the project if it exists + # authorize the project if lesson_params[:project_identifier].present? project = Project.find_by(identifier: lesson_params[:project_identifier]) authorize! :update, project if project end - result = Lesson::CreateFromProject.call(lesson_params:, remix_origin:) + result = Lesson::CreateFromProject.call(lesson_params:) if result.success? @lesson_with_user = result[:lesson].with_user diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 086610009..178927ec2 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -78,9 +78,9 @@ def user_is_the_school_teacher_for_the_school_class end def project_belongs_to_the_same_school - return unless project && school && project.school_id != school.id + return unless (project && (school || project&.school) && project&.school_id != school&.id) - errors.add(:project, "must belong to the same school (#{school.id}) as the lesson (#{id})") + errors.add(:project, "must belong to the same school (#{school&.id}) as the lesson (#{id})") end def project_belongs_to_the_same_user diff --git a/lib/concepts/lesson/operations/create_from_project.rb b/lib/concepts/lesson/operations/create_from_project.rb index 95cd4c5ce..3ffc303a2 100644 --- a/lib/concepts/lesson/operations/create_from_project.rb +++ b/lib/concepts/lesson/operations/create_from_project.rb @@ -3,9 +3,9 @@ class Lesson class CreateFromProject class << self - def call(lesson_params:, remix_origin:) + def call(lesson_params:) response = OperationResponse.new - response[:lesson] = build_lesson_from_project(lesson_params, remix_origin) + response[:lesson] = build_lesson_from_project(lesson_params) response[:lesson].save! response rescue StandardError => e @@ -17,7 +17,7 @@ def call(lesson_params:, remix_origin:) private - def build_lesson_from_project(lesson_params, _remix_origin) + def build_lesson_from_project(lesson_params) project = Project.find_by(identifier: lesson_params[:project_identifier]) lesson = Lesson.new( name: project.name diff --git a/spec/factories/lesson.rb b/spec/factories/lesson.rb index 68f0bce41..e8d6889c2 100644 --- a/spec/factories/lesson.rb +++ b/spec/factories/lesson.rb @@ -6,7 +6,7 @@ sequence(:name) { |n| "Lesson #{n}" } description { 'Description' } visibility { 'teachers' } - project { create(:project, user_id:, name:) } + project { create(:project, user_id:, name:, school: school || school_class&.school) } trait :with_project_components do transient do diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 4eba9d729..0885b10cb 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -68,8 +68,19 @@ expect(lesson).to be_invalid end + it 'requires the user id to match the user_id on the project' do + lesson.project = build(:project, user_id: SecureRandom.uuid) + expect(lesson).to be_invalid + end + + it 'requires the project to belong to the same school as the lesson' do + lesson.project = build(:project, school: create(:school)) + expect(lesson).to be_invalid + end + context 'when the lesson has a school' do before do + lesson.project.update!(school:) lesson.update!(school:) end @@ -84,6 +95,7 @@ context 'when the lesson has a school_class' do before do + lesson.project.update!(school:) lesson.update!(school_class: create(:school_class, teacher_ids: [teacher.id], school:)) end From b90e5d22f0e91cf54b17bf34b79afe01df4f61e5 Mon Sep 17 00:00:00 2001 From: Lois Wells Date: Fri, 30 May 2025 17:32:24 +0100 Subject: [PATCH 6/6] lots of test fixing --- app/controllers/api/lessons_controller.rb | 2 +- spec/factories/lesson.rb | 2 +- spec/features/lesson/archiving_a_lesson_spec.rb | 2 +- spec/features/lesson/updating_a_lesson_spec.rb | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 9dc1659d6..cbb0159d9 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -61,7 +61,7 @@ def create_from_project def update # TODO: Consider removing user_id from the lesson_params for update so users can update other users' lessons without changing ownership # OR consider dropping user_id on lessons and using teacher id/ids on the class instead - result = Lesson::Update.call(lesson: @lesson, lesson_params:) + result = Lesson::Update.call(lesson: @lesson, lesson_params: base_params) if result.success? @lesson_with_user = result[:lesson].with_user diff --git a/spec/factories/lesson.rb b/spec/factories/lesson.rb index e8d6889c2..6a22ecdc8 100644 --- a/spec/factories/lesson.rb +++ b/spec/factories/lesson.rb @@ -6,7 +6,7 @@ sequence(:name) { |n| "Lesson #{n}" } description { 'Description' } visibility { 'teachers' } - project { create(:project, user_id:, name:, school: school || school_class&.school) } + project { create(:project, user_id:, name:, school_id: school_id || school&.id || school_class&.school&.id) } trait :with_project_components do transient do diff --git a/spec/features/lesson/archiving_a_lesson_spec.rb b/spec/features/lesson/archiving_a_lesson_spec.rb index dd95cd434..f990aedf8 100644 --- a/spec/features/lesson/archiving_a_lesson_spec.rb +++ b/spec/features/lesson/archiving_a_lesson_spec.rb @@ -44,7 +44,7 @@ end it "responds 403 Forbidden when the user is not the lesson's owner" do - lesson.update!(user_id: SecureRandom.uuid) + authenticated_in_hydra_as(teacher) delete("/api/lessons/#{lesson.id}", headers:) expect(response).to have_http_status(:forbidden) diff --git a/spec/features/lesson/updating_a_lesson_spec.rb b/spec/features/lesson/updating_a_lesson_spec.rb index 948239e1c..1c7552ccb 100644 --- a/spec/features/lesson/updating_a_lesson_spec.rb +++ b/spec/features/lesson/updating_a_lesson_spec.rb @@ -12,6 +12,7 @@ } end let!(:lesson) { create(:lesson, name: 'Test Lesson', user_id: owner.id) } + let(:teacher_lesson) { create(:lesson, name: 'Test Teacher Lesson', user_id: teacher.id) } let(:teacher) { create(:teacher, school:) } let(:school) { create(:verified_school) } let(:owner) { create(:owner, school:, name: 'School Owner') } @@ -51,9 +52,7 @@ 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:) + put("/api/lessons/#{teacher_lesson.id}", headers:, params:) expect(response).to have_http_status(:forbidden) end