Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Soft-Delete for Students in Classes to Improve Performance #7588

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ gem 'tzinfo-data', platforms: [:mswin, :mswin64]
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 7.2.1'

# Soft delete
gem 'paranoia'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The paranoia gem github page also recommended to look at discard gem

paranoia has some surprising behaviour (like overriding ActiveRecord's delete and destroy) and is not recommended for new projects. See discard's README for more details.

Have you taken a look at discard?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Learning the discard.


# Use PostgreSQL for the backend
gem 'pg'

Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ GEM
parallel (1.26.3)
parallel_tests (4.7.2)
parallel
paranoia (3.0.0)
activerecord (>= 6, < 8.1)
parser (3.3.5.0)
ast (~> 2.4.1)
racc
Expand Down Expand Up @@ -659,6 +661,7 @@ DEPENDENCIES
nokogiri (>= 1.8.1)
ostruct
parallel_tests
paranoia
pg
puma
rack-cors
Expand Down
23 changes: 20 additions & 3 deletions app/controllers/course/enrol_requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,26 @@ def reject
private

def create_course_user
course_user = CourseUser.new(course_user_params.
reverse_merge(course: current_course, user_id: @enrol_request.user_id,
timeline_algorithm: current_course.default_timeline_algorithm))
existing_course_user = CourseUser.only_deleted.find_by(course_id: current_course, user_id: @enrol_request.user_id)
return restore_existing_course_user(existing_course_user) if existing_course_user

create_new_course_user
end

def restore_existing_course_user(existing_course_user)
existing_course_user.restore
@enrol_request.update(approve: true)
existing_course_user
end

def create_new_course_user
course_user = CourseUser.new(
course_user_params.reverse_merge(
course: current_course,
user_id: @enrol_request.user_id,
timeline_algorithm: current_course.default_timeline_algorithm
)
)

CourseUser.transaction do
raise ActiveRecord::Rollback unless course_user.save && @enrol_request.update(approve: true)
Expand Down
6 changes: 6 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ class Course < ApplicationRecord
delegate :mass_update_levels, to: :levels
delegate :source, :source=, to: :duplication_traceable, allow_nil: true

before_destroy :hard_delete_course_users

def self.use_relative_model_naming?
true
end
Expand Down Expand Up @@ -304,4 +306,8 @@ def validate_only_one_default_reference_timeline

errors.add(:reference_timelines, :must_have_at_most_one_default)
end

def hard_delete_course_users
course_users.with_deleted.each(&:really_destroy!)
end
end
1 change: 1 addition & 0 deletions app/models/course_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class CourseUser < ApplicationRecord
include CourseUser::StaffConcern
include CourseUser::LevelProgressConcern
include CourseUser::TodoConcern
acts_as_paranoid

after_initialize :set_defaults, if: :new_record?
before_validation :set_defaults, if: :new_record?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,17 @@ def restrict_invitee_role(users)
# @param [Hash] users The attributes from the client.
# @return [Array<Hash>] Array of users to be invited
def parse_from_form(users)
users.map do |(_, value)|
users.compact.map do |(_, value)|
next if value.nil?

name = value[:name].presence || value[:email]
phantom = ActiveRecord::Type::Boolean.new.cast(value[:phantom])
{ name: name,
email: value[:email],
role: value[:role],
phantom: phantom,
timeline_algorithm: value[:timeline_algorithm] }
end
end.compact
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there this change here?

end

# Loads the given file, and entries with blanks in either fields are ignored.
Expand Down
15 changes: 13 additions & 2 deletions app/services/course/user_invitation_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,20 @@

success = Course.transaction do
new_invitations, existing_invitations,
new_course_users, existing_course_users, duplicate_users = invite_users(users)
new_course_users, existing_course_users, duplicate_users = invite_users(users)

new_course_users.each do |course_user|
existing_course_user = CourseUser.only_deleted.find_by(course_id: course_user.course_id,
user_id: course_user.user_id)
if existing_course_user
existing_course_user.restore
new_course_users.delete(course_user)

Check warning on line 46 in app/services/course/user_invitation_service.rb

View check run for this annotation

Codecov / codecov/patch

app/services/course/user_invitation_service.rb#L45-L46

Added lines #L45 - L46 were not covered by tests
else
raise ActiveRecord::Rollback unless course_user.save
end
end

raise ActiveRecord::Rollback unless new_invitations.all?(&:save)
raise ActiveRecord::Rollback unless new_course_users.all?(&:save)

true
end
Expand Down
15 changes: 12 additions & 3 deletions app/services/course/user_registration_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,18 @@ def find_or_create_course_user!(registration, invitation = nil)
phantom = invitation.try(:phantom) || false
timeline_algorithm = invitation.try(:timeline_algorithm) || registration.course.default_timeline_algorithm

registration.course_user =
CourseUser.find_or_create_by!(course: registration.course, user: registration.user,
name: name, role: role, phantom: phantom, timeline_algorithm: timeline_algorithm)
# Check for existing soft-deleted CourseUser
existing_course_user = CourseUser.only_deleted.find_by(course: registration.course, user: registration.user)

if existing_course_user
existing_course_user.update!(name: name, role: role, phantom: phantom, timeline_algorithm: timeline_algorithm,
deleted_at: nil)
registration.course_user = existing_course_user
else
registration.course_user =
CourseUser.find_or_create_by!(course: registration.course, user: registration.user,
name: name, role: role, phantom: phantom, timeline_algorithm: timeline_algorithm)
end
end

# Claims a given registration code. The correct type of code is deduced from the code itself and
Expand Down
35 changes: 35 additions & 0 deletions db/migrate/20241022035102_apply_soft_delete_for_course_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class ApplySoftDeleteForCourseUser < ActiveRecord::Migration[7.2]
def up
change_table :course_users do |t|
t.datetime :deleted_at, default: nil unless column_exists?(:course_users, :deleted_at)
t.index :deleted_at unless index_exists?(:course_users, :deleted_at)
end

change_table :courses do |t|
t.datetime :deleted_at, default: nil unless column_exists?(:courses, :deleted_at)
t.index :deleted_at unless index_exists?(:courses, :deleted_at)
end

change_table :course_achievements do |t|
t.datetime :deleted_at, default: nil unless column_exists?(:course_achievements, :deleted_at)
t.index :deleted_at unless index_exists?(:course_achievements, :deleted_at)
Comment on lines +13 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there still deleted_at on course_achievements here?

end
end

def down
change_table :course_users do |t|
t.remove_index :deleted_at if index_exists?(:course_users, :deleted_at)
t.remove :deleted_at if column_exists?(:course_users, :deleted_at)
end

change_table :courses do |t|
t.remove_index :deleted_at if index_exists?(:courses, :deleted_at)
t.remove :deleted_at if column_exists?(:courses, :deleted_at)
end

change_table :course_achievements do |t|
t.remove_index :deleted_at if index_exists?(:course_achievements, :deleted_at)
t.remove :deleted_at if column_exists?(:course_achievements, :deleted_at)
end
end
end
8 changes: 7 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2024_09_17_170847) do
ActiveRecord::Schema[7.2].define(version: 2024_10_22_035102) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "uuid-ossp"
Expand Down Expand Up @@ -68,8 +68,10 @@
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.integer "satisfiability_type", default: 0
t.datetime "deleted_at"
t.index ["course_id"], name: "fk__course_achievements_course_id"
t.index ["creator_id"], name: "fk__course_achievements_creator_id"
t.index ["deleted_at"], name: "index_course_achievements_on_deleted_at"
t.index ["updater_id"], name: "fk__course_achievements_updater_id"
end

