From 97f9dc15adf6ce623b4ec7368347daf759baed58 Mon Sep 17 00:00:00 2001 From: abcampo-iry <261805581+abcampo-iry@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:21:10 +0200 Subject: [PATCH 1/3] Add classroom event tracking helper --- app/controllers/api_controller.rb | 12 ++++- app/services/event_tracker.rb | 79 +++++++++++++++++++++++++++++ spec/services/event_tracker_spec.rb | 61 ++++++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 app/services/event_tracker.rb create mode 100644 spec/services/event_tracker_spec.rb diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index fcf0a82f2..d805448b1 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -49,6 +49,16 @@ def render_error_as_json(exception, status) end def track_event(name, properties = {}) - Event.create!(user_id: current_user.id, name:, properties:, time: Time.current) + EventTracker.track!(user_id: current_user.id, name:, properties:) + end + + def track_project_event(name, project, user_role: nil, student_id: nil) + EventTracker.track_project_event!( + name:, + user_id: current_user.id, + project:, + user_role:, + student_id: + ) end end diff --git a/app/services/event_tracker.rb b/app/services/event_tracker.rb new file mode 100644 index 000000000..9bc936452 --- /dev/null +++ b/app/services/event_tracker.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class EventTracker + class << self + def track!(name:, user_id:, properties: {}) + Event.create!( + name:, + user_id:, + properties: properties.compact, + time: Time.current + ) + end + + def track_project_event!(name:, user_id:, project:, user_role: nil, student_id: nil) + properties = project_event_properties(project) + return if properties.blank? + + role = user_role || user_role_for(user_id:, school_id: properties[:school_id]) + properties[:user_role] = role if role.present? + properties[:student_id] = student_id || student_id_for(project:, user_role: role) + + track!(name:, user_id:, properties:) + end + + def project_event_properties(project) + return if project.blank? + + lesson = lesson_for(project) + properties = { + school_id: school_id_for(project, lesson), + class_id: lesson&.school_class_id, + lesson_id: lesson&.id, + project_type: project_type_for(project) + }.compact + + required_project_properties?(properties) ? properties : nil + end + + private + + def required_project_properties?(properties) + %i[school_id class_id lesson_id project_type].all? { |key| properties[key].present? } + end + + def user_role_for(user_id:, school_id:) + return if user_id.blank? || school_id.blank? + + Role.student.exists?(user_id:, school_id:) ? 'student' : 'educator' + end + + def student_id_for(project:, user_role:) + return unless educator_project_interaction?(project:, user_role:) + + student_id = project.user_id + return if student_id.blank? + + lesson = lesson_for(project) + student_id if ClassStudent.exists?(school_class_id: lesson.school_class_id, student_id:) + end + + def lesson_for(project) + project.lesson || project.parent&.lesson + end + + def school_id_for(project, lesson) + project.school_id || project.parent&.school_id || lesson&.school_id + end + + def project_type_for(project) + project.project_type || project.parent&.project_type + end + + def educator_project_interaction?(project:, user_role:) + return false unless user_role == 'educator' + + lesson_for(project)&.school_class_id.present? + end + end +end diff --git a/spec/services/event_tracker_spec.rb b/spec/services/event_tracker_spec.rb new file mode 100644 index 000000000..8d518d68c --- /dev/null +++ b/spec/services/event_tracker_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe EventTracker do + describe '.track_project_event!' do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:student) { create(:student, school:) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id) } + let(:teacher_project) { create(:project, school:, lesson:, user_id: teacher.id, locale: nil) } + + it 'tracks required classroom project metadata' do + described_class.track_project_event!(name: 'Project - Opened', user_id: teacher.id, project: teacher_project) + + expect(Event.last).to have_attributes( + name: 'Project - Opened', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator' + }, + time: be_within(1.second).of(Time.current) + ) + end + + it 'adds a student id for educator interactions with student projects' do + create(:class_student, school_class:, student_id: student.id) + student_project = create(:project, school:, parent: teacher_project, user_id: student.id, locale: nil) + + described_class.track_project_event!(name: 'Project - Feedback given', user_id: teacher.id, project: student_project) + + expect(Event.last.properties).to include( + 'student_id' => student.id, + 'user_role' => 'educator' + ) + end + + it 'does not add a student id for student interactions with their own project' do + create(:class_student, school_class:, student_id: student.id) + student_project = create(:project, school:, parent: teacher_project, user_id: student.id, locale: nil) + + described_class.track_project_event!(name: 'Project - Submitted for review', user_id: student.id, project: student_project) + + expect(Event.last.properties).to include('user_role' => 'student') + expect(Event.last.properties).not_to have_key('student_id') + end + + it 'does not track project events without full classroom metadata' do + project = create(:project, user_id: teacher.id, locale: nil) + + expect do + described_class.track_project_event!(name: 'Project - Saved', user_id: teacher.id, project:) + end.not_to change(Event, :count) + end + end +end From f19575cbee4dba78b35f26b39289c49d95a37357 Mon Sep 17 00:00:00 2001 From: abcampo-iry <261805581+abcampo-iry@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:22:20 +0200 Subject: [PATCH 2/3] Track project workflow analytics events --- app/controllers/api/feedback_controller.rb | 1 + .../api/lessons/batch_controller.rb | 1 + app/controllers/api/lessons_controller.rb | 3 ++ .../api/projects/remixes_controller.rb | 1 + app/controllers/api/projects_controller.rb | 4 ++ .../api/school_projects_controller.rb | 4 ++ .../api/scratch/projects_controller.rb | 3 ++ .../feedback/creating_feedback_spec.rb | 18 +++++++ .../creating_a_batch_of_lessons_spec.rb | 18 +++++++ .../features/lesson/creating_a_lesson_spec.rb | 19 +++++++ .../features/lesson/updating_a_lesson_spec.rb | 22 ++++++++ .../project/creating_a_project_spec.rb | 20 ++++++- spec/features/school_project/complete_spec.rb | 17 ++++++ spec/features/school_project/submit_spec.rb | 21 ++++++++ .../creating_a_scratch_project_spec.rb | 22 +++++++- .../updating_a_scratch_project_spec.rb | 12 +++++ spec/requests/projects/remix_spec.rb | 30 +++++++++++ spec/requests/projects/show_spec.rb | 25 ++++++++- spec/requests/projects/update_spec.rb | 52 ++++++++++++++++++- 19 files changed, 289 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/feedback_controller.rb b/app/controllers/api/feedback_controller.rb index 1de9ff130..f0f14ac77 100644 --- a/app/controllers/api/feedback_controller.rb +++ b/app/controllers/api/feedback_controller.rb @@ -24,6 +24,7 @@ def create if result.success? @feedback = result[:feedback] + track_project_event('Project - Feedback given', project) render :show, formats: [:json], status: :created else render json: { error: result[:error] }, status: :unprocessable_content diff --git a/app/controllers/api/lessons/batch_controller.rb b/app/controllers/api/lessons/batch_controller.rb index 43732eb0a..9e2a0945a 100644 --- a/app/controllers/api/lessons/batch_controller.rb +++ b/app/controllers/api/lessons/batch_controller.rb @@ -18,6 +18,7 @@ def create_batch lessons_params: batch_lessons_params ) @user = current_user + @results.select(&:success?).each { |result| track_project_event('Project - Created', result[:lesson].project) } render :create_batch, formats: [:json], status: :created end diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 675548196..a1071b654 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -33,6 +33,7 @@ def show def create result = Lesson::Create.call(lesson_params: create_params) if result.success? + track_project_event('Project - Created', result[:lesson].project) @lesson_with_user = result[:lesson].with_user render :show, formats: [:json], status: :created else @@ -54,9 +55,11 @@ def create_copy def update # TODO: Consider removing user_id from the lesson_params for update so users can update other users' lessons without changing ownership # OR consider dropping user_id on lessons and using teacher id/ids on the class instead + previous_visibility = @lesson.visibility result = Lesson::Update.call(lesson: @lesson, lesson_params: update_params) if result.success? + track_project_event('Project - Made visible', result[:lesson].project) if previous_visibility != 'students' && result[:lesson].visibility == 'students' @lesson_with_user = result[:lesson].with_user render :show, formats: [:json], status: :ok else diff --git a/app/controllers/api/projects/remixes_controller.rb b/app/controllers/api/projects/remixes_controller.rb index 1f0a17cd7..8f8259c6f 100644 --- a/app/controllers/api/projects/remixes_controller.rb +++ b/app/controllers/api/projects/remixes_controller.rb @@ -36,6 +36,7 @@ def create if result.success? @project = result[:project] + track_project_event('Project - Saved', @project) render '/api/projects/show', formats: [:json] else render json: { error: result[:error] }, status: :bad_request diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb index 6837c9403..9fae5d21f 100644 --- a/app/controllers/api/projects_controller.rb +++ b/app/controllers/api/projects_controller.rb @@ -17,6 +17,8 @@ def index end def show + track_project_event('Project - Opened', @project) if current_user.present? + if !@project.school_id.nil? && @project.lesson_id.nil? project_with_user = @project.with_student(current_user) @user = project_with_user[1] @@ -31,6 +33,7 @@ def create if result.success? @project = result[:project] + track_project_event('Project - Created', @project) render :show, formats: [:json], status: :created else render json: { error: result[:error] }, status: :unprocessable_content @@ -41,6 +44,7 @@ def update result = Project::Update.call(project: @project, update_hash: project_params, current_user:) if result.success? + track_project_event('Project - Saved', @project) render :show, formats: [:json] else render json: { error: result[:error] }, status: :unprocessable_content diff --git a/app/controllers/api/school_projects_controller.rb b/app/controllers/api/school_projects_controller.rb index cf62abb3d..603480406 100644 --- a/app/controllers/api/school_projects_controller.rb +++ b/app/controllers/api/school_projects_controller.rb @@ -23,9 +23,11 @@ def unsubmit def submit authorize! :submit, school_project + previous_status = school_project.status result = SchoolProject::SetStatus.call(school_project:, status: :submitted, user_id: current_user.id) if result.success? @school_project = result[:school_project] + track_project_event('Project - Submitted for review', @school_project.project) if previous_status != 'submitted' render :show_status, formats: [:json], status: :ok else render json: { error: result[:error] }, status: :unprocessable_content @@ -45,9 +47,11 @@ def return def complete authorize! :complete, school_project + previous_status = school_project.status result = SchoolProject::SetStatus.call(school_project:, status: :complete, user_id: current_user.id) if result.success? @school_project = result[:school_project] + track_project_event('Project - Marked as completed', @school_project.project) if previous_status != 'complete' render :show_status, formats: [:json], status: :ok else render json: { error: result[:error] }, status: :unprocessable_content diff --git a/app/controllers/api/scratch/projects_controller.rb b/app/controllers/api/scratch/projects_controller.rb index b8c61cb86..3383d90d5 100644 --- a/app/controllers/api/scratch/projects_controller.rb +++ b/app/controllers/api/scratch/projects_controller.rb @@ -27,6 +27,7 @@ def create scratch_component.content = scratch_content_params existing_remix.save! move_assets_uploaded_by_current_user_before_remix(original_project:, remix_project: existing_remix) + track_project_event('Project - Saved', existing_remix) return render json: { status: 'ok', 'content-name': existing_remix.identifier }, status: :ok end @@ -42,6 +43,7 @@ def create if result.success? move_assets_uploaded_by_current_user_before_remix(original_project:, remix_project: result[:project]) + track_project_event('Project - Saved', result[:project]) render json: { status: 'ok', 'content-name': result[:project].identifier }, status: :ok else render json: { error: result[:error] }, status: :bad_request @@ -51,6 +53,7 @@ def create def update @project.scratch_component&.content = scratch_content_params @project.save! + track_project_event('Project - Saved', @project) render json: { status: 'ok' }, status: :ok end diff --git a/spec/features/feedback/creating_feedback_spec.rb b/spec/features/feedback/creating_feedback_spec.rb index a6c1369e5..e4d501e7d 100644 --- a/spec/features/feedback/creating_feedback_spec.rb +++ b/spec/features/feedback/creating_feedback_spec.rb @@ -46,6 +46,24 @@ it 'adds the feedback to the school project' do expect(student_project.school_project.feedback.count).to eq(1) end + + it 'records a project feedback given event' do + event = Event.last + + expect(event).to have_attributes( + name: 'Project - Feedback given', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator', + 'student_id' => student.id + }, + time: be_within(1.second).of(Time.current) + ) + end end context 'when leaving feedback on a project that is not student work' do diff --git a/spec/features/lesson/creating_a_batch_of_lessons_spec.rb b/spec/features/lesson/creating_a_batch_of_lessons_spec.rb index 7a7e84f86..bead3a881 100644 --- a/spec/features/lesson/creating_a_batch_of_lessons_spec.rb +++ b/spec/features/lesson/creating_a_batch_of_lessons_spec.rb @@ -127,6 +127,24 @@ expect(response).to have_http_status(:created) end + it 'records project created events for each created lesson project' do + events = Event.where(name: 'Project - Created') + + expect(events.count).to eq(2) + expect(events.map(&:user_id)).to all(eq(teacher.id)) + expect(events.map(&:properties)).to match_array( + Lesson.order(:created_at).map do |lesson| + { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::CODE_EDITOR_SCRATCH, + 'user_role' => 'educator' + } + end + ) + end + context 'when school_class_id does not correspond to school_id' do let(:lesson_projects) { lesson_project_params.map { |entry| entry.merge(school_id: SecureRandom.uuid) } } diff --git a/spec/features/lesson/creating_a_lesson_spec.rb b/spec/features/lesson/creating_a_lesson_spec.rb index 3679c672c..93827bd78 100644 --- a/spec/features/lesson/creating_a_lesson_spec.rb +++ b/spec/features/lesson/creating_a_lesson_spec.rb @@ -148,6 +148,25 @@ expect(response).to have_http_status(:created) end + it 'records a project created event' do + authenticated_in_hydra_as(teacher) + + post('/api/lessons', headers:, params:) + + expect(Event.last).to have_attributes( + name: 'Project - Created', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => Lesson.last.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator' + }, + time: be_within(1.second).of(Time.current) + ) + end + it 'responds 422 Unprocessable if school_id is missing' do new_params = { lesson: params[:lesson].without(:school_id) } diff --git a/spec/features/lesson/updating_a_lesson_spec.rb b/spec/features/lesson/updating_a_lesson_spec.rb index 37b4643c9..17f8e7e55 100644 --- a/spec/features/lesson/updating_a_lesson_spec.rb +++ b/spec/features/lesson/updating_a_lesson_spec.rb @@ -129,5 +129,27 @@ lesson.reload expect(lesson.school_class_id).not_to eq(school_class.id) end + + context "when changing visibility to 'students'" do + let!(:lesson) { create(:lesson, school_class:, name: 'Test Lesson', visibility: 'teachers', user_id: teacher.id) } + let(:params) { { lesson: { visibility: 'students' } } } + + it 'records a project made visible event' do + put("/api/lessons/#{lesson.id}", headers:, params:) + + expect(Event.last).to have_attributes( + name: 'Project - Made visible', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator' + }, + time: be_within(1.second).of(Time.current) + ) + end + end end end diff --git a/spec/features/project/creating_a_project_spec.rb b/spec/features/project/creating_a_project_spec.rb index 5332f9d0e..3d4f912e4 100644 --- a/spec/features/project/creating_a_project_spec.rb +++ b/spec/features/project/creating_a_project_spec.rb @@ -125,7 +125,8 @@ context 'when the project is associated with a lesson' do let(:school) { create(:school) } - let(:lesson) { create(:lesson, school:, user_id: teacher.id) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id) } let(:lesson_created_by_owner) { create(:lesson, school:, user_id: owner.id) } let(:teacher) { create(:teacher, school:) } @@ -146,6 +147,23 @@ expect(response).to have_http_status(:created) end + it 'records a project created event' do + post('/api/projects', headers:, params:) + + expect(Event.last).to have_attributes( + name: 'Project - Created', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator' + }, + time: be_within(1.second).of(Time.current) + ) + end + it 'responds 201 Created when the current user is the owner of the lesson' do authenticated_in_hydra_as(teacher) lesson.update!(user_id: teacher.id) diff --git a/spec/features/school_project/complete_spec.rb b/spec/features/school_project/complete_spec.rb index 242e1a8e5..ea062ead4 100644 --- a/spec/features/school_project/complete_spec.rb +++ b/spec/features/school_project/complete_spec.rb @@ -44,6 +44,7 @@ context('when transition is valid') do before do + class_student student_project.school_project.transition_status_to!(:submitted, student.id) post("/api/projects/#{student_project.identifier}/complete", headers:) end @@ -59,6 +60,22 @@ it 'sets the status to complete' do expect(student_project.school_project).to be_complete end + + it 'records a project marked as completed event' do + expect(Event.last).to have_attributes( + name: 'Project - Marked as completed', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator', + 'student_id' => student.id + }, + time: be_within(1.second).of(Time.current) + ) + end end end diff --git a/spec/features/school_project/submit_spec.rb b/spec/features/school_project/submit_spec.rb index 56a156a6a..75fda8f41 100644 --- a/spec/features/school_project/submit_spec.rb +++ b/spec/features/school_project/submit_spec.rb @@ -29,6 +29,7 @@ context('when transition is valid') do before do + class_student post("/api/projects/#{student_project.identifier}/submit", headers:) end @@ -43,10 +44,26 @@ it 'sets the status to submitted' do expect(student_project.school_project).to be_submitted end + + it 'records a project submitted for review event' do + expect(Event.last).to have_attributes( + name: 'Project - Submitted for review', + user_id: student.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'student' + }, + time: be_within(1.second).of(Time.current) + ) + end end context 'when attempting an invalid status transition' do before do + class_student student_project.school_project.transition_status_to!(:complete, student.id) post("/api/projects/#{student_project.identifier}/submit", headers:) end @@ -62,6 +79,10 @@ it 'does not change the status' do expect(student_project.school_project).to be_complete end + + it 'does not record an event' do + expect(Event.where(name: 'Project - Submitted for review')).to be_empty + end end end diff --git a/spec/features/scratch/creating_a_scratch_project_spec.rb b/spec/features/scratch/creating_a_scratch_project_spec.rb index 2f01eb160..3c5f77c87 100644 --- a/spec/features/scratch/creating_a_scratch_project_spec.rb +++ b/spec/features/scratch/creating_a_scratch_project_spec.rb @@ -22,11 +22,12 @@ } end let(:lesson) { create(:lesson, school:, user_id: teacher.id) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } let(:original_project) do create( :project, school:, - lesson:, + lesson: lesson, user_id: teacher.id, project_type: Project::Types::CODE_EDITOR_SCRATCH, locale: nil @@ -124,6 +125,25 @@ def make_request(query: request_query, request_headers: headers, request_params: expect(remixed_project.scratch_component.content.to_h).to eq(scratch_project.deep_stringify_keys) end + it 'records a project saved event when creating a remix' do + original_project.update!(lesson: create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students')) + + make_request + + expect(Event.last).to have_attributes( + name: 'Project - Saved', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => original_project.lesson.id, + 'project_type' => Project::Types::CODE_EDITOR_SCRATCH, + 'user_role' => 'educator' + }, + time: be_within(1.second).of(Time.current) + ) + end + it 'moves the current user pending original-project assets onto the new remix' do student = create(:student, school:) school_class = create(:school_class, school:, teacher_ids: [teacher.id]) diff --git a/spec/features/scratch/updating_a_scratch_project_spec.rb b/spec/features/scratch/updating_a_scratch_project_spec.rb index 3df8e2a0c..1d62eb1f6 100644 --- a/spec/features/scratch/updating_a_scratch_project_spec.rb +++ b/spec/features/scratch/updating_a_scratch_project_spec.rb @@ -35,6 +35,18 @@ expect(data[:status]).to eq('ok') expect(project.reload.scratch_component.content.to_h['targets']).to eq(['some update']) + expect(Event.last).to have_attributes( + name: 'Project - Saved', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::CODE_EDITOR_SCRATCH, + 'user_role' => 'educator' + }, + time: be_within(1.second).of(Time.current) + ) end it 'returns 403 Forbidden when trying to update a project user does not have access to' do diff --git a/spec/requests/projects/remix_spec.rb b/spec/requests/projects/remix_spec.rb index 1275a8639..239135b43 100644 --- a/spec/requests/projects/remix_spec.rb +++ b/spec/requests/projects/remix_spec.rb @@ -212,6 +212,36 @@ end end + context 'when a student saves a remix of a visible lesson project' do + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') } + let!(:original_project) { create(:project, school:, lesson:, user_id: teacher.id, locale: nil) } + + before do + create(:class_student, school_class:, student_id: student.id) + authenticated_in_hydra_as(student) + end + + it 'records a project saved event' do + post("/api/projects/#{original_project.identifier}/remix", params: { project: project_params }, headers:) + + expect(Event.last).to have_attributes( + name: 'Project - Saved', + user_id: student.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'student' + }, + time: be_within(1.second).of(Time.current) + ) + end + end + context 'when project cannot be saved' do before do authenticated_in_hydra_as(owner) diff --git a/spec/requests/projects/show_spec.rb b/spec/requests/projects/show_spec.rb index f5aa0241b..457f8cb29 100644 --- a/spec/requests/projects/show_spec.rb +++ b/spec/requests/projects/show_spec.rb @@ -59,7 +59,12 @@ let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') } let(:teacher_project) { create(:project, :with_instructions, school_id: school.id, lesson_id: lesson.id, user_id: teacher.id, locale: nil) } - let(:student_project) { create(:project, school_id: school.id, lesson_id: nil, user_id: create(:student, school:).id, remixed_from_id: teacher_project.id, locale: nil, instructions: teacher_project.instructions) } + let(:student) { create(:student, school:) } + let(:class_student) { create(:class_student, school_class:, student_id: student.id) } + let(:student_project) do + class_student + create(:project, school_id: school.id, lesson_id: nil, user_id: student.id, remixed_from_id: teacher_project.id, locale: nil, instructions: teacher_project.instructions) + end let(:student_project_json) do { identifier: student_project.identifier, @@ -89,6 +94,24 @@ get("/api/projects/#{student_project.identifier}", headers:) expect(response.body).to eq(student_project_json) end + + it 'records a project opened event' do + get("/api/projects/#{student_project.identifier}", headers:) + + expect(Event.last).to have_attributes( + name: 'Project - Opened', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator', + 'student_id' => student.id + }, + time: be_within(1.second).of(Time.current) + ) + end end context 'when loading another teacher\'s project in a class where user is a teacher' do diff --git a/spec/requests/projects/update_spec.rb b/spec/requests/projects/update_spec.rb index 239dbea4e..fd9a6a8a6 100644 --- a/spec/requests/projects/update_spec.rb +++ b/spec/requests/projects/update_spec.rb @@ -126,9 +126,41 @@ end end + context 'when authed user is a teacher updating a class project' do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id) } + let(:project) { create(:project, school:, lesson:, locale: nil, user_id: teacher.id) } + let(:params) { { project: { name: 'updated project name' } } } + + before do + authenticated_in_hydra_as(teacher) + end + + it 'records a project saved event' do + put("/api/projects/#{project.identifier}", params:, headers:) + + expect(Event.last).to have_attributes( + name: 'Project - Saved', + user_id: teacher.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'educator' + }, + time: be_within(1.second).of(Time.current) + ) + end + end + context 'when authed user is a student and the project is remixed from a lesson project' do let(:teacher) { create(:teacher, school:) } - let(:lesson_project) { create(:project, school:, locale: nil, user_id: teacher.id, lesson: create(:lesson, visibility: 'students')) } + let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } + let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') } + let(:lesson_project) { create(:project, school:, locale: nil, user_id: teacher.id, lesson:) } let(:project) { create(:project, school:, locale: nil, user_id: student.id, remixed_from_id: lesson_project.id) } let(:params) { { project: { components: [] } } } let(:school) { create(:school) } @@ -136,6 +168,7 @@ before do authenticated_in_hydra_as(student) + create(:class_student, school_class:, student_id: student.id) end it 'returns success if instructions not updated' do @@ -148,6 +181,23 @@ put("/api/projects/#{project.identifier}", params:, headers:) expect(response).to have_http_status(:unprocessable_content) end + + it 'records a project saved event' do + put("/api/projects/#{project.identifier}", params:, headers:) + + expect(Event.last).to have_attributes( + name: 'Project - Saved', + user_id: student.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'lesson_id' => lesson.id, + 'project_type' => Project::Types::PYTHON, + 'user_role' => 'student' + }, + time: be_within(1.second).of(Time.current) + ) + end end context 'when auth token is invalid' do From 707bb0a544617adc1873ca7bfb72c55ecb8053c9 Mon Sep 17 00:00:00 2001 From: abcampo-iry <261805581+abcampo-iry@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:26:12 +0200 Subject: [PATCH 3/3] Track student and class analytics events --- .../api/class_members_controller.rb | 16 ++++++++++ .../api/school_classes_controller.rb | 30 ++++++++++++++++++- .../api/school_students_controller.rb | 6 +++- app/jobs/create_students_job.rb | 25 ++++++++++++++-- lib/concepts/school_student/create_batch.rb | 8 ++--- .../creating_a_batch_of_class_members_spec.rb | 17 +++++++++++ .../creating_a_class_member_spec.rb | 21 +++++++++++++ .../importing_a_school_class_spec.rb | 14 +++++++++ .../creating_a_school_student_spec.rb | 17 +++++++++++ spec/jobs/create_students_job_spec.rb | 15 ++++++++++ 10 files changed, 160 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/class_members_controller.rb b/app/controllers/api/class_members_controller.rb index a00d104de..df0a9311b 100644 --- a/app/controllers/api/class_members_controller.rb +++ b/app/controllers/api/class_members_controller.rb @@ -37,6 +37,7 @@ def create if result.success? @class_member = result[:class_members].first + track_class_students_added([@class_member]) render :show, formats: [:json], status: :created else render json: result.slice(:error, :errors), status: :unprocessable_content @@ -55,6 +56,7 @@ def create_batch if result.failure? && result[:error].include?('No valid school members provided') render json: result, status: :unprocessable_content else + track_class_students_added(result[:class_members]) successful = result[:class_members].map { |m| { success: true, user_id: m.user_id } } errors = result[:errors].map { |user_id, error| { success: false, user_id:, error: } } render json: successful + errors @@ -115,5 +117,19 @@ def list_teachers(school, teacher_ids) { school_teachers: [] } end end + + def track_class_students_added(class_members) + Array(class_members).grep(ClassStudent).each do |class_student| + student_id = class_student.student_id || class_student.student&.id + next if student_id.blank? + + track_event( + 'Class - Student added', + school_id: @school.id, + class_id: @school_class.id, + student_id: + ) + end + end end end diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 8ac48e14a..22e5469e5 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -157,7 +157,7 @@ def create_school_students(school_students_params, school_class) { school_students: school_students_result[:school_students], errors: school_students_result[:errors] - } + }.tap { track_created_school_students(school_students_result[:school_students], class_id: school_class.id) } end def assign_students_to_class(school_class, school_students) @@ -172,6 +172,7 @@ def assign_students_to_class(school_class, school_students) { success: false, student_id: user_id, school_class_id: school_class.id, error: error } end + track_imported_class_students_added(school_class, class_members_result[:class_members]) class_members_result[:class_members] + class_members_errors end @@ -221,5 +222,32 @@ def import_school_students_params def school_owner? current_user.school_owner?(@school) end + + def track_created_school_students(school_students, class_id: nil) + Array(school_students).each do |school_student| + next unless school_student[:success] && school_student[:created] + + track_event( + 'Student - Created', + school_id: @school.id, + class_id:, + student_id: school_student[:student].id + ) + end + end + + def track_imported_class_students_added(school_class, class_members) + Array(class_members).grep(ClassStudent).each do |class_student| + student_id = class_student.student_id || class_student.student&.id + next if student_id.blank? + + track_event( + 'Class - Student added', + school_id: @school.id, + class_id: school_class.id, + student_id: + ) + end + end end end diff --git a/app/controllers/api/school_students_controller.rb b/app/controllers/api/school_students_controller.rb index ba67dde6f..ba6180c91 100644 --- a/app/controllers/api/school_students_controller.rb +++ b/app/controllers/api/school_students_controller.rb @@ -27,6 +27,7 @@ def create ) if result.success? + track_event('Student - Created', school_id: @school.id, student_id: result[:student_id]) if result[:student_id].present? head :no_content else render json: { error: result[:error] }, status: :unprocessable_content @@ -86,7 +87,10 @@ def enqueue_batches(students) @batch.enqueue do students.each_slice(MAX_BATCH_CREATION_SIZE) do |student_batch| SchoolStudent::CreateBatch.call( - school: @school, school_students_params: student_batch, token: current_user.token + school: @school, + school_students_params: student_batch, + token: current_user.token, + actor_user_id: current_user.id ) end end diff --git a/app/jobs/create_students_job.rb b/app/jobs/create_students_job.rb index 06170c135..c5ac58dab 100644 --- a/app/jobs/create_students_job.rb +++ b/app/jobs/create_students_job.rb @@ -26,11 +26,14 @@ class ConcurrencyExceededForSchool < StandardError; end queue_as :create_students_job - def self.attempt_perform_later(school_id:, students:, token:) - perform_later(school_id:, students:, token:) + def self.attempt_perform_later(school_id:, students:, token:, actor_user_id: nil) + args = { school_id:, students:, token: } + args[:actor_user_id] = actor_user_id if actor_user_id.present? + + perform_later(**args) end - def perform(school_id:, students:, token:) + def perform(school_id:, students:, token:, actor_user_id: nil) school = School.find(school_id) decrypted_students = StudentHelpers.decrypt_students(students) SafeguardingFlagService.create_for_token(token:, school:) @@ -39,6 +42,22 @@ def perform(school_id:, students:, token:) responses[:created].each do |user_id| Role.student.create!(school_id:, user_id:) + track_student_created(school_id:, student_id: user_id, actor_user_id:) end end + + private + + def track_student_created(school_id:, student_id:, actor_user_id:) + return if actor_user_id.blank? + + EventTracker.track!( + name: 'Student - Created', + user_id: actor_user_id, + properties: { + school_id:, + student_id: + } + ) + end end diff --git a/lib/concepts/school_student/create_batch.rb b/lib/concepts/school_student/create_batch.rb index ad76b3c28..7aa7b7187 100644 --- a/lib/concepts/school_student/create_batch.rb +++ b/lib/concepts/school_student/create_batch.rb @@ -5,9 +5,9 @@ class Error < StandardError; end class CreateBatch class << self - def call(school:, school_students_params:, token:) + def call(school:, school_students_params:, token:, actor_user_id: nil) response = OperationResponse.new - response[:job_id] = create_batch(school, school_students_params, token) + response[:job_id] = create_batch(school, school_students_params, token, actor_user_id) response rescue CreateStudentsJob::ConcurrencyExceededForSchool => e response[:error] = e @@ -22,8 +22,8 @@ def call(school:, school_students_params:, token:) private - def create_batch(school, students, token) - job = CreateStudentsJob.attempt_perform_later(school_id: school.id, students:, token:) + def create_batch(school, students, token, actor_user_id) + job = CreateStudentsJob.attempt_perform_later(school_id: school.id, students:, token:, actor_user_id:) job&.job_id end end diff --git a/spec/features/class_member/creating_a_batch_of_class_members_spec.rb b/spec/features/class_member/creating_a_batch_of_class_members_spec.rb index 396e12c92..19ef87eea 100644 --- a/spec/features/class_member/creating_a_batch_of_class_members_spec.rb +++ b/spec/features/class_member/creating_a_batch_of_class_members_spec.rb @@ -61,6 +61,23 @@ expect(data).to include({ success: true, user_id: class_member[:user_id] }) end end + + it 'records class student added events for the student members only' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members/batch", headers:, params:) + + events = Event.where(name: 'Class - Student added').order(:time) + expect(events.count).to eq(3) + expect(events.map(&:user_id)).to all(eq(teacher.id)) + expect(events.map(&:properties)).to match_array( + students.map do |student| + { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'student_id' => student.id + } + end + ) + end end context 'when adding an owner as another teacher' do diff --git a/spec/features/class_member/creating_a_class_member_spec.rb b/spec/features/class_member/creating_a_class_member_spec.rb index db04e0b5f..27a3acf7f 100644 --- a/spec/features/class_member/creating_a_class_member_spec.rb +++ b/spec/features/class_member/creating_a_class_member_spec.rb @@ -60,6 +60,21 @@ expect(response_student).to eq(student_attributes) end + it 'records a class student added event' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: student_params) + + expect(Event.last).to have_attributes( + name: 'Class - Student added', + user_id: owner.id, + properties: { + 'school_id' => school.id, + 'class_id' => school_class.id, + 'student_id' => student.id + }, + time: be_within(1.second).of(Time.current) + ) + end + context 'when the student is already in the class' do before do school_class.students.create!({ student_id: student.id }) @@ -126,6 +141,12 @@ expect(response_teacher).to eq(teacher_attributes) end + it 'does not record a class student added event' do + expect do + post("/api/schools/#{school.id}/classes/#{school_class.id}/members", headers:, params: teacher_params) + end.not_to change(Event, :count) + end + context 'when the teacher is already in the class' do before do school_class.teachers.create!({ teacher_id: another_teacher.id }) diff --git a/spec/features/school_class/importing_a_school_class_spec.rb b/spec/features/school_class/importing_a_school_class_spec.rb index 9c54515bc..28a2c15ae 100644 --- a/spec/features/school_class/importing_a_school_class_spec.rb +++ b/spec/features/school_class/importing_a_school_class_spec.rb @@ -301,6 +301,20 @@ # Class members should be assigned successfully expect(response_data[:class_members]).to be_an(Array) expect(response_data[:class_members].length).to eq(2) + + student_events = Event.where(name: 'Student - Created') + expect(student_events.map(&:properties)).to contain_exactly( + { 'school_id' => school.id, 'class_id' => response_data[:school_class][:id], 'student_id' => 'student-1-id' }, + { 'school_id' => school.id, 'class_id' => response_data[:school_class][:id], 'student_id' => 'student-2-id' } + ) + expect(student_events).to all(have_attributes(user_id: teacher.id, time: be_within(1.second).of(Time.current))) + + class_member_events = Event.where(name: 'Class - Student added') + expect(class_member_events.map(&:properties)).to contain_exactly( + { 'school_id' => school.id, 'class_id' => response_data[:school_class][:id], 'student_id' => 'student-1-id' }, + { 'school_id' => school.id, 'class_id' => response_data[:school_class][:id], 'student_id' => 'student-2-id' } + ) + expect(class_member_events).to all(have_attributes(user_id: teacher.id, time: be_within(1.second).of(Time.current))) end it 'allows re-importing the same class (finds existing class)' do diff --git a/spec/features/school_student/creating_a_school_student_spec.rb b/spec/features/school_student/creating_a_school_student_spec.rb index ef046307b..ecb8e0cb0 100644 --- a/spec/features/school_student/creating_a_school_student_spec.rb +++ b/spec/features/school_student/creating_a_school_student_spec.rb @@ -38,6 +38,23 @@ expect(response).to have_http_status(:no_content) end + it 'records a student created event' do + student_id = SecureRandom.uuid + stub_profile_api_create_school_student(user_id: student_id) + + post("/api/schools/#{school.id}/students", headers:, params:) + + expect(Event.last).to have_attributes( + name: 'Student - Created', + user_id: owner.id, + properties: { + 'school_id' => school.id, + 'student_id' => student_id + }, + time: be_within(1.second).of(Time.current) + ) + end + it 'responds 204 No Content when the user is a school-teacher' do teacher = create(:teacher, school:) authenticated_in_hydra_as(teacher) diff --git a/spec/jobs/create_students_job_spec.rb b/spec/jobs/create_students_job_spec.rb index 7b6d1b007..f515bd475 100644 --- a/spec/jobs/create_students_job_spec.rb +++ b/spec/jobs/create_students_job_spec.rb @@ -8,6 +8,7 @@ let(:token) { UserProfileMock::TOKEN } let(:school) { create(:verified_school) } let(:user_id) { create(:user).id } + let(:actor_user_id) { create(:owner, school:).id } let(:students) do [{ @@ -46,4 +47,18 @@ expect(Role.student.where(school:, user_id:)).to exist end + + it 'records a student created event when the actor is provided' do + described_class.perform_now(school_id: school.id, students:, token:, actor_user_id:) + + expect(Event.last).to have_attributes( + name: 'Student - Created', + user_id: actor_user_id, + properties: { + 'school_id' => school.id, + 'student_id' => user_id + }, + time: be_within(1.second).of(Time.current) + ) + end end