From b91a08b2ec8b2e7892040b6aa2d48e83208fb9f2 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Mon, 9 Feb 2026 10:29:56 +0000 Subject: [PATCH 1/5] Rename the 'Archiving a Lesson' spec. --- ...on_spec.rb => destroying_a_lesson_spec.rb} | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) rename spec/features/lesson/{archiving_a_lesson_spec.rb => destroying_a_lesson_spec.rb} (80%) diff --git a/spec/features/lesson/archiving_a_lesson_spec.rb b/spec/features/lesson/destroying_a_lesson_spec.rb similarity index 80% rename from spec/features/lesson/archiving_a_lesson_spec.rb rename to spec/features/lesson/destroying_a_lesson_spec.rb index dd95cd434..807c1bfa3 100644 --- a/spec/features/lesson/archiving_a_lesson_spec.rb +++ b/spec/features/lesson/destroying_a_lesson_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe 'Archiving a lesson', type: :request do +RSpec.describe 'Destroying a lesson', type: :request do before do authenticated_in_hydra_as(owner) end @@ -19,23 +19,16 @@ 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) + it 'destroys the lesson' do + lesson_id = lesson.id + delete("/api/lessons/#{lesson_id}", headers:) + expect(Lesson.exists?(lesson_id)).to be(false) end - it 'archives the lesson' do + it 'destroys the associated project' do + project_id = lesson.project.id 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) + expect(Project.exists?(project_id)).to be(false) end it 'responds 401 Unauthorized when no token is given' do From 9713cd6b50636ff711aa332bb03dc9909c8ed7e3 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Mon, 9 Feb 2026 10:30:42 +0000 Subject: [PATCH 2/5] Make LessonsController#destroy peform hard delete. This commit changes the behaviour of LessonsController#destroy from an archive (soft-delete) operation to a hard destroy operation. Due to the possibility of having existing lessons in the database that were soft-deleted, we retain the `Lesson.unarchived` scope and only return Lessons where `archived_at` is null. We also remove the Archive and Unarchive operations, since these are no longer needed. --- app/controllers/api/lessons_controller.rb | 16 ++++++--------- app/dashboards/lesson_dashboard.rb | 2 -- app/models/lesson.rb | 22 +-------------------- app/views/api/lessons/_lesson.json.jbuilder | 1 - lib/concepts/lesson/operations/archive.rb | 17 ---------------- lib/concepts/lesson/operations/unarchive.rb | 17 ---------------- 6 files changed, 7 insertions(+), 68 deletions(-) delete mode 100644 lib/concepts/lesson/operations/archive.rb delete mode 100644 lib/concepts/lesson/operations/unarchive.rb diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 7071c1f50..75b8b1691 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -62,21 +62,17 @@ def update 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 + @lesson.destroy! + head :no_content + rescue StandardError => e + Sentry.capture_exception(e) + render json: { error: "Error destroying lesson: #{e}" }, status: :unprocessable_entity end private def filtered_lessons_scope - archive_scope = params[:include_archived] == 'true' ? Lesson : Lesson.unarchived - scope = params[:school_class_id] ? archive_scope.where(school_class_id: params[:school_class_id]) : archive_scope + scope = params[:school_class_id] ? Lesson.unarchived.where(school_class_id: params[:school_class_id]) : Lesson.unarchived scope = scope.joins(:project).where(projects: { identifier: params[:project_identifier] }) if params[:project_identifier].present? scope.order(created_at: :asc) end diff --git a/app/dashboards/lesson_dashboard.rb b/app/dashboards/lesson_dashboard.rb index 2a5775315..f8e3b719b 100644 --- a/app/dashboards/lesson_dashboard.rb +++ b/app/dashboards/lesson_dashboard.rb @@ -22,7 +22,6 @@ class LessonDashboard < Administrate::BaseDashboard description: Field::String, visibility: Field::String, due_date: Field::DateTime, - archived_at: Field::DateTime, created_at: Field::DateTime, updated_at: Field::DateTime }.freeze @@ -53,7 +52,6 @@ class LessonDashboard < Administrate::BaseDashboard description visibility due_date - archived_at created_at updated_at ].freeze diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 4578adbb5..9a23c8819 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -5,11 +5,10 @@ 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_one :project, dependent: :nullify + has_one :project, dependent: :destroy accepts_nested_attributes_for :project before_validation :assign_school_from_school_class - before_destroy -> { throw :abort } validates :user_id, presence: true validates :name, presence: true @@ -18,7 +17,6 @@ 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 - scope :archived, -> { where.not(archived_at: nil) } scope :unarchived, -> { where(archived_at: nil) } def self.users @@ -34,24 +32,6 @@ 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 - def submitted_count return 0 unless project diff --git a/app/views/api/lessons/_lesson.json.jbuilder b/app/views/api/lessons/_lesson.json.jbuilder index f34f0f861..c46dc21dc 100644 --- a/app/views/api/lessons/_lesson.json.jbuilder +++ b/app/views/api/lessons/_lesson.json.jbuilder @@ -11,7 +11,6 @@ json.call( :description, :visibility, :due_date, - :archived_at, :created_at, :updated_at ) diff --git a/lib/concepts/lesson/operations/archive.rb b/lib/concepts/lesson/operations/archive.rb deleted file mode 100644 index 3bd8bd547..000000000 --- a/lib/concepts/lesson/operations/archive.rb +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index f7c3170dc..000000000 --- a/lib/concepts/lesson/operations/unarchive.rb +++ /dev/null @@ -1,17 +0,0 @@ -# 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 From fabd12e33a71464200ed413096f0d1ca77bb6a28 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Mon, 9 Feb 2026 10:34:54 +0000 Subject: [PATCH 3/5] Update specs for hard-delete of lessons. --- spec/concepts/lesson/archive_spec.rb | 17 ---- spec/concepts/lesson/unarchive_spec.rb | 17 ---- spec/features/lesson/listing_lessons_spec.rb | 31 ++----- spec/models/lesson_spec.rb | 87 ++------------------ 4 files changed, 16 insertions(+), 136 deletions(-) delete mode 100644 spec/concepts/lesson/archive_spec.rb delete mode 100644 spec/concepts/lesson/unarchive_spec.rb diff --git a/spec/concepts/lesson/archive_spec.rb b/spec/concepts/lesson/archive_spec.rb deleted file mode 100644 index 14113f6b0..000000000 --- a/spec/concepts/lesson/archive_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Lesson::Archive, type: :unit do - 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 deleted file mode 100644 index 99dac2333..000000000 --- a/spec/concepts/lesson/unarchive_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Lesson::Unarchive, type: :unit do - 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/listing_lessons_spec.rb b/spec/features/lesson/listing_lessons_spec.rb index 30817538e..2ff1d68ca 100644 --- a/spec/features/lesson/listing_lessons_spec.rb +++ b/spec/features/lesson/listing_lessons_spec.rb @@ -61,8 +61,8 @@ expect(data.first[:user_name]).to be_nil end - it 'does not include archived lessons' do - lesson.archive! + it 'does not include lessons that were previously archived' do + lesson.update!(archived_at: Time.now.utc) get('/api/lessons', headers:) data = JSON.parse(response.body, symbolize_names: true) @@ -70,13 +70,14 @@ expect(data.size).to eq(0) end - it 'includes archived lessons if ?include_archived=true is set' do - lesson.archive! + it 'does not include previously archived lessons when filtering by school_class_id' do + lesson.update!(school_class_id: school_class.id) + lesson.update!(archived_at: Time.now.utc) - get('/api/lessons?include_archived=true', headers:) + get("/api/lessons?school_class_id=#{school_class.id}", headers:) data = JSON.parse(response.body, symbolize_names: true) - expect(data.size).to eq(1) + expect(data.size).to eq(0) end it 'does not include lessons with no class if school_class_id provided' do @@ -103,24 +104,6 @@ expect(data.size).to eq(1) end - it 'defaults to not including archived lessons from the class if school_class_id provided' do - lesson.archive! - lesson.update!(school_class_id: school_class.id) - get("/api/lessons?school_class_id=#{school_class.id}", headers:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data.size).to eq(0) - end - - it 'includes archived lessons from class if include_archived=true and school_class_id provided' do - lesson.archive! - lesson.update!(school_class_id: school_class.id) - get("/api/lessons?include_archived=true&school_class_id=#{school_class.id}", headers:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data.size).to eq(1) - end - it 'includes the submitted_count for each lesson' do lesson.update!(school_class_id: school_class.id) remix = create(:project, school:, remixed_from_id: lesson.project.id, user_id: student.id) diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 05d7b3568..8947e3f75 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -41,9 +41,16 @@ end describe 'callbacks' do - it 'cannot be destroyed and should be archived instead' do + it 'can be destroyed' do lesson = create(:lesson) - expect { lesson.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) + expect { lesson.destroy! }.not_to raise_error + end + + it 'destroys associated project' do + lesson = create(:lesson) + project_id = lesson.project.id + lesson.destroy! + expect(Project.exists?(project_id)).to be(false) end end @@ -112,19 +119,6 @@ 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) } @@ -225,69 +219,6 @@ 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 - describe '#submitted_count' do it 'returns 0 if there is no project' do lesson = create(:lesson, project: nil) From af2da7e64f15c078977ea6ceb6fa944f9c4fbc3b Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Mon, 9 Feb 2026 13:25:09 +0000 Subject: [PATCH 4/5] Simplify error handling in LessonsController#destroy The initial commit was unnecessarily defensive. This is simpler. --- app/controllers/api/lessons_controller.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 75b8b1691..745f38348 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -64,9 +64,6 @@ def update def destroy @lesson.destroy! head :no_content - rescue StandardError => e - Sentry.capture_exception(e) - render json: { error: "Error destroying lesson: #{e}" }, status: :unprocessable_entity end private From 99493c031947e484287445d667d0f403b37ce373 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Mon, 9 Feb 2026 14:32:33 +0000 Subject: [PATCH 5/5] Add archived_at to Lessons.ignored_columns. This commit ignores `Lesson.archived_at` and removes the `unarchived` scope from LessonsController. Now, all Lessons are in scope, but currently there are no Lessons in the database that have been archived. --- app/controllers/api/lessons_controller.rb | 2 +- app/models/lesson.rb | 4 ++-- spec/features/lesson/listing_lessons_spec.rb | 19 ------------------- spec/models/lesson_spec.rb | 13 ------------- 4 files changed, 3 insertions(+), 35 deletions(-) diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 745f38348..59362570f 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -69,7 +69,7 @@ def destroy private def filtered_lessons_scope - scope = params[:school_class_id] ? Lesson.unarchived.where(school_class_id: params[:school_class_id]) : Lesson.unarchived + scope = params[:school_class_id] ? Lesson.where(school_class_id: params[:school_class_id]) : Lesson.all scope = scope.joins(:project).where(projects: { identifier: params[:project_identifier] }) if params[:project_identifier].present? scope.order(created_at: :asc) end diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 9a23c8819..e3b7a433d 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Lesson < ApplicationRecord + self.ignored_columns += [:archived_at] + 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 @@ -17,8 +19,6 @@ 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 - scope :unarchived, -> { where(archived_at: nil) } - def self.users User.from_userinfo(ids: pluck(:user_id)) end diff --git a/spec/features/lesson/listing_lessons_spec.rb b/spec/features/lesson/listing_lessons_spec.rb index 2ff1d68ca..4128991f9 100644 --- a/spec/features/lesson/listing_lessons_spec.rb +++ b/spec/features/lesson/listing_lessons_spec.rb @@ -61,25 +61,6 @@ expect(data.first[:user_name]).to be_nil end - it 'does not include lessons that were previously archived' do - lesson.update!(archived_at: Time.now.utc) - - get('/api/lessons', headers:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data.size).to eq(0) - end - - it 'does not include previously archived lessons when filtering by school_class_id' do - lesson.update!(school_class_id: school_class.id) - lesson.update!(archived_at: Time.now.utc) - - get("/api/lessons?school_class_id=#{school_class.id}", headers:) - data = JSON.parse(response.body, symbolize_names: true) - - expect(data.size).to eq(0) - end - it 'does not include lessons with no class if school_class_id provided' do get("/api/lessons?school_class_id=#{school_class.id}", headers:) data = JSON.parse(response.body, symbolize_names: true) diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 8947e3f75..c5e581e87 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -119,19 +119,6 @@ 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 let(:school) { create(:school) }