Skip to content

Commit 1d60675

Browse files
Support multiple teachers per class (#456)
## What's Changed? - Added a `ClassTeacher` model which allows classes to have multiple teachers - `Class` model now accepts nested attributes for `ClassTeacher`s - `ClassMember` model has been renamed to `ClassStudent` to provide consistency with the approach for `SchoolStudent`, `SchoolMember` etc. - `ClassMember` operations will now deal with both teachers and students in keeping with the `SchoolMember` module closes #463 --------- Co-authored-by: Kristina <[email protected]>
1 parent 5e1abc5 commit 1d60675

File tree

58 files changed

+625
-343
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+625
-343
lines changed

app/controllers/api/class_members_controller.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ class ClassMembersController < ApiController
55
before_action :authorize_user
66
load_and_authorize_resource :school
77
load_and_authorize_resource :school_class, through: :school, through_association: :classes, id_param: :class_id
8-
load_and_authorize_resource :class_member, through: :school_class, through_association: :members
8+
load_and_authorize_resource :class_student, through: :school_class, through_association: :students
99

1010
def index
11-
@class_members = @school_class.members.accessible_by(current_ability)
12-
result = ClassMember::List.call(school_class: @school_class, class_members: @class_members, token: current_user.token)
11+
@class_students = @school_class.students.accessible_by(current_ability)
12+
result = ClassMember::List.call(school_class: @school_class, class_students: @class_students, token: current_user.token)
1313

1414
if result.success?
1515
@class_members = result[:class_members]

app/controllers/api/projects_controller.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def show
2222
@user = project_with_user[1]
2323
end
2424

25+
@project.user_id = @current_user.id if class_teacher?(@project)
2526
render :show, formats: [:json]
2627
end
2728

@@ -105,6 +106,10 @@ def school_owner?
105106
school && current_user.school_owner?(school)
106107
end
107108

109+
def class_teacher?(project)
110+
project.lesson_id.present? && project.lesson.school_class.present? && project.lesson.school_class.teacher_ids.include?(current_user.id)
111+
end
112+
108113
def school
109114
@school ||= @project&.school || School.find_by(id: base_params[:school_id])
110115
end

app/controllers/api/school_classes_controller.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@ class SchoolClassesController < ApiController
88

99
def index
1010
school_classes = @school.classes.accessible_by(current_ability)
11-
school_classes = school_classes.where(teacher_id: current_user.id) if params[:my_classes] == 'true'
11+
school_classes = school_classes.joins(:teachers).where(teachers: { teacher_id: current_user.id }) if params[:my_classes] == 'true'
1212
@school_classes_with_teachers = school_classes.with_teachers
1313
render :index, formats: [:json], status: :ok
1414
end
1515

1616
def show
17-
@school_class_with_teacher = @school_class.with_teacher
17+
@school_class_with_teachers = @school_class.with_teachers
1818
render :show, formats: [:json], status: :ok
1919
end
2020

2121
def create
22-
result = SchoolClass::Create.call(school: @school, school_class_params:)
22+
result = SchoolClass::Create.call(school: @school, school_class_params:, current_user:)
2323

2424
if result.success?
25-
@school_class_with_teacher = result[:school_class].with_teacher
25+
@school_class_with_teachers = result[:school_class].with_teachers
2626
render :show, formats: [:json], status: :created
2727
else
2828
render json: { error: result[:error] }, status: :unprocessable_entity
@@ -34,7 +34,7 @@ def update
3434
result = SchoolClass::Update.call(school_class:, school_class_params:)
3535

3636
if result.success?
37-
@school_class_with_teacher = result[:school_class].with_teacher
37+
@school_class_with_teachers = result[:school_class].with_teachers
3838
render :show, formats: [:json], status: :ok
3939
else
4040
render json: { error: result[:error] }, status: :unprocessable_entity
@@ -55,7 +55,7 @@ def destroy
5555

5656
def school_class_params
5757
# A school teacher may only create classes they own.
58-
params.require(:school_class).permit(:name, :description).merge(teacher_id: current_user.id)
58+
params.require(:school_class).permit(:name, :description)
5959
end
6060

6161
def school_owner?

app/models/ability.rb

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def define_school_owner_abilities(school:)
6161
can(%i[read], :school_member)
6262
can(%i[read create update destroy], SchoolClass, school: { id: school.id })
6363
can(%i[read], Project, school_id: school.id, lesson: { visibility: %w[teachers students] })
64-
can(%i[read create create_batch destroy], ClassMember, school_class: { school: { id: school.id } })
64+
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } })
6565
can(%i[read create destroy], :school_owner)
6666
can(%i[read create destroy], :school_teacher)
6767
can(%i[read create create_batch update destroy], :school_student)
@@ -73,9 +73,8 @@ def define_school_teacher_abilities(user:, school:)
7373
can(%i[read], School, id: school.id)
7474
can(%i[read], :school_member)
7575
can(%i[create], SchoolClass, school: { id: school.id })
76-
can(%i[read update destroy], SchoolClass, school: { id: school.id }, teacher_id: user.id)
77-
can(%i[read create create_batch destroy], ClassMember,
78-
school_class: { school: { id: school.id }, teacher_id: user.id })
76+
can(%i[read update destroy], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id })
77+
can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id }, teachers: { teacher_id: user.id } })
7978
can(%i[read], :school_owner)
8079
can(%i[read], :school_teacher)
8180
can(%i[read create create_batch update], :school_student)
@@ -88,24 +87,24 @@ def define_school_teacher_abilities(user:, school:)
8887
end
8988
can(%i[read update], Project, school_id: school.id, lesson: { visibility: %w[teachers students] })
9089
can(%i[read], Project,
91-
remixed_from_id: Project.where(user_id: user.id, school_id: school.id, remixed_from_id: nil).pluck(:id))
90+
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))
9291
end
9392

