Skip to content

Commit 2a6ab27

Browse files
Add functionality to return student remixes with unread feedback count (#636)
## Status - [API work for this ticket](RaspberryPiFoundation/digital-editor-issues#953) ## What's changed? - Return a count of unread feedback on a students remixes
1 parent 1064458 commit 2a6ab27

File tree

3 files changed

+227
-78
lines changed

3 files changed

+227
-78
lines changed

app/controllers/api/school_classes_controller.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def index
1414
if current_user&.school_teacher?(@school) || current_user&.school_owner?(@school)
1515
render :teacher_index, formats: [:json], status: :ok
1616
else
17-
render :student_index, formats: [:json], status: :ok
17+
render_student_index(school_classes)
1818
end
1919
end
2020

@@ -83,6 +83,29 @@ def destroy
8383

8484
private
8585

86+
def render_student_index(school_classes)
87+
unread_counts = calculate_unread_counts(school_classes)
88+
@school_classes_with_teachers_and_unread_counts = @school_classes_with_teachers.zip(unread_counts)
89+
render :student_index, formats: [:json], status: :ok
90+
end
91+
92+
def calculate_unread_counts(school_classes)
93+
class_ids = school_classes.map(&:id)
94+
95+
counts_by_class_id =
96+
SchoolProject
97+
.joins(:feedback)
98+
.joins(project: { parent: { lesson: :school_class } })
99+
.where(projects: { user_id: current_user.id })
100+
.where(feedback: { read_at: nil })
101+
.where(school_classes: { id: class_ids })
102+
.merge(Lesson.accessible_by(current_ability))
103+
.group('school_classes.id')
104+
.count('DISTINCT school_projects.id') # Count distinct projects, not feedback records
105+
106+
school_classes.map { |school_class| counts_by_class_id[school_class.id] || 0 }
107+
end
108+
86109
def find_or_create_school_class(school_class_params)
87110
# First try and find the class (in case we're re-importing)
88111
existing_school_class = SchoolClass.find_by(
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3-
json.array!(@school_classes_with_teachers) do |school_class, teachers|
3+
json.array!(@school_classes_with_teachers_and_unread_counts) do |(school_class, teachers), unread_count|
44
json.partial! 'school_class', school_class: school_class, teachers: teachers
5+
json.unread_feedback_count unread_count
56
end

spec/features/school_class/listing_school_classes_spec.rb

Lines changed: 201 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,110 +3,235 @@
33
require 'rails_helper'
44

55
RSpec.describe 'Listing school classes', type: :request do
6-
before do
7-
authenticated_in_hydra_as(owner)
8-
stub_user_info_api_for_users([teacher, owner_teacher].map(&:id), users: [owner_teacher, teacher])
9-
10-
create(:class_student, school_class:, student_id: student.id)
11-
end
12-
136
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
14-
let!(:school_class) { create(:school_class, name: 'Test School Class', teacher_ids: [teacher.id], school:) }
157
let(:school) { create(:school) }
168
let(:student) { create(:student, school:) }
179
let(:teacher) { create(:teacher, school:, name: 'School Teacher') }
1810
let(:owner) { create(:owner, school:) }
19-
2011
let(:owner_teacher) { create(:teacher, school:, id: owner.id, name: owner.name, email: owner.email) }
21-
let!(:owner_school_class) { create(:school_class, name: 'Owner School Class', teacher_ids: [owner_teacher.id], school:) }
2212

23-
it 'responds 200 OK' do
24-
get("/api/schools/#{school.id}/classes", headers:)
25-
expect(response).to have_http_status(:ok)
26-
end
27-
28-
it 'responds with the school classes JSON' do
29-
get("/api/schools/#{school.id}/classes", headers:)
30-
data = JSON.parse(response.body, symbolize_names: true)
13+
let!(:school_class) { create(:school_class, name: 'Test School Class', teacher_ids: [teacher.id], school:) }
14+
let!(:owner_school_class) { create(:school_class, name: 'Owner School Class', teacher_ids: [owner_teacher.id], school:) }
3115

32-
expect(data.first[:name]).to eq('Test School Class')
16+
before do
17+
authenticated_in_hydra_as(owner)
18+
stub_user_info_api_for_users([teacher, owner_teacher].map(&:id), users: [owner_teacher, teacher])
19+
create(:class_student, school_class:, student_id: student.id)
3320
end
3421

35-
it 'only responds with the user\'s classes if the my_classes param is present' do
36-
get("/api/schools/#{school.id}/classes?my_classes=true", headers:)
37-
data = JSON.parse(response.body, symbolize_names: true)
38-
39-
expect(data.first[:name]).to eq(owner_school_class.name)
22+
# Helper to make API call and parse response
23+
def get_classes(path = "/api/schools/#{school.id}/classes")
24+
get(path, headers:)
25+
JSON.parse(response.body, symbolize_names: true)
4026
end
4127

42-
it 'responds with the teachers JSON' do
43-
get("/api/schools/#{school.id}/classes", headers:)
44-
data = JSON.parse(response.body, symbolize_names: true)
45-
expect(data.first[:teachers].first[:name]).to eq('School Teacher')
28+
def find_school_class_by_name(classes_data, name)
29+
classes_data.find { |school_class| school_class[:name] == name }
4630
end
4731

48-
it "skips teachers if the user profile doesn't exist" do
49-
stub_user_info_api_for_unknown_users(user_id: teacher.id)
32+
# Helper to create a lesson with a student remix and optional feedback
33+
def create_remix_with_feedback(school_class:, student:, feedback_attrs: [])
34+
lesson = create(:lesson, school:, school_class:, visibility: 'students', user_id: teacher.id)
35+
remix = create(:project, school:, remixed_from_id: lesson.project.id, user_id: student.id)
5036

51-
get("/api/schools/#{school.id}/classes", headers:)
52-
data = JSON.parse(response.body, symbolize_names: true)
53-
expect(data.first[:teachers].first).to be_nil
54-
end
37+
feedback_attrs.each do |attrs|
38+
create(:feedback, school_project: remix.school_project, user_id: teacher.id, **attrs)
39+
end
5540

56-
it 'includes submitted_count if user is a school-teacher' do
57-
authenticated_in_hydra_as(teacher)
58-
get("/api/schools/#{school.id}/classes", headers:)
59-
data = JSON.parse(response.body, symbolize_names: true)
60-
expect(data.first).to have_key(:submitted_count)
41+
{ lesson:, remix: }
6142
end
6243

63-
it 'includes submitted_count if user is a school-owner' do
64-
authenticated_in_hydra_as(owner)
65-
get("/api/schools/#{school.id}/classes", headers:)
66-
data = JSON.parse(response.body, symbolize_names: true)
67-
expect(data.first).to have_key(:submitted_count)
44+
describe 'basic responses' do
45+
it 'responds 200 OK' do
46+
get_classes
47+
expect(response).to have_http_status(:ok)
48+
end
49+
50+
it 'responds with the school classes JSON' do
51+
data = get_classes
52+
expect(data.first[:name]).to eq('Test School Class')
53+
end
54+
55+
it 'responds with the teachers JSON' do
56+
data = get_classes
57+
expect(data.first[:teachers].first[:name]).to eq('School Teacher')
58+
end
59+
60+
it "skips teachers if the user profile doesn't exist" do
61+
stub_user_info_api_for_unknown_users(user_id: teacher.id)
62+
data = get_classes
63+
expect(data.first[:teachers].first).to be_nil
64+
end
65+
66+
it 'responds 401 Unauthorized when no token is given' do
67+
get "/api/schools/#{school.id}/classes"
68+
expect(response).to have_http_status(:unauthorized)
69+
end
70+
71+
it 'responds 403 Forbidden when the user is a school-owner for a different school' do
72+
other_school = create(:school, id: SecureRandom.uuid)
73+
school_class.update!(school_id: other_school.id)
74+
75+
get("/api/schools/#{other_school.id}/classes/#{school_class.id}/members", headers:)
76+
expect(response).to have_http_status(:forbidden)
77+
end
6878
end
6979

70-
it 'does not include submitted_count if user is a school-student' do
71-
authenticated_in_hydra_as(student)
72-
stub_user_info_api_for(teacher)
73-
get("/api/schools/#{school.id}/classes", headers:)
74-
data = JSON.parse(response.body, symbolize_names: true)
75-
expect(data.first).not_to have_key(:submitted_count)
80+
describe 'my_classes filter' do
81+
it "only responds with the user's classes if the my_classes param is present" do
82+
data = get_classes("/api/schools/#{school.id}/classes?my_classes=true")
83+
expect(data.first[:name]).to eq(owner_school_class.name)
84+
end
7685
end
7786

78-
it "does not include school classes that the school-teacher doesn't teach" do
79-
teacher = create(:teacher, school:)
80-
authenticated_in_hydra_as(teacher)
81-
create(:school_class, school:, teacher_ids: [teacher.id])
82-
83-
get("/api/schools/#{school.id}/classes", headers:)
84-
data = JSON.parse(response.body, symbolize_names: true)
87+
describe 'class visibility' do
88+
it "does not include school classes that the school-teacher doesn't teach" do
89+
other_teacher = create(:teacher, school:)
90+
authenticated_in_hydra_as(other_teacher)
91+
create(:school_class, school:, teacher_ids: [other_teacher.id])
8592

86-
expect(data.size).to eq(1)
87-
end
88-
89-
it "does not include school classes that the school-student isn't a member of" do
90-
authenticated_in_hydra_as(student)
91-
stub_user_info_api_for(teacher)
92-
create(:school_class, school:, teacher_ids: [teacher.id])
93+
data = get_classes
94+
expect(data.size).to eq(1)
95+
end
9396

94-
get("/api/schools/#{school.id}/classes", headers:)
95-
data = JSON.parse(response.body, symbolize_names: true)
97+
it "does not include school classes that the school-student isn't a member of" do
98+
authenticated_in_hydra_as(student)
99+
stub_user_info_api_for(teacher)
100+
create(:school_class, school:, teacher_ids: [teacher.id])
96101

97-
expect(data.size).to eq(1)
102+
data = get_classes
103+
expect(data.size).to eq(1)
104+
end
98105
end
99106

100-
it 'responds 401 Unauthorized when no token is given' do
101-
get "/api/schools/#{school.id}/classes"
102-
expect(response).to have_http_status(:unauthorized)
107+
describe 'submitted_count' do
108+
it 'includes submitted_count if user is a school-teacher' do
109+
authenticated_in_hydra_as(teacher)
110+
data = get_classes
111+
expect(data.first).to have_key(:submitted_count)
112+
end
113+
114+
it 'includes submitted_count if user is a school-owner' do
115+
data = get_classes
116+
expect(data.first).to have_key(:submitted_count)
117+
end
118+
119+
it 'does not include submitted_count if user is a school-student' do
120+
authenticated_in_hydra_as(student)
121+
stub_user_info_api_for(teacher)
122+
data = get_classes
123+
expect(data.first).not_to have_key(:submitted_count)
124+
end
103125
end
104126

105-
it 'responds 403 Forbidden when the user is a school-owner for a different school' do
106-
school = create(:school, id: SecureRandom.uuid)
107-
school_class.update!(school_id: school.id)
108-
109-
get("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:)
110-
expect(response).to have_http_status(:forbidden)
127+
describe 'unread_feedback_count' do
128+
context 'when user is a school-student' do
129+
before do
130+
authenticated_in_hydra_as(student)
131+
stub_user_info_api_for(teacher)
132+
end
133+
134+
it 'includes unread_feedback_count' do
135+
create_remix_with_feedback(
136+
school_class:,
137+
student:,
138+
feedback_attrs: [
139+
{ content: 'Not read', read_at: nil },
140+
{ content: 'Already read', read_at: Time.current }
141+
]
142+
)
143+
144+
data = get_classes
145+
this_class = find_school_class_by_name(data, 'Test School Class')
146+
expect(this_class[:unread_feedback_count]).to eq(1)
147+
end
148+
149+
it 'returns correct counts across multiple classes' do
150+
school_class_2 = create(:school_class, name: 'Second Class', teacher_ids: [teacher.id], school:)
151+
create(:class_student, school_class: school_class_2, student_id: student.id)
152+
153+
# Class 1: 2 remixes with unread feedback
154+
create_remix_with_feedback(school_class:, student:, feedback_attrs: [{ content: 'Unread', read_at: nil }])
155+
create_remix_with_feedback(school_class:, student:, feedback_attrs: [{ content: 'Unread', read_at: nil }])
156+
157+
# Class 2: 1 remix with unread feedback
158+
create_remix_with_feedback(school_class: school_class_2, student:, feedback_attrs: [{ content: 'Unread', read_at: nil }])
159+
160+
data = get_classes
161+
expect(find_school_class_by_name(data, 'Test School Class')[:unread_feedback_count]).to eq(2)
162+
expect(find_school_class_by_name(data, 'Second Class')[:unread_feedback_count]).to eq(1)
163+
end
164+
165+
it 'returns 0 when all feedback is read' do
166+
create_remix_with_feedback(school_class:, student:, feedback_attrs: [{ content: 'Read', read_at: Time.current }])
167+
168+
data = get_classes
169+
this_class = find_school_class_by_name(data, 'Test School Class')
170+
expect(this_class[:unread_feedback_count]).to eq(0)
171+
end
172+
173+
it 'returns 0 when class has no feedback' do
174+
data = get_classes
175+
this_class = find_school_class_by_name(data, 'Test School Class')
176+
expect(this_class[:unread_feedback_count]).to eq(0)
177+
end
178+
179+
it 'counts projects not individual feedback items' do
180+
create_remix_with_feedback(
181+
school_class:,
182+
student:,
183+
feedback_attrs: [
184+
{ content: 'Unread 1', read_at: nil },
185+
{ content: 'Unread 2', read_at: nil },
186+
{ content: 'Unread 3', read_at: nil }
187+
]
188+
)
189+
190+
data = get_classes
191+
this_class = find_school_class_by_name(data, 'Test School Class')
192+
expect(this_class[:unread_feedback_count]).to eq(1)
193+
end
194+
195+
it "only counts the current student's remixes" do
196+
other_student = create(:student, school:)
197+
create(:class_student, school_class:, student_id: other_student.id)
198+
199+
create_remix_with_feedback(school_class:, student:, feedback_attrs: [{ content: 'Mine', read_at: nil }])
200+
create_remix_with_feedback(school_class:, student: other_student, feedback_attrs: [{ content: 'Theirs', read_at: nil }])
201+
202+
data = get_classes
203+
this_class = find_school_class_by_name(data, 'Test School Class')
204+
expect(this_class[:unread_feedback_count]).to eq(1)
205+
end
206+
207+
it 'does not count feedback on inaccessible lessons' do
208+
# Visible lesson
209+
create_remix_with_feedback(school_class:, student:, feedback_attrs: [{ content: 'Visible', read_at: nil }])
210+
211+
# Hidden lesson
212+
hidden_lesson = create(:lesson, school:, school_class:, visibility: 'teachers', user_id: teacher.id)
213+
hidden_remix = create(:project, school:, remixed_from_id: hidden_lesson.project.id, user_id: student.id)
214+
create(:feedback, school_project: hidden_remix.school_project, user_id: teacher.id, content: 'Hidden', read_at: nil)
215+
216+
data = get_classes
217+
this_class = find_school_class_by_name(data, 'Test School Class')
218+
expect(this_class[:unread_feedback_count]).to eq(1)
219+
end
220+
end
221+
222+
context 'when user is a school-teacher' do
223+
it 'does not include unread_feedback_count' do
224+
authenticated_in_hydra_as(teacher)
225+
data = get_classes
226+
expect(data.first).not_to have_key(:unread_feedback_count)
227+
end
228+
end
229+
230+
context 'when user is a school-owner' do
231+
it 'does not include unread_feedback_count' do
232+
data = get_classes
233+
expect(data.first).not_to have_key(:unread_feedback_count)
234+
end
235+
end
111236
end
112237
end

0 commit comments

Comments
 (0)