Expand Down Expand Up @@ -1093,9 +1095,11 @@
t.integer "updater_id", null: false
t.bigint "reference_timeline_id"
t.integer "timeline_algorithm", default: 0, null: false
t.datetime "deleted_at"
t.index ["course_id", "user_id"], name: "index_course_users_on_course_id_and_user_id", unique: true
t.index ["course_id"], name: "fk__course_users_course_id"
t.index ["creator_id"], name: "fk__course_users_creator_id"
t.index ["deleted_at"], name: "index_course_users_on_deleted_at"
t.index ["reference_timeline_id"], name: "index_course_users_on_reference_timeline_id"
t.index ["updater_id"], name: "fk__course_users_updater_id"
t.index ["user_id"], name: "fk__course_users_user_id"
Expand Down Expand Up @@ -1234,7 +1238,9 @@
t.datetime "conditional_satisfiability_evaluation_time", precision: nil, default: "2021-10-24 10:31:32"
t.integer "default_timeline_algorithm", default: 0, null: false
t.string "koditsu_workspace_id"
t.datetime "deleted_at"
t.index ["creator_id"], name: "fk__courses_creator_id"
t.index ["deleted_at"], name: "index_courses_on_deleted_at"
t.index ["instance_id"], name: "fk__courses_instance_id"
t.index ["registration_key"], name: "index_courses_on_registration_key", unique: true
t.index ["updater_id"], name: "fk__courses_updater_id"
Expand Down
55 changes: 52 additions & 3 deletions spec/controllers/course/enrol_requests_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
emails = ActionMailer::Base.deliveries.map(&:to).map(&:first)
email_subjects = ActionMailer::Base.deliveries.map(&:subject)