9493
def define_school_student_abilities(user:, school:)
9594
can(%i[read], School, id: school.id)
96-
can(%i[read], SchoolClass, school: { id: school.id }, members: { student_id: user.id })
95+
can(%i[read], SchoolClass, school: { id: school.id }, students: { student_id: user.id })
9796
# Ensure no access to ClassMember resources, relationships otherwise allow access in some circumstances.
98-
can(%i[read], Lesson, school_id: school.id, visibility: 'students', school_class: { members: { student_id: user.id } })
97+
can(%i[read], Lesson, school_id: school.id, visibility: 'students', school_class: { students: { student_id: user.id } })
9998
can(%i[read create update], Project, school_id: school.id, user_id: user.id, lesson_id: nil)
100-
can(%i[read], Project, lesson: { school_id: school.id, school_class: { members: { student_id: user.id } } })
99+
can(%i[read], Project, lesson: { school_id: school.id, school_class: { students: { student_id: user.id } } })
101100
can(%i[show_finished set_finished], SchoolProject, project: { user_id: user.id, lesson_id: nil }, school_id: school.id)
102101
end
103102

104103
def school_teacher_can_manage_lesson?(user:, school:, lesson:)
105104
is_my_lesson = lesson.school_id == school.id && lesson.user_id == user.id
106-
is_my_class = lesson.school_class && lesson.school_class.teacher_id == user.id
105+
is_my_class = lesson.school_class&.teacher_ids&.include?(user.id)
107106

108-
is_my_lesson && (is_my_class || !lesson.school_class)
107+
is_my_class || (is_my_lesson && !lesson.school_class)
109108
end
110109

111110
def school_teacher_can_manage_project?(user:, school:, project:)

app/models/class_member.rb renamed to app/models/class_student.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
class ClassMember < ApplicationRecord
3+
class ClassStudent < ApplicationRecord
44
belongs_to :school_class
55
delegate :school, to: :school_class
66
attr_accessor :student

