From 3c5f29788e657af5b9c69e64cf371a8960f33d36 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 19:58:34 +1000 Subject: [PATCH] feat(demo): demo scenarios and sample data tasks Brings the T2 2026 demo work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit Co-authored-by: Ronit Khokhar Co-authored-by: Gaurav Myana --- app/api/demo_scenario_api.rb | 22 + lib/demo_data/all_features_scenario.rb | 811 ++++++++++++++++++ lib/demo_data/mobile_feedback_scenario.rb | 183 ++++ lib/tasks/all_features_demo.rake | 28 + lib/tasks/generate_feedback_data.rake | 12 +- lib/tasks/ppi_sample_data.rake | 291 +++++++ .../demo_data/all_features_scenario_test.rb | 505 +++++++++++ 7 files changed, 1848 insertions(+), 4 deletions(-) create mode 100644 app/api/demo_scenario_api.rb create mode 100644 lib/demo_data/all_features_scenario.rb create mode 100644 lib/demo_data/mobile_feedback_scenario.rb create mode 100644 lib/tasks/all_features_demo.rake create mode 100644 lib/tasks/ppi_sample_data.rake create mode 100644 test/lib/demo_data/all_features_scenario_test.rb diff --git a/app/api/demo_scenario_api.rb b/app/api/demo_scenario_api.rb new file mode 100644 index 0000000000..7077330194 --- /dev/null +++ b/app/api/demo_scenario_api.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'grape' +require Rails.root.join('lib/demo_data/all_features_scenario') + +class DemoScenarioApi < Grape::API + helpers AuthenticationHelpers + + before do + header 'Cache-Control', 'private, no-store' + authenticated? + end + + desc 'Get the guarded local mobile-feedback demo scenario contract', + tags: ['demo'], + summary: 'Get guarded local demo scenario' + get '/demo/scenario' do + DemoData::AllFeaturesScenario.contract_for(user: current_user) + rescue DemoData::AllFeaturesScenario::SafetyError + error!({ error: 'Not found.' }, 404) + end +end diff --git a/lib/demo_data/all_features_scenario.rb b/lib/demo_data/all_features_scenario.rb new file mode 100644 index 0000000000..75dd8e8296 --- /dev/null +++ b/lib/demo_data/all_features_scenario.rb @@ -0,0 +1,811 @@ +# frozen_string_literal: true + +require Rails.root.join('lib/demo_data/mobile_feedback_scenario') + +module DemoData + # Builds the small, deterministic API dataset used by the all-features demo. + # + # This is intentionally not a general seed. Both creation and cleanup refuse + # to run unless all three safety conditions match the dedicated local demo + # database. Re-running creation first removes this namespace and rebuilds it, + # so partial runs and stale relative dates cannot accumulate duplicate data. + class AllFeaturesScenario + class SafetyError < StandardError; end + + DATABASE_NAME = 'doubtfire-all-features-demo' + PROFILE_NAME = 'all-features' + CAMPUS_NAME = 'All Features Demo Campus' + CAMPUS_ABBREVIATION = 'AFDEMO' + DEMO_USERNAME = 'demo_student' + CONVENOR_USERNAME = 'demo_convenor' + PEER_USERNAMES = (1..24).map do |number| + "demo_peer_#{number.to_s.rjust(2, '0')}" + end.freeze + USERNAMES = [DEMO_USERNAME, CONVENOR_USERNAME, *PEER_USERNAMES].freeze + CURRENT_UNIT_CODES = MobileFeedbackScenario::CURRENT_UNIT_CODES + PREVIOUS_UNIT_CODE = MobileFeedbackScenario::PREVIOUS_UNIT_CODE + UNIT_CODES = MobileFeedbackScenario::UNIT_CODES + PPI_UNIT_CODE = MobileFeedbackScenario::PRIMARY_UNIT_CODE + PPI_TASK_ABBREVIATION = MobileFeedbackScenario::PPI_TASK_ABBREVIATION + COHORT_SIZE = 25 + PPI_STATUS_COUNTS = MobileFeedbackScenario::PPI_PEER_STATUS_COUNTS + .fetch(PPI_UNIT_CODE) + .merge(ready_for_feedback: 5) + .freeze + PPI_REQUIRED_VISIBLE_STATUSES = + MobileFeedbackScenario::EXPECTED_TASK_STATUS_PERCENTAGES.keys.freeze + PPI_UPLOADED_STATUSES = MobileFeedbackScenario::UPLOADED_STATUSES + SUBMITTED_COUNT = PPI_UPLOADED_STATUSES.sum do |status| + PPI_STATUS_COUNTS.fetch(status, 0) + end + NOTIFICATION_COUNT = 7 + TASK_BLUEPRINTS = MobileFeedbackScenario::TASK_BLUEPRINTS + UNIT_NAMES = MobileFeedbackScenario::UNIT_NAMES + + def self.run!(reference_time: Time.zone.now) + new(reference_time: reference_time).run! + end + + def self.cleanup! + new(reference_time: Time.zone.now).cleanup! + end + + def self.verify!(reference_time: Time.zone.now) + new(reference_time: reference_time).verify! + end + + def self.contract_for(user:, reference_time: Time.zone.now) + new(reference_time: reference_time).contract_for(user: user) + end + + def initialize(reference_time:) + @reference_time = reference_time.in_time_zone.beginning_of_day + end + + def run! + guard! + + result = nil + ActiveRecord::Base.transaction do + cleanup_records! + create_scenario! + result = summary + end + result + end + + def cleanup! + guard! + + ActiveRecord::Base.transaction { cleanup_records! } + true + end + + def verify! + guard! + + minimum_cohort_size = configured_positive_integer!( + 'DF_PPI_MINIMUM_COHORT_SIZE' + ) + stale_after_hours = configured_positive_integer!( + 'DF_PPI_STALE_AFTER_HOURS' + ) + if minimum_cohort_size < PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + raise SafetyError, + 'DF_PPI_MINIMUM_COHORT_SIZE is below the API privacy floor.' + end + + student = User.find_by!(username: DEMO_USERNAME) + unit = Unit.find_by!(code: PPI_UNIT_CODE) + definition = unit.task_definitions.find_by!( + abbreviation: PPI_TASK_ABBREVIATION + ) + project = student.projects.find_by!(unit: unit) + viewer_task = project.tasks.find_by!(task_definition: definition) + snapshot = unit.peer_progress_snapshots.find_by!( + task_definition: definition, + target_grade: project.target_grade + ) + + cohort_size = unit.active_projects.where( + target_grade: project.target_grade + ).count + unless unit.active? && unit.peer_progress_enabled? && + student.display_peer_progress? && project.enrolled? && + cohort_size == COHORT_SIZE && + cohort_size - 1 >= minimum_cohort_size + raise SafetyError, + 'All-features peer-progress cohort or display settings are invalid.' + end + + unless definition.target_grade <= project.target_grade && + definition.start_date.present? && + definition.start_date <= Time.zone.now + raise SafetyError, + 'All-features peer-progress task is not released for the demo student.' + end + + latest_grade_change = unit.active_projects.where( + target_grade: project.target_grade + ).maximum(:target_grade_changed_at) + unless snapshot.cohort_size == cohort_size && + snapshot.submitted_count.is_a?(Integer) && + snapshot.submitted_percentage.present? && + snapshot.calculated_at >= stale_after_hours.hours.ago && + (latest_grade_change.nil? || + snapshot.calculated_at >= latest_grade_change) + raise SafetyError, + 'All-features peer-progress snapshot is stale or inconsistent.' + end + + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + if peer_progress.nil? + raise SafetyError, + 'All-features peer-progress snapshot cannot exclude the demo viewer.' + end + + public_metrics = PeerProgressViewerPolicy.public_metrics(peer_progress) + distribution = public_metrics.fetch(:status_distribution) + unless distribution&.length == + PeerProgressDistributionPolicy::STATUS_KEYS.length + raise SafetyError, + 'All-features detailed peer-progress distribution is suppressed.' + end + + percentages = distribution.index_by do |entry| + entry.fetch(:status).to_sym + end + unless PPI_REQUIRED_VISIBLE_STATUSES.all? do |status| + percentages.fetch(status).fetch(:percentage).positive? + end + raise SafetyError, + 'All-features peer-progress lifecycle statuses are not visible.' + end + + contract = contract_for(user: student) + lifecycle = contract.fetch(:task_lifecycle) + lifecycle_percentages = lifecycle.fetch(:statuses).to_h do |entry| + [entry.fetch(:status).to_sym, entry.fetch(:percentage)] + end + expected_lifecycle = + MobileFeedbackScenario::EXPECTED_TASK_STATUS_PERCENTAGES + ppi_states = contract.fetch(:units).index_by { |item| item.fetch(:code) } + available_ppi_units = MobileFeedbackScenario::PPI_PEER_STATUS_COUNTS.keys + lifecycle_valid = lifecycle.fetch(:total_tasks) == 10 && + lifecycle.fetch(:submitted_percentage).to_f.round == 60 && + lifecycle.fetch(:completed_percentage).to_f.round == 10 && + lifecycle_percentages == expected_lifecycle + available_ppi_valid = available_ppi_units.all? do |code| + ppi_states.fetch(code).fetch(:ppi).fetch(:state) == 'available' + end + unavailable_ppi_valid = ppi_states.fetch( + MobileFeedbackScenario::PPI_UNAVAILABLE_UNIT_CODE + ).fetch(:ppi).fetch(:state) == 'unavailable' + notifications_valid = contract.fetch(:notification_hooks) + .pluck(:event).uniq.length == + NOTIFICATION_COUNT + group_valid = contract.dig(:group_hook, :member_count) == + MobileFeedbackScenario::GROUP.fetch(:member_count) + + unless lifecycle_valid && available_ppi_valid && + unavailable_ppi_valid && notifications_valid && group_valid + raise SafetyError, + 'All-features mobile-feedback scenario contract is inconsistent.' + end + + { + profile: PROFILE_NAME, + submitted_percentage: public_metrics.fetch(:submitted_percentage), + completed_percentage: public_metrics.fetch(:completed_percentage), + status_distribution: distribution + } + rescue ActiveRecord::RecordNotFound => e + raise SafetyError, + "All-features demo data is incomplete: #{e.message}" + end + + def guard! + unless Rails.env.development? + raise SafetyError, + 'All-features demo data can run only in Rails development.' + end + + database_name = connected_database_name + unless database_name == DATABASE_NAME + raise SafetyError, + "All-features demo data requires database #{DATABASE_NAME.inspect}; " \ + "connected to #{database_name.inspect}." + end + + return if ENV.fetch('DF_DEMO_DATA_PROFILE', nil) == PROFILE_NAME + + raise SafetyError, + 'Set DF_DEMO_DATA_PROFILE=all-features to confirm this demo-only operation.' + end + + def contract_for(user:) + guard! + unless user&.username == DEMO_USERNAME + raise SafetyError, 'The demo scenario is unavailable for this account.' + end + + projects = user.projects.includes(:unit).index_by { |item| item.unit.code } + units = CURRENT_UNIT_CODES.to_h do |code| + project = projects.fetch(code) + unit = project.unit + definition = unit.task_definitions.find_by!( + abbreviation: PPI_TASK_ABBREVIATION + ) + [ + code, + { + key: code, + code: code, + name: unit.name, + unit_id: unit.id, + project_id: project.id, + ppi: ppi_contract( + unit: unit, + project: project, + definition: definition + ) + } + ] + end + + { + schema_version: MobileFeedbackScenario::SCHEMA_VERSION, + scenario_id: MobileFeedbackScenario::SCENARIO_ID, + demo_only: true, + generated_at: PeerProgressSnapshot.joins(:unit) + .where(units: { code: CURRENT_UNIT_CODES }) + .maximum(:calculated_at)&.utc&.iso8601, + primary_unit_key: PPI_UNIT_CODE, + units: units.values, + task_lifecycle: task_lifecycle_contract(projects.fetch(PPI_UNIT_CODE)), + notification_hooks: notification_contract(user), + group_hook: group_contract(projects), + walkthrough_links: walkthrough_contract(projects) + } + rescue ActiveRecord::RecordNotFound, KeyError => e + raise SafetyError, "All-features demo data is incomplete: #{e.message}" + end + + private + + attr_reader :reference_time + + def connected_database_name + ActiveRecord::Base.connection_db_config.database.to_s + end + + def configured_positive_integer!(name) + value = Integer(ENV.fetch(name), 10) + raise ArgumentError unless value.positive? + + value + rescue KeyError, ArgumentError + raise SafetyError, "#{name} must be a positive integer." + end + + def ppi_contract(unit:, project:, definition:) + snapshot = unit.peer_progress_snapshots.find_by!( + task_definition: definition, + target_grade: project.target_grade + ) + viewer_task = project.tasks.find_by!(task_definition: definition) + peer_cohort_size = [snapshot.cohort_size - 1, 0].max + + if peer_cohort_size < PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + return { + state: 'unavailable', + unavailable_reason: 'insufficient_cohort', + task_abbreviation: definition.abbreviation, + task_definition_id: definition.id, + submitted_percentage: nil, + completed_percentage: nil, + status_distribution: nil + } + end + + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + # build returns nil once the viewer's project or task has changed since + # aggregation. Report that the way PeerProgressApi does instead of + # failing the whole contract. + if peer_progress.nil? + return { + state: 'unavailable', + unavailable_reason: 'aggregation_incomplete', + task_abbreviation: definition.abbreviation, + task_definition_id: definition.id, + submitted_percentage: nil, + completed_percentage: nil, + status_distribution: nil + } + end + + metrics = PeerProgressViewerPolicy.public_metrics(peer_progress) + distribution = metrics.fetch(:status_distribution) + if distribution.blank? + raise SafetyError, 'Demo peer-progress distribution is unavailable.' + end + + { + state: 'available', + unavailable_reason: nil, + task_abbreviation: definition.abbreviation, + task_definition_id: definition.id, + submitted_percentage: metrics.fetch(:submitted_percentage), + completed_percentage: metrics.fetch(:completed_percentage), + status_distribution: distribution + } + end + + # Percentages are derived from the persisted tasks, so verify! can compare + # them with the canonical fixture and the contract never pairs live counts + # with fixed percentages. + def task_lifecycle_contract(project) + grouped = project.tasks.group(:task_status_id).count + total = project.tasks.count + # Every fixture status, then any other status a demo task has moved to, + # so the counts always add up to total_tasks. + expected_keys = MobileFeedbackScenario::EXPECTED_TASK_STATUS_PERCENTAGES.keys + other_keys = TaskStatus.where(id: grouped.keys).map(&:status_key) - expected_keys + statuses = (expected_keys + other_keys).map do |status| + task_status = TaskStatus.public_send(status) + count = grouped.fetch(task_status.id, 0) + tasks = project.tasks + .joins(:task_definition) + .where(task_status: task_status) + .order('task_definitions.abbreviation') + { + status: status.to_s, + count: count, + percentage: lifecycle_percentage(count, total), + task_abbreviations: tasks.pluck('task_definitions.abbreviation') + } + end + + { + unit_key: project.unit.code, + total_tasks: total, + submitted_percentage: lifecycle_percentage( + project.tasks.where.not(file_uploaded_at: nil).count, + total + ), + completed_percentage: lifecycle_percentage( + grouped.fetch(TaskStatus.complete.id, 0), + total + ), + statuses: statuses + } + end + + def lifecycle_percentage(count, total) + return 0.0 if total.zero? + + (count * 100.0 / total).round(1) + end + + # Resolve each hook through the dedupe key it was seeded with. Other + # notifications can share an event, for example a tutor comment made + # while walking through the demo. + def notification_contract(user) + records = user.notifications + .where(dedupe_key: MobileFeedbackScenario::NOTIFICATIONS.map { |fixture| notification_dedupe_key(fixture) }) + .index_by(&:dedupe_key) + MobileFeedbackScenario::NOTIFICATIONS.map do |fixture| + notification = records.fetch(notification_dedupe_key(fixture)) + { + key: fixture.fetch(:key), + id: notification.id, + event: notification.event, + notification_type: notification.notification_type, + read: notification.read?, + created_at: notification.created_at.utc.iso8601, + link: notification.link + } + end + end + + def notification_dedupe_key(fixture) + "all_features_demo:#{fixture.fetch(:key)}" + end + + def group_contract(projects) + fixture = MobileFeedbackScenario::GROUP + project = projects.fetch(fixture.fetch(:unit_code)) + group_set = project.unit.group_sets.find_by!( + name: fixture.fetch(:group_set_name) + ) + group = group_set.groups.find_by!(name: fixture.fetch(:name)) + + { + key: fixture.fetch(:key), + unit_key: project.unit.code, + unit_id: project.unit_id, + project_id: project.id, + group_set_id: group_set.id, + group_id: group.id, + name: group.name, + member_count: group.student_count, + capacity: group.capacity, + route: "/projects/#{project.id}/groups" + } + end + + def walkthrough_contract(projects) + MobileFeedbackScenario::WALKTHROUGH_LINKS.map do |fixture| + route = case fixture.fetch(:route) + when :notifications + '/notifications' + when :dashboard + project = projects.fetch(fixture.fetch(:unit_code)) + "/projects/#{project.id}/dashboard" + when :task + project = projects.fetch(fixture.fetch(:unit_code)) + "/projects/#{project.id}/dashboard/#{fixture.fetch(:task)}" + end + route = "#{route}?#{fixture.fetch(:query)}" if fixture[:query] + { + key: fixture.fetch(:key), + label: fixture.fetch(:label), + route: route + } + end + end + + def create_scenario! + ensure_reference_data! + campus = create_campus! + convenor = create_user!( + username: CONVENOR_USERNAME, + first_name: 'Demo', + last_name: 'Convenor', + role: Role.convenor + ) + demo_student = create_user!( + username: DEMO_USERNAME, + first_name: 'Demo', + last_name: 'Student', + role: Role.student, + student_id: 'DEMO-STUDENT' + ) + peers = create_peer_users! + + units = UNIT_CODES.index_with do |code| + create_unit!(code: code, convenor: convenor) + end + + demo_projects = units.transform_values do |unit| + project = enrol!(unit: unit, student: demo_student, campus: campus) + materialise_demo_tasks!(project) + project + end + + peer_projects = create_ppi_cohorts!( + units: units, + peers: peers, + campus: campus + ) + CURRENT_UNIT_CODES.each do |code| + aggregate_peer_progress!(units.fetch(code)) + end + create_group_hook!( + unit: units.fetch(MobileFeedbackScenario::GROUP.fetch(:unit_code)), + campus: campus, + convenor: convenor, + demo_project: demo_projects.fetch( + MobileFeedbackScenario::GROUP.fetch(:unit_code) + ), + peer_projects: peer_projects.fetch( + MobileFeedbackScenario::GROUP.fetch(:unit_code) + ).first(2) + ) + create_notifications!(demo_student) + end + + def create_peer_users! + PEER_USERNAMES.each_with_index.map do |username, index| + create_user!( + username: username, + first_name: 'Demo', + last_name: "Peer #{(index + 1).to_s.rjust(2, '0')}", + role: Role.student, + student_id: "DEMO-PEER-#{(index + 1).to_s.rjust(2, '0')}", + notifications_enabled: false + ) + end + end + + def ensure_reference_data! + missing_roles = (1..Role.auditor_id).reject { |id| Role.exists?(id: id) } + missing_statuses = (1..TaskStatus.count).reject do |id| + TaskStatus.exists?(id: id) + end + return if missing_roles.empty? && missing_statuses.empty? + + raise SafetyError, + 'Run db:init before db:all_features_demo; required roles or task statuses are missing.' + end + + def create_campus! + Campus.create!( + name: CAMPUS_NAME, + abbreviation: CAMPUS_ABBREVIATION, + mode: :manual, + active: true, + timezone: 'Australia/Melbourne' + ) + end + + def create_user!( + username:, + first_name:, + last_name:, + role:, + student_id: nil, + notifications_enabled: true + ) + User.create!( + username: username, + login_id: username, + email: "#{username}@all-features.invalid", + first_name: first_name, + last_name: last_name, + nickname: first_name, + role: role, + student_id: student_id, + password: 'password', + password_confirmation: 'password', + receive_task_notifications: notifications_enabled, + receive_feedback_notifications: notifications_enabled, + receive_portfolio_notifications: notifications_enabled, + display_peer_progress: true, + opt_in_to_research: false, + has_run_first_time_setup: true + ) + end + + def create_unit!(code:, convenor:) + previous = code == PREVIOUS_UNIT_CODE + unit = Unit.create!( + code: code, + name: UNIT_NAMES.fetch(code), + description: 'Synthetic local data for the isolated all-features demo.', + start_date: previous ? reference_time - 24.weeks : reference_time - 6.weeks, + end_date: previous ? reference_time - 8.weeks : reference_time + 7.weeks, + active: !previous, + send_notifications: false, + enable_sync_timetable: false, + enable_sync_enrolments: false, + allow_flexible_dates: false, + peer_progress_enabled: CURRENT_UNIT_CODES.include?(code), + grade_definitions: Unit::DEFAULT_GRADE_DEFINITIONS + ) + unit.employ_staff(convenor, Role.convenor) + create_task_definitions!(unit) + unit + end + + def create_task_definitions!(unit) + TASK_BLUEPRINTS.each do |blueprint| + target_offset = blueprint.fetch(:target_offset) + if unit.code != PPI_UNIT_CODE + target_offset = MobileFeedbackScenario::NON_PRIMARY_TARGET_OVERRIDES + .fetch(blueprint.fetch(:abbreviation), target_offset) + end + TaskDefinition.create!( + unit: unit, + name: blueprint.fetch(:name), + abbreviation: blueprint.fetch(:abbreviation), + description: 'Synthetic task for the isolated all-features demo.', + weighting: blueprint.fetch(:weighting), + target_grade: 0, + start_date: reference_time + blueprint.fetch(:start_offset).days, + target_date: reference_time + target_offset.days, + due_date: reference_time + (target_offset + 4).days, + upload_requirements: [ + { + 'key' => 'file0', + 'name' => 'Demo document', + 'type' => 'document' + } + ] + ) + end + end + + def enrol!(unit:, student:, campus:) + project = unit.enrol_student(student, campus) + project.update!( + target_grade: 0, + enrolled: true, + started: true, + progress: 'Synthetic all-features demo progress.' + ) + project + end + + def materialise_demo_tasks!(project) + TASK_BLUEPRINTS.each_with_index do |blueprint, index| + status = TaskStatus.public_send(blueprint.fetch(:status)) + attributes = { + project: project, + task_definition: project.unit.task_definitions.find_by!( + abbreviation: blueprint.fetch(:abbreviation) + ), + task_status: status + } + + if PPI_UPLOADED_STATUSES.include?(blueprint.fetch(:status)) + submitted_at = reference_time - (index + 1).hours + attributes[:file_uploaded_at] = submitted_at + attributes[:submission_date] = submitted_at + end + + if status == TaskStatus.complete + attributes[:completion_date] = (reference_time - 8.days).to_date + end + + Task.create!(attributes) + end + project.update_task_stats + end + + def create_ppi_cohorts!(units:, peers:, campus:) + MobileFeedbackScenario::PPI_PEER_STATUS_COUNTS.to_h do |code, counts| + unit = units.fetch(code) + definition = unit.task_definitions.find_by!( + abbreviation: PPI_TASK_ABBREVIATION + ) + statuses = counts.flat_map { |status, count| [status] * count } + projects = peers.each_with_index.map do |student, index| + project = enrol!(unit: unit, student: student, campus: campus) + status_key = statuses.fetch(index) + submitted_at = if PPI_UPLOADED_STATUSES.include?(status_key) + reference_time - 1.day + end + Task.create!( + project: project, + task_definition: definition, + task_status: TaskStatus.public_send(status_key), + file_uploaded_at: submitted_at, + submission_date: submitted_at, + completion_date: + status_key == :complete ? (reference_time - 1.day).to_date : nil + ) + project.update_task_stats + project + end + [code, projects] + end + end + + def aggregate_peer_progress!(unit) + # Run the production aggregation job synchronously. Calling #perform does + # not enqueue Sidekiq work and therefore does not touch the running demo. + AggregatePeerProgressJob.new.perform(unit.id) + end + + def create_group_hook!( + unit:, + campus:, + convenor:, + demo_project:, + peer_projects: + ) + fixture = MobileFeedbackScenario::GROUP + unit_role = unit.unit_roles.find_by!(user: convenor) + tutorial = Tutorial.create!( + unit: unit, + unit_role: unit_role, + campus: campus, + abbreviation: fixture.fetch(:tutorial), + meeting_day: 'Wednesday', + meeting_time: '10:00', + meeting_location: 'Synthetic demo room' + ) + group_set = GroupSet.create!( + unit: unit, + name: fixture.fetch(:group_set_name), + capacity: fixture.fetch(:capacity), + allow_students_to_manage_groups: false, + allow_students_to_create_groups: false, + keep_groups_in_same_class: false, + locked: false + ) + group = Group.create!( + group_set: group_set, + tutorial: tutorial, + name: fixture.fetch(:name) + ) + [demo_project, *peer_projects].each do |project| + group.add_member(project, notify: false) + end + end + + def create_notifications!(student) + projects_by_code = student.projects.includes(:unit).index_by do |project| + project.unit.code + end + project = projects_by_code.fetch(PPI_UNIT_CODE) + + MobileFeedbackScenario::NOTIFICATIONS.each do |blueprint| + created_at = reference_time - blueprint.fetch(:age) + link = "/projects/#{project.id}/dashboard/" \ + "#{blueprint.fetch(:task)}#{blueprint.fetch(:suffix, '')}" + notification = NotificationService.reserve( + user: student, + type: blueprint.fetch(:type), + event: blueprint.fetch(:event), + message: blueprint.fetch(:message), + link: link, + dedupe_key: notification_dedupe_key(blueprint) + ) + notification.update!( + created_at: created_at, + updated_at: created_at, + delivered_at: created_at, + read_at: blueprint.fetch(:read) ? created_at + 5.minutes : nil + ) + end + end + + def cleanup_records! + Unit.where(code: UNIT_CODES).find_each(&:destroy!) + User.where(username: USERNAMES).find_each(&:destroy!) + Campus.find_by(abbreviation: CAMPUS_ABBREVIATION)&.destroy! + end + + def summary + ppi_unit = Unit.find_by!(code: PPI_UNIT_CODE) + ppi_definition = ppi_unit.task_definitions.find_by!( + abbreviation: PPI_TASK_ABBREVIATION + ) + snapshot = ppi_unit.peer_progress_snapshots.find_by!( + task_definition: ppi_definition, + target_grade: 0 + ) + demo_project = User.find_by!(username: DEMO_USERNAME) + .projects.find_by!(unit: ppi_unit) + viewer_task = demo_project.tasks.find_by!( + task_definition: ppi_definition + ) + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: demo_project, + viewer_task: viewer_task + ) + public_metrics = PeerProgressViewerPolicy.public_metrics(peer_progress) + + { + profile: PROFILE_NAME, + login: DEMO_USERNAME, + password: 'password', + unit_codes: UNIT_CODES, + users: User.where(username: USERNAMES).count, + projects: Project.joins(:unit).where(units: { code: UNIT_CODES }).count, + tasks: Task.joins(project: :unit).where(units: { code: UNIT_CODES }).count, + notifications: User.find_by!(username: DEMO_USERNAME).notifications.count, + push_subscriptions: PushSubscription.joins(:user).where(users: { username: USERNAMES }).count, + peer_progress: { + unit_code: PPI_UNIT_CODE, + task_abbreviation: PPI_TASK_ABBREVIATION, + submitted_percentage: public_metrics.fetch(:submitted_percentage), + completed_percentage: public_metrics.fetch(:completed_percentage), + distribution_available: + public_metrics.fetch(:status_distribution).present? + } + } + end + end +end diff --git a/lib/demo_data/mobile_feedback_scenario.rb b/lib/demo_data/mobile_feedback_scenario.rb new file mode 100644 index 0000000000..e5f7086a42 --- /dev/null +++ b/lib/demo_data/mobile_feedback_scenario.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +module DemoData + # Canonical semantic contract for the isolated mobile-feedback walkthrough. + # + # This module intentionally contains no database IDs. All dynamic identifiers + # are resolved only after the guarded all-features seed has been materialised. + # The web client consumes the authenticated /api/demo/scenario projection and + # never inserts this contract into normal project, unit, task, notification or + # group caches. + module MobileFeedbackScenario + SCHEMA_VERSION = 1 + SCENARIO_ID = 'mobile-feedback-v1' + PRIMARY_UNIT_CODE = 'DEMO10001' + PPI_TASK_ABBREVIATION = 'DUE7' + + CURRENT_UNIT_CODES = %w[ + DEMO10001 + DEMO20007 + DEMO30046 + DEMO30243 + ].freeze + PREVIOUS_UNIT_CODE = 'DEMO09999' + UNIT_CODES = [*CURRENT_UNIT_CODES, PREVIOUS_UNIT_CODE].freeze + + UNIT_NAMES = { + 'DEMO10001' => 'Foundations of OnTrack', + 'DEMO20007' => 'Active Learning Studio', + 'DEMO30046' => 'Applied Project Delivery', + 'DEMO30243' => 'Professional Practice', + PREVIOUS_UNIT_CODE => 'Previous Study Portfolio' + }.freeze + + UPLOADED_STATUSES = %i[ + ready_for_feedback + fix_and_resubmit + redo + complete + fail + ].freeze + + TASK_BLUEPRINTS = [ + { + abbreviation: 'OVERDUE', name: 'Overdue Foundations', + start_offset: -21, target_offset: -1, + status: :not_started, weighting: 3 + }, + { + abbreviation: 'FUTURE', name: 'Future Planning', + start_offset: 10, target_offset: 14, + status: :not_started, weighting: 2 + }, + { + abbreviation: 'DUE3', name: 'Due Within Three Days', + start_offset: -10, target_offset: 2, + status: :working_on_it, weighting: 6 + }, + { + abbreviation: 'WORK', name: 'Work in Progress', + start_offset: -5, target_offset: 10, + status: :working_on_it, weighting: 5 + }, + { + abbreviation: PPI_TASK_ABBREVIATION, + name: 'Due Within Seven Days', + start_offset: -7, target_offset: 6, + status: :ready_for_feedback, weighting: 4 + }, + { + abbreviation: 'AWAITING', name: 'Awaiting Tutor Feedback', + start_offset: -12, target_offset: 5, + status: :ready_for_feedback, weighting: 4 + }, + { + abbreviation: 'RESUBMIT', name: 'Fix and Resubmit', + start_offset: -18, target_offset: 4, + status: :fix_and_resubmit, weighting: 4 + }, + { + abbreviation: 'REDO', name: 'Redo Required', + start_offset: -20, target_offset: 8, + status: :redo, weighting: 4 + }, + { + abbreviation: 'DONE', name: 'Completed Practice', + start_offset: -28, target_offset: -7, + status: :complete, weighting: 1 + }, + { + abbreviation: 'FAILED', name: 'Not Yet Passed', + start_offset: -24, target_offset: -5, + status: :fail, weighting: 3 + } + ].map(&:freeze).freeze + + NON_PRIMARY_TARGET_OVERRIDES = { 'DUE3' => 9 }.freeze + + EXPECTED_TASK_STATUS_PERCENTAGES = { + not_started: 20.0, + working_on_it: 20.0, + ready_for_feedback: 20.0, + fix_and_resubmit: 10.0, + redo: 10.0, + complete: 10.0, + fail: 10.0 + }.freeze + + # Peer-only counts. The authenticated viewer is added by the normal task + # materialisation path, then excluded by PeerProgressViewerPolicy before any + # values are returned. Raw counts never cross the API boundary. + PPI_PEER_STATUS_COUNTS = { + 'DEMO10001' => { + not_started: 4, working_on_it: 5, ready_for_feedback: 4, + fix_and_resubmit: 3, redo: 3, complete: 3, fail: 2 + }, + 'DEMO20007' => { + not_started: 2, working_on_it: 5, ready_for_feedback: 5, + fix_and_resubmit: 2, redo: 2, complete: 5, fail: 3 + }, + 'DEMO30046' => { + not_started: 7, working_on_it: 4, ready_for_feedback: 3, + fix_and_resubmit: 2, redo: 2, complete: 4, fail: 2 + } + }.transform_values(&:freeze).freeze + PPI_UNAVAILABLE_UNIT_CODE = 'DEMO30243' + + NOTIFICATIONS = [ + { + key: 'new-task', type: 'task', event: 'new_task_available', + message: 'A new task is available in DEMO10001.', + task: 'FUTURE', age: 20.minutes, read: false + }, + { + key: 'due-soon', type: 'task', event: 'task_due_soon', + message: 'DUE3 in DEMO10001 is due soon.', + task: 'DUE3', age: 45.minutes, read: false + }, + { + key: 'date-changed', type: 'task', event: 'task_due_date_changed', + message: 'The due date for DUE7 has changed.', + task: PPI_TASK_ABBREVIATION, age: 90.minutes, read: false + }, + { + key: 'feedback', type: 'feedback', event: 'task_comment_created', + message: 'You have new feedback in OnTrack.', + task: 'AWAITING', suffix: '/feedback', age: 3.hours, read: false + }, + { + key: 'status-changed', type: 'task', event: 'task_status_changed', + message: 'RESUBMIT has changed status.', + task: 'RESUBMIT', age: 8.hours, read: true + }, + { + key: 'extension', type: 'extension', event: 'extension_assessed', + message: 'Your extension request has been assessed.', + task: 'WORK', age: 1.day, read: true + }, + { + key: 'portfolio', type: 'portfolio', event: 'portfolio_received', + message: 'Your portfolio is ready to review.', + task: 'DONE', age: 2.days, read: true + } + ].map(&:freeze).freeze + + GROUP = { + key: 'project-team', unit_code: 'DEMO20007', + group_set_name: 'Demo project teams', tutorial: 'ST1', + name: 'Team Indigo', capacity: 4, member_count: 3 + }.freeze + + WALKTHROUGH_LINKS = [ + { key: 'tasks', label: 'Tasks and CPD', unit_code: PRIMARY_UNIT_CODE, + route: :dashboard }, + { key: 'ppi', label: 'Peer Progress Indicator', + unit_code: PRIMARY_UNIT_CODE, task: PPI_TASK_ABBREVIATION, + route: :task, query: 'walkthrough=ppi' }, + { key: 'burndown', label: 'Progress Burndown', + unit_code: PRIMARY_UNIT_CODE, route: :dashboard, + query: 'walkthrough=burndown' }, + { key: 'notifications', label: 'Notifications', route: :notifications } + ].map(&:freeze).freeze + end +end diff --git a/lib/tasks/all_features_demo.rake b/lib/tasks/all_features_demo.rake new file mode 100644 index 0000000000..59d46adf8f --- /dev/null +++ b/lib/tasks/all_features_demo.rake @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require Rails.root.join('lib/demo_data/all_features_scenario') + +namespace :db do + desc 'Recreate the guarded, local all-features demo dataset' + task all_features_demo: :environment do + Rails.logger.level = Logger::INFO + result = DemoData::AllFeaturesScenario.run! + + puts "All-features demo data is ready: #{result.inspect}" + end + + desc 'Verify the guarded all-features demo dataset without changing it' + task all_features_demo_verify: :environment do + result = DemoData::AllFeaturesScenario.verify! + + puts "All-features demo data passed verification: #{result.inspect}" + end + + desc 'Remove only the guarded all-features demo dataset' + task all_features_demo_cleanup: :environment do + Rails.logger.level = Logger::INFO + DemoData::AllFeaturesScenario.cleanup! + + puts 'All-features demo data has been removed.' + end +end diff --git a/lib/tasks/generate_feedback_data.rake b/lib/tasks/generate_feedback_data.rake index 075c9657a5..a0aae55cfa 100644 --- a/lib/tasks/generate_feedback_data.rake +++ b/lib/tasks/generate_feedback_data.rake @@ -1,19 +1,23 @@ namespace :db do desc "Generate feedback chips" - task generate_feedback_chips: :environment do + task generate_feedback_chips: [:skip_prod, :environment] do require 'faker' units = Unit.limit(5) task_definitions = TaskDefinition.limit(5) + # Only hang dummy chips under the outcomes this task creates. Iterating + # LearningOutcome.all would attach Faker chips to every real, convenor + # authored outcome on the box. + created_outcomes = [] units.each do |unit| - FactoryBot.create_list(:learning_outcome, 3, context_type: 'Unit', context_id: unit.id) + created_outcomes.concat(FactoryBot.create_list(:learning_outcome, 3, context_type: 'Unit', context_id: unit.id)) end task_definitions.each do |task_definition| - FactoryBot.create_list(:learning_outcome, 3, context_type: 'TaskDefinition', context_id: task_definition.id) + created_outcomes.concat(FactoryBot.create_list(:learning_outcome, 3, context_type: 'TaskDefinition', context_id: task_definition.id)) end - LearningOutcome.all.find_each do |lo| + created_outcomes.each do |lo| # create 4 top level group chips group_chips = FactoryBot.create_list(:feedback_group_chip, 2, learning_outcome_id: lo.id) nested_group_chips = FactoryBot.create_list(:feedback_group_chip, 2, learning_outcome_id: lo.id) diff --git a/lib/tasks/ppi_sample_data.rake b/lib/tasks/ppi_sample_data.rake new file mode 100644 index 0000000000..f1dd77cdcc --- /dev/null +++ b/lib/tasks/ppi_sample_data.rake @@ -0,0 +1,291 @@ +require_all 'lib/helpers' +require Rails.root.join('lib/demo_data/all_features_scenario') + +PPI_SAMPLE_LIFECYCLE_STATUSES = %i[ + not_started + working_on_it + ready_for_feedback + fix_and_resubmit + redo + complete + fail +].freeze +PPI_SAMPLE_UPLOADED_STATUSES = %i[ + ready_for_feedback + fix_and_resubmit + redo + complete + fail +].freeze + +def ppi_viewer_vectors_safe?(unit:, snapshot:, minimum_cohort_size:) + viewers = unit.active_projects.where( + target_grade: snapshot.target_grade + ) + + viewers.all? do |project| + viewer_task = project.tasks.find_by!( + task_definition_id: snapshot.task_definition_id + ) + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + peer_progress.present? && + peer_progress.fetch(:cohort_size) >= minimum_cohort_size && + PeerProgressViewerPolicy + .public_metrics(peer_progress) + .fetch(:status_distribution) + .present? + end +end + +namespace :db do + desc 'Create deterministic, privacy-threshold-ready demo data for the Peer Progress Indicator dashboard' + task ppi_sample_data: :environment do + # This task creates hundreds of synthetic users, enrolments and tasks. Use + # the same non-interactive triple guard as the all-features demo instead of + # permitting a typed confirmation against an arbitrary production database. + DemoData::AllFeaturesScenario.new(reference_time: Time.zone.now).guard! + + Rails.logger.level = :info + + # ---- configuration ------------------------------------------------- + num_units = 2 + classes_per_unit = 2 + legacy_students_per_grade = 4 + grade_labels = { 0 => 'Pass', 1 => 'Credit', 2 => 'Distinction', 3 => 'HighDistinction' }.freeze + grades = grade_labels.keys.freeze # [0, 1, 2, 3] + num_tasks = 7 # within the requested 5-10 range + weekdays = %w[Monday Tuesday Wednesday Thursday Friday].freeze + + # ---- helpers --------------------------------------------------------- + + def ppi_positive_integer_env!(name) + value = Integer(ENV.fetch(name), 10) + raise ArgumentError unless value.positive? + + value + rescue KeyError, ArgumentError + raise ArgumentError, "#{name} must be a positive integer" + end + + # Finds or creates a user with a fixed, deterministic username - safe to re-run. + def ppi_find_or_create_user(username, first_name, last_name, role_id) + existing = User.find_by(username: username) + if existing + existing.update!(role_id: role_id) if existing.role_id != role_id + return existing + end + + profile = { + first_name: first_name, + last_name: last_name, + nickname: username, + role_id: role_id, + email: "#{username}@doubtfire.com", + username: username + } + unless AuthenticationHelpers.aaf_auth? + profile[:password] = 'password' + profile[:password_confirmation] = 'password' + end + User.create!(profile) + end + + minimum_cohort_size = ppi_positive_integer_env!('DF_PPI_MINIMUM_COHORT_SIZE') + stale_after_hours = ppi_positive_integer_env!('DF_PPI_STALE_AFTER_HOURS') + if minimum_cohort_size < PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + raise ArgumentError, + "DF_PPI_MINIMUM_COHORT_SIZE must be at least #{PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE}" + end + + # The authenticated viewer is removed before the threshold is applied, so + # each exact-grade cohort needs at least one more student than the peer floor. + required_total_cohort = minimum_cohort_size + 1 + students_per_grade = required_total_cohort.fdiv(classes_per_unit).ceil + baseline_students_per_grade = + (PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + 1) + .fdiv(classes_per_unit).ceil + sample_start_date = Time.zone.now - 6.weeks + sample_end_date = Time.zone.now + 7.weeks + + campus = Campus.first || Campus.create!(name: 'Online', mode: 'timetable', abbreviation: 'C', active: true) + convenor = ppi_find_or_create_user('ppi_convenor', 'Peer', 'Convenor', Role.convenor_id) + + (1..num_units).each do |unit_num| + code = "PPI100#{unit_num}" + unit = Unit.find_or_initialize_by(code: code) + unit.update!( + name: "PPI Sample Unit #{unit_num}", + description: 'Deterministic sample data for testing the Peer Progress Indicator dashboard. Not a real unit.', + start_date: sample_start_date, + end_date: sample_end_date, + active: true, + send_notifications: false, + allow_flexible_dates: false, + peer_progress_enabled: true + ) + + unless grades.all? { |target_grade| unit.grade_value?(target_grade) } + raise "#{unit.code} must retain the four standard target grades for the PPI demo" + end + + unit.employ_staff(convenor, Role.convenor) + + # All tasks are assigned regardless of a student's target grade (target_grade: 0 = Pass), + # so every student in the unit has the same task list - needed to compare % completion + # meaningfully across target-grade bands. + task_defs = (1..num_tasks).map do |t| + task_definition = unit.task_definitions.find_or_initialize_by(abbreviation: "T#{t}") + task_definition.update!( + name: "Task #{t}", + description: "Sample task #{t} for PPI dashboard testing.", + weighting: BigDecimal('1'), + target_grade: 0, + start_date: unit.start_date, + target_date: unit.start_date + t.weeks, + upload_requirements: [{ key: 'file0', name: 'Document', type: 'document' }] + ) + task_definition + end + + seeded_projects = [] + seeded_tasks = [] + + (1..classes_per_unit).each do |class_num| + tutor_username = "ppi_tutor_u#{unit_num}c#{class_num}" + tutor = ppi_find_or_create_user(tutor_username, "Tutor#{unit_num}#{class_num}", 'PPI', Role.tutor_id) + unit.employ_staff(tutor, Role.tutor) + + tutorial_abbrev = "PPI-U#{unit_num}-C#{class_num}" + tutorial_capacity = students_per_grade * grades.length + tutorial = unit.tutorials.find_by(abbreviation: tutorial_abbrev) || unit.add_tutorial( + weekdays[class_num - 1], + '10:00', + "EN1-0#{class_num}", + tutor, + campus, + tutorial_capacity, + tutorial_abbrev + ) + grades.each do |target_grade| + students_per_grade.times do |i| + # Keep the original four-per-grade usernames assigned to their + # existing grade when this task repairs a previously seeded DB. + if i < legacy_students_per_grade + student_index = (target_grade * legacy_students_per_grade) + i + 1 + username = "ppi_u#{unit_num}c#{class_num}s#{student_index.to_s.rjust(2, '0')}" + elsif i < baseline_students_per_grade + legacy_total = grades.length * legacy_students_per_grade + baseline_added_per_grade = baseline_students_per_grade - legacy_students_per_grade + student_index = legacy_total + (target_grade * baseline_added_per_grade) + + (i - legacy_students_per_grade) + 1 + username = "ppi_u#{unit_num}c#{class_num}s#{student_index.to_s.rjust(2, '0')}" + else + student_index = 100 + (target_grade * 100) + i + 1 + username = "ppi_u#{unit_num}c#{class_num}g#{target_grade}s#{(i + 1).to_s.rjust(2, '0')}" + end + student = ppi_find_or_create_user(username, "Student#{student_index}", grade_labels[target_grade], Role.student_id) + + project = unit.enrol_student(student, campus) + project.update!(target_grade: target_grade) + project.enrol_in(tutorial) + seeded_projects << project + + # Populate the full lifecycle on every task/grade cohort. Rotating + # the extra members across tasks keeps the advanced bars varied, + # while ensuring redo and resubmission states are always demoable. + task_defs.each_with_index do |td, td_idx| + task = project.task_for_task_definition(td) + seeded_tasks << task + + cohort_ordinal = ((class_num - 1) * students_per_grade) + i + status_key = PPI_SAMPLE_LIFECYCLE_STATUSES.fetch( + (cohort_ordinal + td_idx + target_grade + unit_num) % + PPI_SAMPLE_LIFECYCLE_STATUSES.length + ) + status = TaskStatus.public_send(status_key) + uploaded = PPI_SAMPLE_UPLOADED_STATUSES.include?(status_key) + submitted_at = uploaded ? Time.zone.now - 1.day : nil + + task.update!( + task_status: status, + file_uploaded_at: submitted_at, + submission_date: submitted_at, + completion_date: + status_key == :complete ? 1.day.ago.to_date : nil + ) + end + + project.update_task_stats + end + end + + repaired_capacity = [tutorial_capacity, tutorial.num_students].max + tutorial.update!(capacity: repaired_capacity) if tutorial.capacity != repaired_capacity + end + + cohort_sizes = grades.index_with do |target_grade| + unit.active_projects.where(target_grade: target_grade).count + end + unless cohort_sizes.values.all? do |size| + size - 1 >= minimum_cohort_size + end + raise "#{unit.code} PPI cohorts are below the configured threshold: #{cohort_sizes.inspect}" + end + + expected_project_count = classes_per_unit * grades.length * students_per_grade + unless unit.active? && unit.peer_progress_enabled? && + seeded_projects.uniq.count == expected_project_count && + seeded_projects.all? { |project| project.enrolled? && project.user.role_id == Role.student_id } + raise "#{unit.code} PPI demo projects are not active student enrolments" + end + + expected_task_count = expected_project_count * task_defs.length + tasks_released = seeded_tasks.uniq.count == expected_task_count && seeded_tasks.all? do |task| + task.local_start_date.present? && + task.local_start_date <= Time.zone.now && + task.task_definition.target_grade <= task.project.target_grade + end + unless task_defs.all? { |task_definition| task_definition.target_grade.zero? } && tasks_released + raise "#{unit.code} PPI demo tasks are not released at the pass target grade" + end + + snapshots = PeerProgressAggregationService.call(unit: unit) + task_definition_ids = task_defs.map(&:id) + demo_snapshots = snapshots.select do |snapshot| + task_definition_ids.include?(snapshot.task_definition_id) && grades.include?(snapshot.target_grade) + end + expected_snapshot_count = task_defs.length * grades.length + latest_grade_changes = grades.index_with do |target_grade| + unit.active_projects.where(target_grade: target_grade).maximum(:target_grade_changed_at) + end + fresh_after = stale_after_hours.hours.ago + + snapshots_valid = demo_snapshots.count == expected_snapshot_count && + demo_snapshots.map { |snapshot| [snapshot.task_definition_id, snapshot.target_grade] }.uniq.count == expected_snapshot_count && + demo_snapshots.all? do |snapshot| + latest_change = latest_grade_changes.fetch(snapshot.target_grade) + snapshot.cohort_size == cohort_sizes.fetch(snapshot.target_grade) && + snapshot.submitted_count.is_a?(Integer) && + !snapshot.submitted_percentage.nil? && + ppi_viewer_vectors_safe?( + unit: unit, + snapshot: snapshot, + minimum_cohort_size: minimum_cohort_size + ) && + snapshot.calculated_at >= fresh_after && + (latest_change.nil? || snapshot.calculated_at >= latest_change) + end + raise "#{unit.code} PPI demo snapshots failed post-seed validation" unless snapshots_valid + + puts "-> #{unit.code}: #{unit.tutorials.count} classes, #{unit.projects.count} students, " \ + "#{task_defs.count} tasks, peer-safe cohorts verified, " \ + "#{demo_snapshots.count} demo snapshots" + end + + puts 'PPI sample dashboard data ready.' + end +end diff --git a/test/lib/demo_data/all_features_scenario_test.rb b/test/lib/demo_data/all_features_scenario_test.rb new file mode 100644 index 0000000000..fd995039e7 --- /dev/null +++ b/test/lib/demo_data/all_features_scenario_test.rb @@ -0,0 +1,505 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' +require Rails.root.join('lib/demo_data/all_features_scenario') + +class AllFeaturesScenarioTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + REFERENCE_TIME = Time.zone.parse('2026-08-24 10:00:00') + + setup do + @scenario = DemoData::AllFeaturesScenario.new( + reference_time: REFERENCE_TIME + ) + @original_profile = ENV.fetch('DF_DEMO_DATA_PROFILE', nil) + @original_minimum_cohort_size = ENV.fetch( + 'DF_PPI_MINIMUM_COHORT_SIZE', + nil + ) + @original_stale_after_hours = ENV.fetch( + 'DF_PPI_STALE_AFTER_HOURS', + nil + ) + clear_auth_header + end + + teardown do + restore_env('DF_DEMO_DATA_PROFILE', @original_profile) + restore_env( + 'DF_PPI_MINIMUM_COHORT_SIZE', + @original_minimum_cohort_size + ) + restore_env('DF_PPI_STALE_AFTER_HOURS', @original_stale_after_hours) + clear_auth_header + end + + test 'hard fails unless every safety guard matches' do + ENV['DF_DEMO_DATA_PROFILE'] = DemoData::AllFeaturesScenario::PROFILE_NAME + + error = assert_raises(DemoData::AllFeaturesScenario::SafetyError) do + @scenario.guard! + end + assert_includes error.message, 'Rails development' + + with_environment('development') do + @scenario.stub(:connected_database_name, 'ordinary-development') do + error = assert_raises(DemoData::AllFeaturesScenario::SafetyError) do + @scenario.guard! + end + assert_includes error.message, + DemoData::AllFeaturesScenario::DATABASE_NAME + end + + @scenario.stub( + :connected_database_name, + DemoData::AllFeaturesScenario::DATABASE_NAME + ) do + ENV.delete('DF_DEMO_DATA_PROFILE') + error = assert_raises(DemoData::AllFeaturesScenario::SafetyError) do + @scenario.guard! + end + assert_includes error.message, 'DF_DEMO_DATA_PROFILE=all-features' + end + end + end + + test 'recreates a complete privacy-safe all-features scenario' do + first_summary = run_scenario_without_delivery! + + assert_equal DemoData::AllFeaturesScenario::PROFILE_NAME, + first_summary.fetch(:profile) + assert_equal DemoData::AllFeaturesScenario::DEMO_USERNAME, + first_summary.fetch(:login) + assert_equal 'password', first_summary.fetch(:password) + + assert_units_and_task_states + assert_ppi_cohort_and_endpoint + assert_notifications_are_curated + assert_group_hook + assert_demo_contract + assert_identities_are_generic + + counts_after_first_run = namespace_counts + second_summary = run_scenario_without_delivery! + + assert_equal counts_after_first_run, namespace_counts + assert_equal first_summary.except(:peer_progress), + second_summary.except(:peer_progress) + assert_equal 60.0, + second_summary.dig(:peer_progress, :submitted_percentage) + assert_equal 10.0, + second_summary.dig(:peer_progress, :completed_percentage) + assert second_summary.dig(:peer_progress, :distribution_available) + assert_equal DemoData::AllFeaturesScenario::NOTIFICATION_COUNT, + demo_student.notifications.count + + assert_contract_follows_changed_demo_data + + with_demo_safety { @scenario.cleanup! } + + assert_empty Unit.where(code: DemoData::AllFeaturesScenario::UNIT_CODES) + assert_empty User.where(username: DemoData::AllFeaturesScenario::USERNAMES) + assert_nil Campus.find_by( + abbreviation: DemoData::AllFeaturesScenario::CAMPUS_ABBREVIATION + ) + end + + private + + def run_scenario_without_delivery! + no_delivery = lambda do |*_args| + raise 'demo scenario must not invoke an external delivery channel' + end + + PushNotificationDeliveryJob.stub(:perform_async, no_delivery) do + NotificationEmailJob.stub(:perform_async, no_delivery) do + with_demo_safety { @scenario.run! } + end + end + end + + def with_demo_safety(&block) + ENV['DF_DEMO_DATA_PROFILE'] = DemoData::AllFeaturesScenario::PROFILE_NAME + with_environment('development') do + @scenario.stub( + :connected_database_name, + DemoData::AllFeaturesScenario::DATABASE_NAME, + &block + ) + end + end + + def with_environment(name, &) + environment = ActiveSupport::EnvironmentInquirer.new(name) + Rails.stub(:env, environment, &) + end + + def assert_units_and_task_states + scenario_units = Unit.where( + code: DemoData::AllFeaturesScenario::UNIT_CODES + ) + assert_equal DemoData::AllFeaturesScenario::UNIT_CODES.sort, + scenario_units.pluck(:code).sort + assert_equal DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES.sort, + scenario_units.where(active: true).pluck(:code).sort + assert_not Unit.find_by!( + code: DemoData::AllFeaturesScenario::PREVIOUS_UNIT_CODE + ).active? + + expected_statuses = { + 'OVERDUE' => :not_started, + 'FUTURE' => :not_started, + 'DUE3' => :working_on_it, + 'WORK' => :working_on_it, + 'DUE7' => :ready_for_feedback, + 'AWAITING' => :ready_for_feedback, + 'RESUBMIT' => :fix_and_resubmit, + 'REDO' => :redo, + 'DONE' => :complete, + 'FAILED' => :fail + } + + DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES.each do |code| + project = demo_student.projects.joins(:unit).find_by!( + units: { code: code } + ) + assert_equal 0, project.target_grade + assert project.enrolled? + assert_equal expected_statuses.keys.sort, + project.tasks.joins(:task_definition) + .pluck('task_definitions.abbreviation').sort + + statuses = project.tasks.includes(:task_definition).to_h do |task| + [task.task_definition.abbreviation, task.status] + end + assert_equal expected_statuses, statuses + assert_equal 6, project.tasks.where.not(file_uploaded_at: nil).count + assert_equal 1, project.tasks.where.not(completion_date: nil).count + assert_not project.unit.send_notifications? + assert project.unit.task_definitions.none?(&:new_task_notifications_from?) + + definitions = project.unit.task_definitions.index_by(&:abbreviation) + assert_equal REFERENCE_TIME.to_date - 1, + definitions.fetch('OVERDUE').target_date.to_date + expected_due3_target = code == 'DEMO10001' ? 2 : 9 + assert_equal REFERENCE_TIME.to_date + expected_due3_target, + definitions.fetch('DUE3').target_date.to_date + assert_equal REFERENCE_TIME.to_date + 6, + definitions.fetch('DUE7').target_date.to_date + assert_operator definitions.fetch('FUTURE').start_date, + :>, + REFERENCE_TIME + end + + recommendation_unit_ids = TaskPrioritizationService + .new(demo_student, today: REFERENCE_TIME.to_date) + .call + .pluck(:unit_id) + .uniq + expected_unit_ids = Unit.where( + code: DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES + ).pluck(:id) + assert_equal expected_unit_ids.sort, recommendation_unit_ids.sort + end + + def assert_ppi_cohort_and_endpoint + unit = Unit.find_by!(code: DemoData::AllFeaturesScenario::PPI_UNIT_CODE) + definition = unit.task_definitions.find_by!( + abbreviation: DemoData::AllFeaturesScenario::PPI_TASK_ABBREVIATION + ) + project = demo_student.projects.find_by!(unit: unit) + snapshot = unit.peer_progress_snapshots.find_by!( + task_definition: definition, + target_grade: 0 + ) + + assert unit.peer_progress_enabled? + assert_equal DemoData::AllFeaturesScenario::COHORT_SIZE, + unit.active_projects.where(target_grade: 0).count + assert_equal DemoData::AllFeaturesScenario::SUBMITTED_COUNT, + unit.tasks.where(task_definition: definition) + .where.not(file_uploaded_at: nil).count + assert_equal DemoData::AllFeaturesScenario::COHORT_SIZE, + snapshot.cohort_size + assert_equal DemoData::AllFeaturesScenario::SUBMITTED_COUNT, + snapshot.submitted_count + assert_equal 64.0, snapshot.submitted_percentage.to_f + expected_status_counts = PeerProgressDistributionPolicy::STATUS_KEYS + .index_with { 0 } + .merge( + DemoData::AllFeaturesScenario::PPI_STATUS_COUNTS + .stringify_keys + ) + assert_equal expected_status_counts, snapshot.status_counts + + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = + PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE.to_s + ENV['DF_PPI_STALE_AFTER_HOURS'] = '48' + clear_auth_header + add_auth_header_for(user: demo_student) + get "/api/projects/#{project.id}/task_def_id/#{definition.id}/peer_progress" + + assert_equal 200, last_response.status, last_response.body + assert_equal 60.0, last_response_body.fetch('submitted_percentage') + assert_equal 10.0, last_response_body.fetch('completed_percentage') + assert_equal true, + last_response_body.fetch('distribution_available') + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + last_response_body.fetch('status_distribution').pluck('status') + assert_equal false, last_response_body.fetch('is_suppressed') + + verification = with_demo_safety { @scenario.verify! } + assert_equal 60.0, verification.fetch(:submitted_percentage) + assert_equal 10.0, verification.fetch(:completed_percentage) + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + verification.fetch(:status_distribution).pluck(:status) + + expected_states = { + 'DEMO10001' => [60.0, 10.0], + 'DEMO20007' => [70.0, 20.0], + 'DEMO30046' => [50.0, 20.0] + } + expected_states.each do |code, (submitted, complete)| + state = with_demo_safety { @scenario.contract_for(user: demo_student) } + .fetch(:units) + .find { |item| item.fetch(:code) == code } + .fetch(:ppi) + assert_equal 'available', state.fetch(:state) + assert_equal submitted, state.fetch(:submitted_percentage) + assert_equal complete, state.fetch(:completed_percentage) + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + state.fetch(:status_distribution).pluck(:status) + end + + contract = with_demo_safety do + @scenario.contract_for(user: demo_student) + end + unavailable = contract.fetch(:units).find do |item| + item.fetch(:code) == + DemoData::MobileFeedbackScenario::PPI_UNAVAILABLE_UNIT_CODE + end.fetch(:ppi) + assert_equal 'unavailable', unavailable.fetch(:state) + assert_equal 'insufficient_cohort', unavailable.fetch(:unavailable_reason) + assert_nil unavailable.fetch(:status_distribution) + end + + def assert_notifications_are_curated + notifications = demo_student.notifications.order(:created_at) + + assert_equal DemoData::AllFeaturesScenario::NOTIFICATION_COUNT, + notifications.count + assert_equal %w[extension feedback portfolio task task task task], + notifications.pluck(:notification_type).sort + assert notifications.all?(&:delivered_at?) + assert(notifications.all? { |notification| notification.link.present? }) + assert(notifications.all? { |notification| notification.dedupe_key.present? }) + assert_equal 4, notifications.where(read_at: nil).count + assert_equal 3, notifications.where.not(read_at: nil).count + assert_equal DemoData::MobileFeedbackScenario::NOTIFICATIONS.pluck(:event).sort, + notifications.pluck(:event).sort + assert_equal notifications.count, notifications.distinct.count + assert_equal 'You have new feedback in OnTrack.', + notifications.find_by!(event: 'task_comment_created').message + assert_equal 0, PushSubscription.joins(:user).where( + users: { username: DemoData::AllFeaturesScenario::USERNAMES } + ).count + + travel_to REFERENCE_TIME do + active_demo_units = Unit.where( + code: DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES, + active: true + ) + + Unit.stub(:where, active_demo_units) do + assert_no_difference('Notification.count') do + SendDueSoonRemindersJob.new.perform + end + end + end + end + + def assert_group_hook + fixture = DemoData::MobileFeedbackScenario::GROUP + unit = Unit.find_by!(code: fixture.fetch(:unit_code)) + group_set = unit.group_sets.find_by!(name: fixture.fetch(:group_set_name)) + group = group_set.groups.find_by!(name: fixture.fetch(:name)) + + assert_equal fixture.fetch(:capacity), group.capacity + assert_equal fixture.fetch(:member_count), group.student_count + assert group.has_user(demo_student) + assert_equal fixture.fetch(:tutorial), group.tutorial.abbreviation + end + + def assert_demo_contract + contract = with_demo_safety do + @scenario.contract_for(user: demo_student) + end + + assert_equal 1, contract.fetch(:schema_version) + assert_equal 'mobile-feedback-v1', contract.fetch(:scenario_id) + assert_equal true, contract.fetch(:demo_only) + assert_equal 10, contract.dig(:task_lifecycle, :total_tasks) + assert_equal 60.0, contract.dig(:task_lifecycle, :submitted_percentage) + assert_equal 10.0, contract.dig(:task_lifecycle, :completed_percentage) + assert_equal( + DemoData::MobileFeedbackScenario::EXPECTED_TASK_STATUS_PERCENTAGES, + contract.fetch(:task_lifecycle).fetch(:statuses).to_h do |entry| + [entry.fetch(:status).to_sym, entry.fetch(:percentage)] + end + ) + assert_equal 7, contract.fetch(:notification_hooks).count + assert_equal 7, + contract.fetch(:notification_hooks).pluck(:event).uniq.count + assert_equal 'Team Indigo', contract.dig(:group_hook, :name) + assert_equal %w[burndown notifications ppi tasks], + contract.fetch(:walkthrough_links).pluck(:key).sort + assert contract.to_json.exclude?('@all-features.invalid') + assert contract.to_json.exclude?('demo_peer_') + + error = assert_raises(DemoData::AllFeaturesScenario::SafetyError) do + with_demo_safety do + @scenario.contract_for(user: User.find_by!(username: DemoData::AllFeaturesScenario::CONVENOR_USERNAME)) + end + end + assert_includes error.message, 'unavailable for this account' + + clear_auth_header + add_auth_header_for(user: demo_student) + DemoData::AllFeaturesScenario.stub(:contract_for, contract) do + get '/api/demo/scenario' + end + assert_equal 200, last_response.status, last_response.body + assert_equal 'private, no-store', last_response.headers['Cache-Control'] + assert_equal 'mobile-feedback-v1', last_response_body.fetch('scenario_id') + + get '/api/demo/scenario' + assert_equal 404, last_response.status + assert_equal({ 'error' => 'Not found.' }, last_response_body) + end + + def assert_identities_are_generic + users = User.where(username: DemoData::AllFeaturesScenario::USERNAMES) + + assert_equal DemoData::AllFeaturesScenario::USERNAMES.length, users.count + assert(users.all? { |user| user.email.end_with?('.invalid') }) + assert(users.all? { |user| user.login_id == user.username }) + assert demo_student.valid_password?('password') + + peers = users.where( + username: DemoData::AllFeaturesScenario::PEER_USERNAMES + ) + assert(peers.all? { |peer| !peer.receive_task_notifications? }) + assert(peers.all? { |peer| !peer.receive_feedback_notifications? }) + assert(peers.all? { |peer| !peer.receive_portfolio_notifications? }) + assert users.all?(&:display_peer_progress?) + end + + def namespace_counts + { + campuses: Campus.where( + abbreviation: DemoData::AllFeaturesScenario::CAMPUS_ABBREVIATION + ).count, + units: Unit.where( + code: DemoData::AllFeaturesScenario::UNIT_CODES + ).count, + users: User.where( + username: DemoData::AllFeaturesScenario::USERNAMES + ).count, + projects: Project.joins(:unit).where( + units: { code: DemoData::AllFeaturesScenario::UNIT_CODES } + ).count, + tasks: Task.joins(project: :unit).where( + units: { code: DemoData::AllFeaturesScenario::UNIT_CODES } + ).count, + notifications: Notification.joins(:user).where( + users: { username: DemoData::AllFeaturesScenario::USERNAMES } + ).count, + group_sets: GroupSet.joins(:unit).where( + units: { code: DemoData::AllFeaturesScenario::UNIT_CODES } + ).count, + groups: Group.joins(group_set: :unit).where( + units: { code: DemoData::AllFeaturesScenario::UNIT_CODES } + ).count, + group_memberships: GroupMembership.joins(group: { group_set: :unit }).where( + units: { code: DemoData::AllFeaturesScenario::UNIT_CODES } + ).count, + push_subscriptions: PushSubscription.joins(:user).where( + users: { username: DemoData::AllFeaturesScenario::USERNAMES } + ).count + } + end + + # Runs after the idempotency checks because it changes seeded rows. + def assert_contract_follows_changed_demo_data + projects = demo_student.projects.includes(:unit).index_by { |project| project.unit.code } + + # A demo project changed after aggregation leaves its snapshot behind. + stale_project = projects.fetch('DEMO20007') + definition = stale_project.unit.task_definitions.find_by!( + abbreviation: DemoData::AllFeaturesScenario::PPI_TASK_ABBREVIATION + ) + snapshot = stale_project.unit.peer_progress_snapshots.find_by!( + task_definition: definition, + target_grade: stale_project.target_grade + ) + stale_project.touch(time: snapshot.calculated_at + 1.minute) + + # Move the failed task to complete without the status side effects. + failed_task = projects.fetch('DEMO10001').tasks.joins(:task_definition) + .find_by!(task_definitions: { abbreviation: 'FAILED' }) + failed_task.update_columns(task_status_id: TaskStatus.complete.id) + + contract = with_demo_safety { @scenario.contract_for(user: demo_student) } + + stale = contract.fetch(:units).find { |item| item.fetch(:code) == 'DEMO20007' }.fetch(:ppi) + assert_equal 'unavailable', stale.fetch(:state) + assert_equal 'aggregation_incomplete', stale.fetch(:unavailable_reason) + assert_nil stale.fetch(:status_distribution) + + lifecycle = contract.fetch(:task_lifecycle) + percentages = lifecycle.fetch(:statuses).to_h do |entry| + [entry.fetch(:status), entry.fetch(:percentage)] + end + assert_equal 20.0, percentages.fetch('complete') + assert_equal 0.0, percentages.fetch('fail') + assert_equal 20.0, lifecycle.fetch(:completed_percentage) + + assert_contract_ignores_unseeded_data(projects) + end + + def assert_contract_ignores_unseeded_data(projects) + seeded_feedback = demo_student.notifications.find_by!(dedupe_key: 'all_features_demo:feedback') + # Tutor comments during the walkthrough raise more notifications with the + # same event. Their keys sort either side of the seeded one, so a lookup by + # event picks the wrong row whichever order the rows come back in. + %w[aa-walkthrough-comment zz-walkthrough-comment].each do |dedupe_key| + FactoryBot.create(:notification, :feedback, user: demo_student, event: 'task_comment_created', dedupe_key: dedupe_key) + end + # A status outside the fixture set. + work_task = projects.fetch('DEMO10001').tasks.joins(:task_definition) + .find_by!(task_definitions: { abbreviation: 'WORK' }) + work_task.update_columns(task_status_id: TaskStatus.need_help.id) + + contract = with_demo_safety { @scenario.contract_for(user: demo_student) } + + feedback_hook = contract.fetch(:notification_hooks).find { |hook| hook.fetch(:key) == 'feedback' } + assert_equal seeded_feedback.id, feedback_hook.fetch(:id) + + lifecycle = contract.fetch(:task_lifecycle) + assert_equal lifecycle.fetch(:total_tasks), lifecycle.fetch(:statuses).sum { |entry| entry.fetch(:count) } + need_help = lifecycle.fetch(:statuses).find { |entry| entry.fetch(:status) == 'need_help' } + assert_equal ['WORK'], need_help.fetch(:task_abbreviations) + end + + def demo_student + User.find_by!(username: DemoData::AllFeaturesScenario::DEMO_USERNAME) + end + + def restore_env(name, value) + value.nil? ? ENV.delete(name) : ENV[name] = value + end +end