Skip to content

Commit 772f551

Browse files
feat(courses): block students from suspended courses
- unified suspension check to helper function - added tests
1 parent 404cebd commit 772f551

10 files changed

Lines changed: 193 additions & 9 deletions

File tree

app/controllers/course/controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def current_ability
5757
private
5858

5959
def handle_access_denied(exception)
60-
return super unless current_course_user&.is_suspended?
60+
return super unless current_course_user&.suspended_from_course?(current_ability)
6161

6262
render json: { is_suspended: true, errors: exception.message }, status: :forbidden
6363
end

app/controllers/course/courses_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def index
1010
def show
1111
head :unauthorized and return unless current_user.present? || current_course.published
1212

13-
return if current_course_user&.is_suspended? && cannot?(:manage, current_course)
13+
return if current_course_user&.suspended_from_course?(current_ability)
1414

1515
if can?(:manage, current_course) || current_course.user?(current_user)
1616
@currently_active_announcements = current_course.announcements.currently_active.includes(:creator)

app/models/components/course/course_ability_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def allow_unregistered_users_registering_courses
3535
end
3636

3737
def allow_registered_users_showing_course
38-
can :read, Course, id: course.id unless course_user.is_suspended?
38+
can :read, Course, id: course.id unless course_user.is_suspended || course.is_suspended
3939
end
4040

4141
def allow_staff_show_course_users

app/models/course_user.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,16 @@ def real_student?
213213
student? && !phantom
214214
end
215215

216+
# Test whether this course_user should be blocked from accessing the course.
217+
# This can be either because the user is suspended, or the course itself is suspended.
218+
# Users with manage permissions (managers, owners, site admins) are unaffected by suspension,
219+
# since they need to be able to access the course to unsuspend it.
220+
#
221+
# @return [Boolean]
222+
def suspended_from_course?(ability)
223+
ability&.cannot?(:manage, course) && ((student? && course.is_suspended) || is_suspended)
224+
end
225+
216226
# Returns my students in the course.
217227
# If a course_user is the manager of a group, all other users in the group with the group role of
218228
# normal will be considered as the students of the course_user.

app/views/course/courses/_course_data.json.jbuilder

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ json.instructors instructors do |instructor|
1515
json.imageUrl user_image(instructor)
1616
end
1717