app/models/class_teacher.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
class ClassTeacher < ApplicationRecord
4+
belongs_to :school_class
5+
delegate :school, to: :school_class
6+
attr_accessor :teacher
7+
8+
validates :teacher_id, presence: true, uniqueness: {
9+
scope: :school_class_id,
10+
case_sensitive: false
11+
}
12+
13+
validate :teacher_has_the_school_teacher_role_for_the_school
14+
15+
has_paper_trail(
16+
meta: {
17+
meta_school_id: ->(cm) { cm.school_class&.school_id }
18+
}
19+
)
20+
21+
private
22+
23+
def teacher_has_the_school_teacher_role_for_the_school
24+
return unless teacher_id_changed? && errors.blank? && teacher.present?
25+
26+
return if teacher.school_teacher?(school)
27+
28+
msg = "'#{teacher.id}' does not have the 'school-teacher' role for organisation '#{school.id}'"
29+
errors.add(:teacher, msg)
30+
end
31+
end

app/models/lesson.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def user_has_the_school_owner_or_school_teacher_role_for_the_school
7070
end
7171

7272
def user_is_the_school_teacher_for_the_school_class
73-
return if !school_class || user_id == school_class.teacher_id
73+
return if !school_class || school_class.teacher_ids.include?(user_id)
7474

7575
errors.add(:user, "'#{user_id}' is not the 'school-teacher' for school_class '#{school_class.id}'")
7676
end

app/models/project.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Project < ApplicationRecord
2121
validate :identifier_cannot_be_taken_by_another_user
2222
validates :locale, presence: true, unless: :user_id
2323
validate :user_has_a_role_within_the_school
24-
validate :user_is_a_member_or_the_owner_of_the_lesson
24+
validate :user_is_class_teacher_or_student
2525
validate :project_with_instructions_must_belong_to_school
2626
validate :project_with_school_id_has_school_project
2727
validate :school_project_school_matches_project_school
@@ -96,11 +96,22 @@ def user_has_a_role_within_the_school
9696
errors.add(:user, msg)
9797
end
9898

99-
def user_is_a_member_or_the_owner_of_the_lesson
99+
def user_is_class_teacher_or_student
100100
# TODO: Revisit the case where the lesson is not associated to a class i.e. when we build a lesson library
101-
return if !lesson || user_id == lesson.user_id || !lesson.school_class || lesson.school_class&.members&.exists?(student_id: user_id)
101+
no_lesson = !lesson
102+
no_school_class = lesson && !lesson.school_class
102103

103-
errors.add(:user, "'#{user_id}' is not the owner or a member of the lesson '#{lesson_id}'")
104+
return if no_lesson || no_school_class || user_is_class_student || user_is_class_teacher
105+
106+
errors.add(:user, "'#{user_id}' is not a class member or the owner of the lesson '#{lesson_id}'")
107+
end
108+
109+
def user_is_class_student
110+
lesson&.school_class&.students&.exists?(student_id: user_id)
111+
end
112+
113+
def user_is_class_teacher
114+
lesson&.school_class&.teachers&.exists?(teacher_id: user_id)
104115
end
105116

106117
def project_with_instructions_must_belong_to_school

app/models/school_class.rb

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
class SchoolClass < ApplicationRecord
44
belongs_to :school
5-
has_many :members, class_name: :ClassMember, inverse_of: :school_class, dependent: :destroy
5+
has_many :students, class_name: :ClassStudent, inverse_of: :school_class, dependent: :destroy
6+
has_many :teachers, class_name: :ClassTeacher, inverse_of: :school_class, dependent: :destroy
67
has_many :lessons, dependent: :nullify
8+
accepts_nested_attributes_for :teachers
9+
10+
scope :with_teachers, ->(user_id) { joins(:teachers).where(teachers: { id: user_id }) }
711

8-
validates :teacher_id, presence: true
912
validates :name, presence: true
10-
validate :teacher_has_the_school_teacher_role_for_the_school
13+
validate :school_class_has_at_least_one_teacher
1114