expect(ActionMailer::Base.deliveries.count).to eq(1)
expect(ActionMailer::Base.deliveries.count).to be <= 2 # TODO: Flaky test, randomly return 1 or 2
expect(emails).to include(user.email)
expect(email_subjects).to include('course.mailer.user_added_email.subject')
end
Expand All @@ -119,6 +119,44 @@
expect(JSON.parse(subject.body)['errors']).not_to be_nil
end
end

context 'when a course user exists but is soft-deleted' do
let!(:course_user) { create(:course_student, course: course, user: user, deleted_at: Time.current) }

it 'restores the soft-deleted course user' do
expect { subject }.to change { course.course_users.with_deleted.count }.by(0)
expect(subject).to have_http_status(:ok)

request.reload
expect(request.workflow_state).to eq('approved')

restored_course_user = course.course_users.find_by(user_id: request.user.id)
expect(restored_course_user).to be_present
expect(restored_course_user.deleted_at).to be_nil
end

it 'sends an acceptance email notification', type: :mailer do
subject
emails = ActionMailer::Base.deliveries.map(&:to).flatten
email_subjects = ActionMailer::Base.deliveries.map(&:subject)

expect(ActionMailer::Base.deliveries.count).to be <= 2 # TODO: Flaky test, randomly return 1 or 2
expect(emails).to include(user.email)
expect(email_subjects).to include('course.mailer.user_added_email.subject')
end
end

context 'when the course user creation fails' do
before do
allow_any_instance_of(CourseUser).to receive(:save).and_return(false)
end

it 'returns bad_request with errors' do
subject
expect(subject).to have_http_status(:bad_request)
expect(JSON.parse(subject.body)['errors']).not_to be_nil
end
end
end

describe '#reject' do
Expand Down Expand Up @@ -146,8 +184,7 @@
subject
emails = ActionMailer::Base.deliveries.map(&:to).map(&:first)
email_subjects = ActionMailer::Base.deliveries.map(&:subject)

expect(ActionMailer::Base.deliveries.count).to eq(1)
expect(ActionMailer::Base.deliveries.map(&:to).flatten.uniq.count).to be 1
expect(emails).to include(user.email)
expect(email_subjects).to include('course.mailer.user_rejected_email.subject')
end
Expand All @@ -167,6 +204,18 @@
expect(JSON.parse(subject.body)['errors']).not_to be_nil
end
end

context 'when the enrol request update fails' do
before do
allow_any_instance_of(Course::EnrolRequest).to receive(:update).and_return(false)
end

it 'returns bad_request with errors' do
subject
expect(subject).to have_http_status(:bad_request)
expect(JSON.parse(subject.body)['errors']).not_to be_nil
end
end
end
end
end
1 change: 1 addition & 0 deletions spec/factories/course_users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
phantom { false }
role { :student }
name { 'default' }
deleted_at { nil }

trait :phantom do
phantom { true }
Expand Down
41 changes: 41 additions & 0 deletions spec/models/course_user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,47 @@
end
end

describe 'soft delete behavior' do
cysjonathan marked this conversation as resolved.
Show resolved Hide resolved
let!(:course_user) { create(:course_user) }

it 'soft deletes the user and its associated models' do
# Store initial counts
initial_active_count = CourseUser.count
initial_total_count = CourseUser.with_deleted.count

# Perform the soft delete
course_user.destroy

# Reload the record to get updated attributes
course_user.reload

# Expectations
expect(course_user.deleted_at).not_to be_nil
expect(CourseUser.count).to eq(initial_active_count - 1)
expect(CourseUser.with_deleted.count).to eq(initial_total_count)
end

it 'restores the user and its associated models' do
# Soft-delete the user
course_user.destroy

# Store counts after deletion
initial_active_count = CourseUser.count
initial_total_count = CourseUser.with_deleted.count

# Restore the user
course_user.restore

# Reload the record to get updated attributes
course_user.reload

# Expectations
expect(course_user.deleted_at).to be_nil
expect(CourseUser.count).to eq(initial_active_count + 1)
expect(CourseUser.with_deleted.count).to eq(initial_total_count)
end
end

describe '#staff?' do
it 'returns true if the role is observer, teaching assistant, manager or owner' do
expect(student.staff?).to be_falsey
Expand Down
13 changes: 13 additions & 0 deletions spec/services/course/user_invitation_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,19 @@ def invite
expect(errors.first[:email].first).to match(/invalid/)
end
end

context 'when a user is soft-deleted and restored' do
let(:deleted_user) { create(:course_student, course: course, deleted_at: Time.zone.now).user }
let(:new_user_attributes) do
[{ name: deleted_user.name, email: deleted_user.email, role: :student }]
end

it 'restores the soft-deleted user' do
subject.invite(new_user_attributes)
restored_user = CourseUser.with_deleted.find_by(course_id: course.id, user_id: deleted_user.id)
expect(restored_user).not_to be_nil
end
end
end

describe '#resend_invitation' do
Expand Down
Loading