18-
if can?(:manage, current_course) || (current_course.user?(current_user) && !current_course_user&.is_suspended)
18+
is_suspended_user = current_course_user&.suspended_from_course?(current_ability)
19+
if can?(:manage, current_course) || (current_course.user?(current_user) && !is_suspended_user)
1920
# Announcements
2021
if @currently_active_announcements && !@currently_active_announcements.empty?
2122
json.currentlyActiveAnnouncements @currently_active_announcements do |announcement|
@@ -55,6 +56,7 @@ if can?(:manage, current_course) || (current_course.user?(current_user) && !curr
5556
end
5657
end
5758

58-
is_suspended_user = current_course_user&.is_suspended && cannot?(:manage, current_course)
59+
json.isSuspended current_course.is_suspended
5960
json.isSuspendedUser is_suspended_user
61+
json.courseSuspensionMessage current_course.course_suspension_message if current_course.is_suspended
6062
json.userSuspensionMessage current_course.user_suspension_message if is_suspended_user

client/app/bundles/common/ErrorPage.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ const SuspendedPage = (): JSX.Element => {
243243

244244
const dispatch = useAppDispatch();
245245
const course = useAppSelector((state) => getCourseEntity(state, +courseId!));
246+
const suspendedSubtitle = course?.isSuspended
247+
? course?.courseSuspensionMessage
248+
: course?.userSuspensionMessage;
246249

247250
useEffectOnce(() => {
248251
if (sourceURL) window.history.replaceState(null, '', sourceURL);
@@ -288,11 +291,13 @@ const SuspendedPage = (): JSX.Element => {
288291
]}
289292
illustrationAlt="Forbidden illustration"
290293
illustrationSrc={forbiddenIllustration}
291-
subtitle={
292-
course?.userSuspensionMessage ?? t(courseTranslations.suspendedSubtitle)
293-
}
294+
subtitle={suspendedSubtitle ?? t(courseTranslations.suspendedSubtitle)}
294295
tip={sourceURL}
295-
title={t(translations.userSuspended)}
296+
title={
297+
course?.isSuspended
298+
? t(translations.courseSuspended)
299+
: t(translations.userSuspended)
300+
}
296301
/>
297302
);
298303
};

client/app/types/course/courses.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export interface CourseEntity extends CourseMiniEntity {
7474
permissions: CourseDataPermissions;
7575
isSuspendedUser?: boolean;
7676
userSuspensionMessage?: string;
77+
isSuspended: boolean;
78+
courseSuspensionMessage?: string;
7779
}
7880

7981
export interface NewCourseFormData {

spec/controllers/course/admin/admin_controller_spec.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,75 @@
104104
end
105105
end
106106

107+
describe '#suspend' do
108+
subject { patch :suspend, params: { course_id: course } }
109+
110+
context 'when the user is a Course Owner' do
111+
let(:user) { create(:course_owner, course: course).user }
112+
113+
it 'suspends the course' do
114+
subject
115+
expect(course.reload.is_suspended).to be true
116+
end
117+
end
118+
119+
context 'when the user is a Course Manager' do
120+
let(:user) { create(:course_manager, course: course).user }
121+
122+
it 'suspends the course' do
123+
subject
124+
expect(course.reload.is_suspended).to be true
125+
end
126+
end
127+
128+
context 'when the user is a Teaching Assistant' do
129+
let(:user) { create(:course_teaching_assistant, course: course).user }
130+
131+
it { expect { subject }.to raise_exception(CanCan::AccessDenied) }
132+
end
133+
134+
context 'when the user is a Course Student' do
135+
let(:user) { create(:course_student, course: course).user }
136+
137+
it { expect { subject }.to raise_exception(CanCan::AccessDenied) }
138+
end
139+
end
140+
141+
describe '#unsuspend' do
142+
let(:course) { create(:course, is_suspended: true) }
143+
subject { patch :unsuspend, params: { course_id: course } }
144+
145+
context 'when the user is a Course Owner' do
146+
let(:user) { create(:course_owner, course: course).user }
147+
148+
it 'unsuspends the course' do
149+
subject
150+
expect(course.reload.is_suspended).to be false
151+
end
152+
end
153+
154+
context 'when the user is a Course Manager' do
155+
let(:user) { create(:course_manager, course: course).user }
156+
157+
it 'unsuspends the course' do
158+
subject
159+
expect(course.reload.is_suspended).to be false
160+
end
161+
end
162+
163+
context 'when the user is a Teaching Assistant' do
164+
let(:user) { create(:course_teaching_assistant, course: course).user }
165+
166+
it { expect { subject }.to raise_exception(CanCan::AccessDenied) }
167+
end
168+
169+
context 'when the user is a Course Student' do
170+
let(:user) { create(:course_student, course: course).user }
171+
172+
it { expect { subject }.to raise_exception(CanCan::AccessDenied) }
173+
end
174+
end
175+
107176
describe '#destroy' do
108177
subject { delete :destroy, params: { course_id: course } }
109178
before { controller.instance_variable_set(:@course, course) }

spec/controllers/course/courses_controller_spec.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,41 @@
5757
end
5858
end
5959

60+
context 'when the course is suspended and the user is a student' do
61+
before { controller_sign_in(controller, user) }
62+
let(:course) { create(:course, published: true, is_suspended: true) }
63+
let!(:course_user) { create(:course_student, course: course, user: user) }
64+
65+
it { is_expected.to have_http_status(:ok) }
66+
67+
it 'sets isSuspended and isSuspendedUser in the response body' do
68+
subject
69+
course_data = JSON.parse(response.body).fetch('course')
70+
expect(course_data['isSuspended']).to be true
71+
expect(course_data['isSuspendedUser']).to be true
72+
end
73+
74+
it 'includes the course suspension message in the response body' do
75+
subject
76+
expect(JSON.parse(response.body).fetch('course')).to have_key('courseSuspensionMessage')
77+
end
78+
end
79+
80+
context 'when the course is suspended and the user is a manager' do
81+
before { controller_sign_in(controller, user) }
82+
let(:course) { create(:course, published: true, is_suspended: true) }
83+
let!(:course_user) { create(:course_manager, course: course, user: user) }
84+
85+
it { is_expected.to have_http_status(:ok) }
86+
87+
it 'sets isSuspended but not isSuspendedUser in the response body' do
88+
subject
89+
course_data = JSON.parse(response.body).fetch('course')
90+
expect(course_data['isSuspended']).to be true
91+
expect(course_data['isSuspendedUser']).to be false
92+
end
93+
end
94+
6095
context 'when the user is not enrolled' do
6196
before { controller_sign_in(controller, user) }
6297

spec/models/course_user_spec.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,67 @@
334334
end
335335
end
336336

337+
describe '#suspended_from_course?' do
338+
def ability_for(course_user)
339+
Ability.new(course_user.user, course, course_user)
340+
end
341+
342+
context 'when the course is not suspended' do
343+
it 'returns false for an active student' do
344+
expect(student.suspended_from_course?(ability_for(student))).to be false
345+
end
346+
347+
it 'returns true for an individually suspended student' do
348+
student.update!(is_suspended: true)
349+
expect(student.suspended_from_course?(ability_for(student))).to be true
350+
end
351+
352+
it 'returns false for an individually suspended manager' do
353+
manager.update!(is_suspended: true)
354+
expect(manager.suspended_from_course?(ability_for(manager))).to be false
355+
end
356+
357+
it 'returns false for an individually suspended owner' do
358+
course_owner.update!(is_suspended: true)
359+
expect(course_owner.suspended_from_course?(ability_for(course_owner))).to be false
360+
end
361+
362+
it 'returns true for an individually suspended teaching assistant' do
363+
teaching_assistant.update!(is_suspended: true)
364+
expect(teaching_assistant.suspended_from_course?(ability_for(teaching_assistant))).to be true
365+
end
366+
367+
it 'returns true for an individually suspended observer' do
368+
observer.update!(is_suspended: true)
369+
expect(observer.suspended_from_course?(ability_for(observer))).to be true
370+
end
371+
end
372+
373+
context 'when the course is suspended' do
374+
before { course.update!(is_suspended: true) }
375+
376+
it 'returns true for an active student' do
377+
expect(student.suspended_from_course?(ability_for(student))).to be true
378+
end
379+
380+
it 'returns false for a manager' do
381+
expect(manager.suspended_from_course?(ability_for(manager))).to be false
382+
end
383+
384+
it 'returns false for an owner' do
385+
expect(course_owner.suspended_from_course?(ability_for(course_owner))).to be false
386+
end
387+
388+
it 'returns false for a teaching assistant' do
389+
expect(teaching_assistant.suspended_from_course?(ability_for(teaching_assistant))).to be false
390+
end
391+
392+
it 'returns false for an observer' do
393+
expect(observer.suspended_from_course?(ability_for(observer))).to be false
394+
end
395+
end
396+
end
397+
337398
describe '#latest_learning_rate_record' do
338399
it 'returns the latest learning rate record' do
339400
create(:learning_rate_record, course_user: student, learning_rate: 1)

0 commit comments

Comments
 (0)