1215
has_paper_trail(
1316
meta: {
@@ -16,27 +19,28 @@ class SchoolClass < ApplicationRecord
1619
)
1720

1821
def self.teachers
19-
User.from_userinfo(ids: pluck(:teacher_id))
22+
teacher_ids = all.map(&:teacher_ids).flatten.uniq
23+
User.from_userinfo(ids: teacher_ids)
2024
end
2125

2226
def self.with_teachers
2327
by_id = teachers.index_by(&:id)
24-
all.map { |instance| [instance, by_id[instance.teacher_id]] }
28+
all.map { |instance| [instance, instance.teacher_ids.map { |teacher_id| by_id[teacher_id] }] }
2529
end
2630

27-
def with_teacher
28-
[self, User.from_userinfo(ids: teacher_id).first]
31+
def teacher_ids
32+
teachers.pluck(:teacher_id)
2933
end
3034

31-
private
35+
def with_teachers
36+
[self, User.from_userinfo(ids: teacher_ids)]
37+
end
3238

33-
def teacher_has_the_school_teacher_role_for_the_school
34-
return unless teacher_id_changed? && errors.blank?
39+
private
3540

36-
user = User.new(id: teacher_id)
37-
return if user.school_teacher?(school)
41+
def school_class_has_at_least_one_teacher
42+
return if teachers.present?
3843

39-
msg = "'#{teacher_id}' does not have the 'school-teacher' role for organisation '#{school.id}'"
40-
errors.add(:user, msg)
44+
errors.add(:teachers, 'must have at least one teacher')
4145
end
4246
end
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
# frozen_string_literal: true
22

3-
json.array!(@school_classes_with_teachers) do |school_class, teacher|
3+
json.array!(@school_classes_with_teachers) do |school_class, teachers|
44
json.call(
55
school_class,
66
:id,
77
:description,
88
:school_id,
9-
:teacher_id,
109
:name,
1110
:created_at,
1211
:updated_at
1312
)
1413

15-
json.teacher_name(teacher&.name)
14+
json.teachers(teachers) do |teacher|
15+
json.partial! '/api/school_teachers/school_teacher', teacher:, include_email: false if teacher.present?
16+
end
1617
end
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
# frozen_string_literal: true
22

3-
school_class, teacher = @school_class_with_teacher
3+
school_class, teachers = @school_class_with_teachers
44

55
json.call(
66
school_class,
77
:id,
88
:description,
99
:school_id,
10-
:teacher_id,
1110
:name,
1211
:created_at,
1312
:updated_at
1413
)
1514

16-
json.teacher_name(teacher&.name)
15+
json.teachers(teachers) do |teacher|
16+
json.partial! '/api/school_teachers/school_teacher', teacher:, include_email: false
17+
end

app/views/api/school_teachers/_school_teacher.json.jbuilder

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
json.call(
44
teacher,
55
:id,
6-
:email,
76
:name
87
)
8+
9+
include_email = local_assigns.fetch(:include_email, true)
10+
11+
json.email(teacher.email) if include_email
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class CreateClassTeachers < ActiveRecord::Migration[7.1]
2+
def change
3+
create_table :class_teachers, id: :uuid do |t|
4+
t.references :school_class, type: :uuid, foreign_key: true, index: true, null: false
5+
t.uuid :teacher_id, null: false
6+
t.timestamps
7+
end
8+
9+
add_index :class_teachers, :teacher_id
10+
add_index :class_teachers, %i[school_class_id teacher_id], unique: true
11+
end
12+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class MoveTeacherDataToClassTeachersTable < ActiveRecord::Migration[7.1]
2+
def up
3+
SchoolClass.find_each do |school_class|
4+
ClassTeacher.create!(school_class: school_class, teacher_id: school_class.teacher_id)
5+
end
6+
end
7+
8+
def down
9+
ClassTeacher.destroy_all
10+
end
11+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class RemoveClassTeacherId < ActiveRecord::Migration[7.1]
2+
def up
3+
remove_column :school_classes, :teacher_id
4+
end
5+
6+
def down
7+
add_column :school_classes, :teacher_id, :uuid
8+
ClassTeacher.find_each do |class_teacher|
9+
school_class = class_teacher.school_class
10+
school_class.update!(teacher_id: class_teacher.teacher_id)
11+
end
12+
change_column_null :school_classes, :teacher_id, false
13+
end
14+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class RenameClassMembersToClassStudents < ActiveRecord::Migration[7.1]
2+
def up
3+
rename_table :class_members, :class_students
4+
end
5+
6+
def down
7+
rename_table :class_students, :class_members
8+
end
9+
end

0 commit comments

Comments
 (0)