diff --git a/Gemfile b/Gemfile index 69ca22f0b..b367b8222 100644 --- a/Gemfile +++ b/Gemfile @@ -102,6 +102,7 @@ gem 'net-smtp', require: false gem 'tca_client' # Async jobs +gem 'ice_cube' gem 'sidekiq' gem 'sidekiq-cron' gem 'sidekiq-status' diff --git a/Gemfile.lock b/Gemfile.lock index 8bc4f19b3..9df7ab4c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -589,6 +589,7 @@ DEPENDENCIES grape-swagger-rails hirb icalendar + ice_cube json-jwt listen minitest diff --git a/app/api/api_root.rb b/app/api/api_root.rb index 4e28e9c2b..c87cfc881 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -80,6 +80,7 @@ class ApiRoot < Grape::API mount SidekiqApi mount LtiApi if Doubtfire::Application.config.lti_enabled mount TaskPrerequisitesApi + mount CommunicationRulesApi mount Tii::TurnItInApi mount Tii::TurnItInHooksApi @@ -135,6 +136,7 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to SidekiqApi AuthenticationHelpers.add_auth_to LtiApi if Doubtfire::Application.config.lti_enabled AuthenticationHelpers.add_auth_to TaskPrerequisitesApi + AuthenticationHelpers.add_auth_to CommunicationRulesApi AuthenticationHelpers.add_auth_to Tii::TurnItInApi AuthenticationHelpers.add_auth_to Tii::TiiGroupAttachmentApi diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb new file mode 100644 index 000000000..2c436311c --- /dev/null +++ b/app/api/communication_rules_api.rb @@ -0,0 +1,767 @@ +require 'grape' +require 'entities/communication_set_entity' +require 'entities/communication_rule_entity' +require 'entities/communication_condition_entity' +require 'entities/communication_action_entity' +require 'entities/communication_set_schedule_entity' +require 'entities/sidekiq_job_entity' + +class CommunicationRulesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers SidekiqHelper + helpers do + def permitted_schedule_params(raw_schedule) + ActionController::Parameters.new(raw_schedule).permit( + :id, + :name, + :active, + :anchor_week, + :anchor_day, + :hour, + :minute, + :timezone, + :recurrence, + :interval, + :repeat_count, + :until_at + ) + end + + def schedule_params_from_request + communication_set_params = params[:communication_set] || params['communication_set'] || {} + communication_set_params[:schedules] || communication_set_params['schedules'] + end + + def sync_set_schedules!(communication_set, raw_schedules) + schedules = Array(raw_schedules).map { |schedule| permitted_schedule_params(schedule).to_h } + + keep_ids = schedules.filter_map { |schedule| schedule['id'] || schedule[:id] } + + communication_set.transaction do + communication_set.communication_set_schedules.where.not(id: keep_ids).destroy_all + + schedules.each do |schedule_attrs| + schedule_id = schedule_attrs.delete('id') || schedule_attrs.delete(:id) + + if schedule_id.present? + communication_set.communication_set_schedules.find(schedule_id).update!(schedule_attrs) + else + communication_set.communication_set_schedules.create!(schedule_attrs) + end + end + end + end + end + + before do + authenticated? + end + + desc 'Get communication sets for a unit' + params do + requires :unit_id, type: Integer + end + get '/units/:unit_id/communication_sets' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present unit.communication_sets.includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions]), + with: Entities::CommunicationSetEntity + end + + desc 'Get a communication set for a unit with preview data' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + communication_set = unit.communication_sets + .includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions]) + .find(params[:id]) + + previews = communication_set.preview_allocations_by_rule + + present( + id: communication_set.id, + unit_id: communication_set.unit_id, + name: communication_set.name, + active: communication_set.active, + schedules: Entities::CommunicationSetScheduleEntity.represent(communication_set.communication_set_schedules), + rules: Entities::CommunicationRuleEntity.represent(communication_set.communication_rules), + previews: communication_set.communication_rules.map do |rule| + { + target_rule_id: rule.id, + allocations: previews.fetch(rule.id, []).map do |allocation| + { + rule_id: allocation[:rule].id, + rule_name: allocation[:rule].name, + position: allocation[:rule].position, + students: allocation[:projects].map do |project| + { + first_name: project.user&.first_name, + last_name: project.user&.last_name, + preferred_name: project.user&.nickname, + username: project.user&.username, + student_id: project.user&.student_id, + full_name: [project.user&.first_name, project.user&.last_name].compact.join(' '), + target_grade: project.target_grade, + last_sign_in_at: project.user&.last_sign_in_at, + campus: project.campus&.name + } + end + } + end + } + end + ) + end + + desc 'Create a communication set for a unit' + params do + requires :unit_id, type: Integer + requires :communication_set, type: Hash do + requires :name, type: String + optional :active, type: Boolean + optional :schedules, type: Array do + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + end + post '/units/:unit_id/communication_sets' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + set_params = ActionController::Parameters.new(params) + .require(:communication_set) + .permit(:name, :active) + + communication_set = unit.communication_sets.create!(set_params) + sync_set_schedules!(communication_set, schedule_params_from_request) + communication_set.reload + present communication_set, with: Entities::CommunicationSetEntity + end + + desc 'Update a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + requires :communication_set, type: Hash do + optional :name, type: String + optional :active, type: Boolean + optional :schedules, type: Array do + optional :id, type: Integer + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + end + put '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:id]) + set_params = ActionController::Parameters.new(params) + .require(:communication_set) + .permit(:name, :active) + + communication_set.update!(set_params) + sync_set_schedules!(communication_set, schedule_params_from_request) if schedule_params_from_request.present? || (params[:communication_set] || params['communication_set'] || {}).key?(:schedules) || (params[:communication_set] || params['communication_set'] || {}).key?('schedules') + communication_set.reload + present communication_set, with: Entities::CommunicationSetEntity + end + + desc 'Delete a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + unit.communication_sets.find(params[:id]).destroy! + status 204 + end + + desc 'Execute a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_sets/:id/execute' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to execute unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:id]) + job_id = ExecuteCommunicationSetJob.perform_async(communication_set.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Get schedules for a communication set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/schedules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + present communication_set.communication_set_schedules.order(:id), + with: Entities::CommunicationSetScheduleEntity + end + + desc 'Get a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule = communication_set.communication_set_schedules.find(params[:id]) + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Create a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :communication_set_schedule, type: Hash do + requires :name, type: String + optional :active, type: Boolean + requires :anchor_week, type: Integer + requires :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + post '/units/:unit_id/communication_sets/:communication_set_id/schedules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule_params = permitted_schedule_params(params[:communication_set_schedule]).to_h + schedule = communication_set.communication_set_schedules.create!(schedule_params) + schedule.reload + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Update a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + requires :communication_set_schedule, type: Hash do + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + put '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule = communication_set.communication_set_schedules.find(params[:id]) + schedule_params = permitted_schedule_params(params[:communication_set_schedule]).to_h + schedule.update!(schedule_params) + schedule.reload + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Delete a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + communication_set.communication_set_schedules.find(params[:id]).destroy! + status 204 + end + + desc 'Get communication rules for a unit' + params do + requires :unit_id, type: Integer + end + get '/units/:unit_id/communication_rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present unit.communication_rules.includes(:communication_conditions, :communication_actions), + with: Entities::CommunicationRuleEntity + end + + desc 'Get communication rules for a set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + + present communication_set.communication_rules.includes(:communication_conditions, :communication_actions), + with: Entities::CommunicationRuleEntity + end + + desc 'Create a communication rule for a communication set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :communication_rule, type: Hash do + requires :name, type: String + requires :operator, type: String + optional :position, type: Integer + optional :active, type: Boolean + optional :send_log_to_convenors, type: Boolean + end + end + post '/units/:unit_id/communication_sets/:communication_set_id/rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + rule_params = ActionController::Parameters.new(params) + .require(:communication_rule) + .permit(:name, :operator, :position, :active, :send_log_to_convenors) + + rule_params[:position] = communication_set.communication_rules.count if rule_params[:position].nil? + rule = communication_set.communication_rules.create!(rule_params) + present rule, with: Entities::CommunicationRuleEntity + end + + desc 'Update a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + requires :communication_rule, type: Hash do + optional :name, type: String + optional :operator, type: String + optional :position, type: Integer + optional :active, type: Boolean + optional :send_log_to_convenors, type: Boolean + end + end + put '/units/:unit_id/communication_rules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + rule_params = ActionController::Parameters.new(params) + .require(:communication_rule) + .permit(:name, :operator, :position, :active, :send_log_to_convenors) + + rule.update!(rule_params) + present rule, with: Entities::CommunicationRuleEntity + end + + desc 'Execute a communication rule within its communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_rules/:id/execute' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to execute unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + job_id = ExecuteCommunicationSetJob.perform_async(rule.communication_set_id, rule.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Preview projects matched by a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_rules/:id/preview' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to preview unit communications' }, 403) + end + + # rule = unit.communication_rules.find(params[:id]) + # job_id = CommunicationRuleJob.perform_async(rule.id) + # job = setup_job(job_id) + + # present job, with: Entities::SidekiqJobEntity + # rule = unit.communication_rules.find(params[:id]) + + rule = unit.communication_rules.find(params[:id]) + allocations = rule.communication_set.preview_allocations_for_rule(rule) + + present( + target_rule_id: rule.id, + allocations: allocations.map do |allocation| + { + rule_id: allocation[:rule].id, + rule_name: allocation[:rule].name, + position: allocation[:rule].position, + students: allocation[:projects].map do |project| + { + first_name: project.user&.first_name, + last_name: project.user&.last_name, + preferred_name: project.user&.nickname, + username: project.user&.username, + student_id: project.user&.student_id, + full_name: [project.user&.first_name, project.user&.last_name].compact.join(' '), + target_grade: project.target_grade, + last_sign_in_at: project.user&.last_sign_in_at, + campus: project.campus&.name + } + end + } + end + ) + end + + desc 'Get communication conditions for a rule' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + end + get '/units/:unit_id/communication_rules/:communication_rule_id/conditions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + + present rule.communication_conditions, with: Entities::CommunicationConditionEntity + end + + desc 'Delete a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + rule.destroy! + status 204 + end + + desc 'Create a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :communication_condition, type: Hash do + requires :type, type: String + requires :operator, type: String + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + optional :task_statuses, type: Array[String] + optional :task_status_count, type: Integer + optional :task_target_grade, type: Integer + optional :last_sign_in_at, type: DateTime + optional :tutorial_id, type: Integer + optional :tutorial_stream_id, type: Integer + optional :campus_id, type: Integer + end + end + post '/units/:unit_id/communication_rules/:communication_rule_id/conditions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + raw_condition_params = params[:communication_condition] + condition_params = { + type: raw_condition_params[:type], + operator: raw_condition_params[:operator], + target_grade: raw_condition_params[:target_grade], + task_definition_id: raw_condition_params[:task_definition_id], + task_status_count: raw_condition_params[:task_status_count], + task_target_grade: raw_condition_params[:task_target_grade], + last_sign_in_at: raw_condition_params[:last_sign_in_at], + tutorial_id: raw_condition_params[:tutorial_id], + tutorial_stream_id: raw_condition_params[:tutorial_stream_id], + campus_id: raw_condition_params[:campus_id] + }.compact + + task_statuses = raw_condition_params[:task_statuses] + condition_params[:task_statuses] = Array(task_statuses) unless task_statuses.nil? + + condition = rule.communication_conditions.create!(condition_params) + present condition, with: Entities::CommunicationConditionEntity + end + + desc 'Update a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + requires :communication_condition, type: Hash do + optional :type, type: String + optional :operator, type: String + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + optional :task_statuses, type: Array[String] + optional :task_status_count, type: Integer + optional :task_target_grade, type: Integer + optional :last_sign_in_at, type: DateTime + optional :tutorial_id, type: Integer + optional :tutorial_stream_id, type: Integer + optional :campus_id, type: Integer + end + end + put '/units/:unit_id/communication_rules/:communication_rule_id/conditions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + condition = rule.communication_conditions.find(params[:id]) + raw_condition_params = params[:communication_condition] + condition_params = { + type: raw_condition_params[:type], + operator: raw_condition_params[:operator], + target_grade: raw_condition_params[:target_grade], + task_definition_id: raw_condition_params[:task_definition_id], + task_status_count: raw_condition_params[:task_status_count], + task_target_grade: raw_condition_params[:task_target_grade], + last_sign_in_at: raw_condition_params[:last_sign_in_at], + tutorial_id: raw_condition_params[:tutorial_id], + tutorial_stream_id: raw_condition_params[:tutorial_stream_id], + campus_id: raw_condition_params[:campus_id] + }.compact + + task_statuses = raw_condition_params[:task_statuses] + condition_params[:task_statuses] = Array(task_statuses) unless task_statuses.nil? + + condition.update!(condition_params) + present condition, with: Entities::CommunicationConditionEntity + end + + desc 'Get communication actions for a rule' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + end + get '/units/:unit_id/communication_rules/:communication_rule_id/actions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + + present rule.communication_actions, with: Entities::CommunicationActionEntity + end + + desc 'Delete a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:communication_rule_id/conditions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + rule.communication_conditions.find(params[:id]).destroy! + status 204 + end + + desc 'Create a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :communication_action, type: Hash do + requires :type, type: String + optional :subject, type: String + optional :body, type: String + optional :email_tutors, type: Boolean + optional :email_convenors, type: Boolean + optional :target_grade, type: Integer + end + end + post '/units/:unit_id/communication_rules/:communication_rule_id/actions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + action_params = ActionController::Parameters.new(params) + .require(:communication_action) + .permit( + :type, + :subject, + :body, + :email_tutors, + :email_convenors, + :target_grade + ) + + action = rule.communication_actions.create!(action_params) + present action, with: Entities::CommunicationActionEntity + end + + desc 'Update a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + requires :communication_action, type: Hash do + optional :type, type: String + optional :subject, type: String + optional :body, type: String + optional :email_tutors, type: Boolean + optional :email_convenors, type: Boolean + optional :target_grade, type: Integer + end + end + put '/units/:unit_id/communication_rules/:communication_rule_id/actions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + action = rule.communication_actions.find(params[:id]) + action_params = ActionController::Parameters.new(params) + .require(:communication_action) + .permit(:type, :subject, :body, :email_tutors, :email_convenors, :target_grade) + + action.update!(action_params) + present action, with: Entities::CommunicationActionEntity + end + + desc 'Delete a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:communication_rule_id/actions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + rule.communication_actions.find(params[:id]).destroy! + status 204 + end +end diff --git a/app/api/entities/communication_action_entity.rb b/app/api/entities/communication_action_entity.rb new file mode 100644 index 000000000..5786e1888 --- /dev/null +++ b/app/api/entities/communication_action_entity.rb @@ -0,0 +1,12 @@ +module Entities + class CommunicationActionEntity < Grape::Entity + expose :id + expose :type + expose :communication_rule_id + expose :subject + expose :body + expose :email_tutors + expose :email_convenors + expose :target_grade + end +end diff --git a/app/api/entities/communication_condition_entity.rb b/app/api/entities/communication_condition_entity.rb new file mode 100644 index 000000000..8cde0c9ab --- /dev/null +++ b/app/api/entities/communication_condition_entity.rb @@ -0,0 +1,17 @@ +module Entities + class CommunicationConditionEntity < Grape::Entity + expose :id + expose :type + expose :communication_id, as: :communication_rule_id + expose :operator + expose :target_grade + expose :task_definition_id + expose :task_statuses + expose :task_status_count + expose :task_target_grade + expose :last_sign_in_at + expose :tutorial_id + expose :tutorial_stream_id + expose :campus_id + end +end diff --git a/app/api/entities/communication_rule_entity.rb b/app/api/entities/communication_rule_entity.rb new file mode 100644 index 000000000..97f56da5a --- /dev/null +++ b/app/api/entities/communication_rule_entity.rb @@ -0,0 +1,17 @@ +module Entities + class CommunicationRuleEntity < Grape::Entity + expose :id + expose :communication_set_id + expose :name + expose :operator + expose :position + expose :active + expose :send_log_to_convenors + expose :communication_conditions, + as: :conditions, + using: Entities::CommunicationConditionEntity + expose :communication_actions, + as: :actions, + using: Entities::CommunicationActionEntity + end +end diff --git a/app/api/entities/communication_set_entity.rb b/app/api/entities/communication_set_entity.rb new file mode 100644 index 000000000..a9b6029b9 --- /dev/null +++ b/app/api/entities/communication_set_entity.rb @@ -0,0 +1,17 @@ +require 'entities/communication_rule_entity' +require 'entities/communication_set_schedule_entity' + +module Entities + class CommunicationSetEntity < Grape::Entity + expose :id + expose :unit_id + expose :name + expose :active + expose :communication_set_schedules, + as: :schedules, + using: Entities::CommunicationSetScheduleEntity + expose :communication_rules, + as: :rules, + using: Entities::CommunicationRuleEntity + end +end diff --git a/app/api/entities/communication_set_schedule_entity.rb b/app/api/entities/communication_set_schedule_entity.rb new file mode 100644 index 000000000..1c1573b02 --- /dev/null +++ b/app/api/entities/communication_set_schedule_entity.rb @@ -0,0 +1,21 @@ +module Entities + class CommunicationSetScheduleEntity < Grape::Entity + expose :id + expose :communication_set_id + expose :name + expose :active + expose :anchor_week + expose :anchor_day + expose :hour + expose :minute + expose :timezone + expose :recurrence + expose :interval + expose :repeat_count + expose :until_at + expose :ice_cube_schedule + expose :next_run_at + expose :last_run_at + expose :last_enqueued_at + end +end diff --git a/app/mailers/communications_mailer.rb b/app/mailers/communications_mailer.rb new file mode 100644 index 000000000..95034342d --- /dev/null +++ b/app/mailers/communications_mailer.rb @@ -0,0 +1,37 @@ +class CommunicationsMailer < ApplicationMailer + def communication_email(to:, from:, subject:, body:, recipient:, sender:, unit:, rule:) + @recipient = recipient + @sender = sender + @unit = unit + @rule = rule + @body = body.to_s + @body_paragraphs = @body.split(/\r?\n/).map(&:strip).reject(&:blank?) + + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + + mail(to: to, from: from, subject: subject) + end + + def action_log_email(payload) + @recipient = payload[:recipient] + @sender = payload[:sender] + @unit = payload[:unit] + @rule = payload[:rule] + @body = payload[:body].to_s + @body_paragraphs = @body.split(/\r?\n/).map(&:strip).reject(&:blank?) + @affected_students_count = payload[:affected_students_count] + + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + + attachments[payload[:csv_filename]] = { + mime_type: 'text/csv', + content: payload[:csv_content] + } + + mail(to: payload[:to], from: payload[:from], subject: payload[:subject]) + end +end diff --git a/app/models/break.rb b/app/models/break.rb index f857fd12b..ed1c76b1a 100644 --- a/app/models/break.rb +++ b/app/models/break.rb @@ -1,6 +1,9 @@ class Break < ApplicationRecord belongs_to :teaching_period, optional: false + after_save :refresh_teaching_period_communication_schedule_caches + after_destroy :refresh_teaching_period_communication_schedule_caches + validates :start_date, presence: true validates :number_of_weeks, presence: true validates :teaching_period_id, presence: true @@ -46,4 +49,10 @@ def monday_after_break def end_date start_date + duration end + + private + + def refresh_teaching_period_communication_schedule_caches + teaching_period.refresh_communication_schedule_caches + end end diff --git a/app/models/communication/campus_condition.rb b/app/models/communication/campus_condition.rb new file mode 100644 index 000000000..07e2bc009 --- /dev/null +++ b/app/models/communication/campus_condition.rb @@ -0,0 +1,4 @@ +class CampusCondition < CommunicationCondition + validates :campus, presence: true + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/change_target_grade_action.rb b/app/models/communication/change_target_grade_action.rb new file mode 100644 index 000000000..07913b2d0 --- /dev/null +++ b/app/models/communication/change_target_grade_action.rb @@ -0,0 +1,3 @@ +class ChangeTargetGradeAction < CommunicationAction + validates :target_grade, presence: true +end diff --git a/app/models/communication/communication_action.rb b/app/models/communication/communication_action.rb new file mode 100644 index 000000000..1c6c924aa --- /dev/null +++ b/app/models/communication/communication_action.rb @@ -0,0 +1,11 @@ +class CommunicationAction < ApplicationRecord + VALID_TYPES = %w[ + EmailStudentAction + EmailStaffAction + ChangeTargetGradeAction + ].freeze + + belongs_to :communication_rule, class_name: 'CommunicationRule' + + validates :type, presence: true, inclusion: { in: VALID_TYPES } +end diff --git a/app/models/communication/communication_condition.rb b/app/models/communication/communication_condition.rb new file mode 100644 index 000000000..bbe510cbe --- /dev/null +++ b/app/models/communication/communication_condition.rb @@ -0,0 +1,92 @@ +class CommunicationCondition < ApplicationRecord + VALID_TYPES = %w[ + TargetGradeCondition + TaskDefinitionStatusCondition + TaskStatusCountCondition + LoginStatusCondition + TutorialEnrolmentCondition + TutorialStreamEnrolmentCondition + CampusCondition + ].freeze + + GRADE_OPERATORS = %w[ + greater_than + greater_than_or_equal_to + less_than + less_than_or_equal_to + equal_to + not_equal_to + ].freeze + + EQUALITY_OPERATORS = %w[equal_to not_equal_to].freeze + DATE_OPERATORS = %w[before after].freeze + ENROLMENT_OPERATORS = %w[enrolled_in not_enrolled_in].freeze + TASK_STATUS_KEYS = %w[ + not_started + complete + need_help + working_on_it + fix_and_resubmit + feedback_exceeded + redo + discuss + ready_for_feedback + demonstrate + fail + time_exceeded + assess_in_portfolio + attention_required + ].freeze + + belongs_to :communication, + class_name: 'CommunicationRule', + inverse_of: :communication_conditions + + belongs_to :task_definition, optional: true + belongs_to :tutorial, optional: true + belongs_to :tutorial_stream, optional: true + belongs_to :campus, optional: true + + attribute :task_statuses, :json, default: -> { [] } + + validates :type, presence: true, inclusion: { in: VALID_TYPES } + validates :operator, presence: true + before_validation :normalize_task_statuses + + def task_statuses_must_be_present + unless task_statuses.is_a?(Array) && task_statuses.any?(&:present?) + errors.add(:task_statuses, 'must include at least one task status') + return + end + + invalid_statuses = task_statuses.reject { |status| TASK_STATUS_KEYS.include?(status) } + return if invalid_statuses.empty? + + errors.add(:task_statuses, "contains invalid task statuses: #{invalid_statuses.join(', ')}") + end + + private + + def normalize_task_statuses + parsed_statuses = + case task_statuses + when nil + nil + when String + begin + JSON.parse(task_statuses) + rescue JSON::ParserError + [task_statuses] + end + when Array + task_statuses + else + Array(task_statuses) + end + + self.task_statuses = + parsed_statuses&.filter_map do |status| + status.is_a?(String) ? status.strip.presence : status.presence + end + end +end diff --git a/app/models/communication/communication_rule.rb b/app/models/communication/communication_rule.rb new file mode 100644 index 000000000..781439314 --- /dev/null +++ b/app/models/communication/communication_rule.rb @@ -0,0 +1,128 @@ +class CommunicationRule < ApplicationRecord + LOGICAL_OPERATORS = %w[and or].freeze + + belongs_to :communication_set, class_name: 'CommunicationSet' + delegate :unit, to: :communication_set + + has_many :communication_conditions, + class_name: 'CommunicationCondition', + foreign_key: :communication_id, + inverse_of: :communication, + dependent: :destroy + has_many :communication_actions, class_name: 'CommunicationAction', dependent: :destroy + + validates :name, presence: true + validates :operator, presence: true, inclusion: { in: LOGICAL_OPERATORS } + validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + def matching_projects(projects = nil) + projects ||= communication_set.eligible_projects + return projects if communication_conditions.empty? + + projects.select do |project| + matches = communication_conditions.map { |condition| condition_match?(project, condition) } + + operator == 'or' ? matches.any? : matches.all? + end + end + + private + + def condition_match?(project, condition) + case condition.type + when 'TargetGradeCondition' + target_grade_condition_match?(project, condition) + when 'TaskDefinitionStatusCondition' + task_definition_status_condition_match?(project, condition) + when 'TaskStatusCountCondition' + task_status_count_condition_match?(project, condition) + when 'LoginStatusCondition' + login_status_condition_match?(project, condition) + when 'TutorialEnrolmentCondition' + tutorial_enrolment_condition_match?(project, condition) + when 'TutorialStreamEnrolmentCondition' + tutorial_stream_enrolment_condition_match?(project, condition) + when 'CampusCondition' + campus_condition_match?(project, condition) + else + false + end + end + + def target_grade_condition_match?(project, condition) + project_target_grade = project.target_grade + return false if project_target_grade.nil? + + compare_value(project_target_grade, condition.target_grade, condition.operator) + end + + def task_definition_status_condition_match?(project, condition) + task = project.tasks.find { |t| t.task_definition_id == condition.task_definition_id } + status = task&.task_status&.status_key&.to_s || 'not_started' + statuses = condition.task_statuses || [] + + case condition.operator + when 'equal_to' then statuses.include?(status) + when 'not_equal_to' then !statuses.include?(status) + else false + end + end + + def task_status_count_condition_match?(project, condition) + statuses = condition.task_statuses || [] + relevant_task_definitions = project.unit.task_definitions.select do |task_definition| + task_definition.target_grade == condition.task_target_grade + end + + count = relevant_task_definitions.count do |task_definition| + task = project.tasks.find { |project_task| project_task.task_definition_id == task_definition.id } + task_status = task&.task_status&.status_key&.to_s || 'not_started' + + statuses.include?(task_status) + end + + compare_value(count, condition.task_status_count, condition.operator) + end + + def login_status_condition_match?(project, condition) + last_sign_in_at = project.user&.last_sign_in_at + + case condition.operator + when 'before' then last_sign_in_at.present? && last_sign_in_at < condition.last_sign_in_at + when 'after' then last_sign_in_at.present? && last_sign_in_at > condition.last_sign_in_at + else false + end + end + + def tutorial_enrolment_condition_match?(project, condition) + enrolled = project.tutorial_enrolments.any? { |enrolment| enrolment.tutorial_id == condition.tutorial_id } + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def tutorial_stream_enrolment_condition_match?(project, condition) + enrolled = project.tutorial_enrolments.any? do |enrolment| + enrolment.tutorial&.tutorial_stream_id == condition.tutorial_stream_id + end + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def campus_condition_match?(project, condition) + enrolled = project.campus_id == condition.campus_id + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def compare_value(left, right, operator) + case operator + when 'greater_than' then left > right + when 'greater_than_or_equal_to' then left >= right + when 'less_than' then left < right + when 'less_than_or_equal_to' then left <= right + when 'equal_to' then left == right + when 'not_equal_to' then left != right + else false + end + end +end diff --git a/app/models/communication/communication_set.rb b/app/models/communication/communication_set.rb new file mode 100644 index 000000000..d59e31240 --- /dev/null +++ b/app/models/communication/communication_set.rb @@ -0,0 +1,112 @@ +class CommunicationSet < ApplicationRecord + belongs_to :unit + + has_many :communication_set_schedules, + class_name: 'CommunicationSetSchedule', + inverse_of: :communication_set, + dependent: :destroy + + has_many :communication_rules, + -> { order(:position) }, + class_name: 'CommunicationRule', + inverse_of: :communication_set, + dependent: :destroy + + validates :name, presence: true + + def eligible_projects + unit.projects + .where(enrolled: true) + .includes(:user, :campus, { tasks: [:task_status, :task_definition] }, { tutorial_enrolments: :tutorial }) + .to_a + end + + def preview_projects_for_rule(target_rule) + preview_allocations_for_rule(target_rule) + .find { |allocation| allocation[:rule].id == target_rule.id } + &.fetch(:projects, []) || [] + end + + def preview_allocations_by_rule + communication_rules.each_with_object({}) do |rule, allocations_by_rule| + allocations_by_rule[rule.id] = preview_allocations_for_rule(rule) + end + end + + def preview_allocations_for_rule(target_rule) + remaining_projects = eligible_projects + allocations = [] + + communication_rules.each do |rule| + matched_projects = rule.matching_projects(remaining_projects) + allocations << { rule: rule, projects: matched_projects } + return allocations if rule.id == target_rule.id + + remaining_projects -= matched_projects + end + + allocations + end + + def copy_to(other_unit) + new_set = dup + new_set.unit = other_unit + new_set.save! + + communication_set_schedules.each do |schedule| + new_schedule = schedule.dup + new_schedule.communication_set = new_set + new_schedule.ice_cube_schedule = nil + new_schedule.next_run_at = nil + new_schedule.last_run_at = nil + new_schedule.last_enqueued_at = nil + new_schedule.save! + end + + communication_rules.each do |rule| + new_rule = rule.dup + new_rule.communication_set = new_set + new_rule.save! + + rule.communication_conditions.each do |condition| + new_condition = condition.dup + new_condition.communication = new_rule + new_condition.task_definition = matching_task_definition(other_unit, condition) + new_condition.tutorial_stream = matching_tutorial_stream(other_unit, condition) + new_condition.tutorial = matching_tutorial(other_unit, condition) + new_condition.save! + end + + rule.communication_actions.each do |action| + new_action = action.dup + new_action.communication_rule = new_rule + new_action.save! + end + end + + new_set + end + + private + + def matching_task_definition(unit, condition) + return nil if condition.task_definition.blank? + + unit.task_definitions.find_by(abbreviation: condition.task_definition.abbreviation) + end + + def matching_tutorial_stream(unit, condition) + return nil if condition.tutorial_stream.blank? + + unit.tutorial_streams.find_by(abbreviation: condition.tutorial_stream.abbreviation) + end + + def matching_tutorial(unit, condition) + return nil if condition.tutorial.blank? + + unit.tutorials.find_by( + abbreviation: condition.tutorial.abbreviation, + campus_id: condition.tutorial.campus_id + ) + end +end diff --git a/app/models/communication/communication_set_schedule.rb b/app/models/communication/communication_set_schedule.rb new file mode 100644 index 000000000..26f0a4162 --- /dev/null +++ b/app/models/communication/communication_set_schedule.rb @@ -0,0 +1,175 @@ +class CommunicationSetSchedule < ApplicationRecord + RECURRENCES = %w[none daily weekly monthly].freeze + VALID_DAYS = Date::DAYNAMES.freeze + DAY_ABBREVIATIONS = Date::ABBR_DAYNAMES.freeze + + belongs_to :communication_set, class_name: 'CommunicationSet', inverse_of: :communication_set_schedules + delegate :unit, to: :communication_set + + validates :name, presence: true + validates :anchor_week, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :anchor_day, presence: true + validates :hour, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 24 } + validates :minute, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 60 } + validates :interval, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :recurrence, presence: true, inclusion: { in: RECURRENCES } + validates :repeat_count, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :active, inclusion: { in: [true, false] } + validate :anchor_day_supported + validate :timezone_supported + validate :anchor_date_resolves + + before_validation :normalize_anchor_day + before_validation :default_timezone + before_validation :sync_schedule_cache + + scope :active, -> { where(active: true) } + scope :due, ->(time = Time.zone.now) { where('next_run_at IS NOT NULL AND next_run_at <= ?', time) } + scope :with_active_unit, -> { joins(communication_set: :unit).where(units: { active: true }) } + + def resolved_anchor_date + return nil if unit.blank? || anchor_week.blank? || anchor_day.blank? + + unit.date_for_week_and_day(anchor_week, anchor_day_abbreviation) + end + + def resolved_start_at + date = resolved_anchor_date + return nil if date.nil? + + timezone_object.local(date.year, date.month, date.day, hour, minute) + end + + def build_ice_cube_schedule + start_at = resolved_start_at + return nil if start_at.nil? + + schedule = IceCube::Schedule.new(start_at) + rule = recurrence_rule + schedule.add_recurrence_rule(rule) if rule + schedule + end + + def next_occurrence_after(time = Time.zone.now) + schedule = build_ice_cube_schedule + return nil if schedule.nil? + + time_for_schedule = time.in_time_zone(timezone_object) + occurrence = if recurrence == 'none' + occurrence = schedule.start_time + occurrence >= time_for_schedule ? occurrence : nil + else + schedule.next_occurrence(time_for_schedule) + end + + return nil if occurrence.blank? + + occurrence_within_unit_dates?(occurrence) ? occurrence : nil + end + + def due?(time = Time.zone.now) + active_for_scheduling? && next_run_at.present? && next_run_at <= time + end + + def refresh_next_run_at!(from_time = Time.zone.now) + update!(next_run_at: active_for_scheduling? ? next_occurrence_after(from_time) : nil) + end + + def anchor_day_abbreviation + return nil if anchor_day.blank? + + day = anchor_day.to_s.strip + return day if DAY_ABBREVIATIONS.include?(day) + + index = VALID_DAYS.index(day.titlecase) + index.nil? ? nil : DAY_ABBREVIATIONS[index] + end + + private + + def recurrence_rule + case recurrence + when 'daily' + IceCube::Rule.daily(interval) + when 'weekly' + IceCube::Rule.weekly(interval) + when 'monthly' + IceCube::Rule.monthly(interval) + end&.tap do |rule| + rule.count(repeat_count) if repeat_count.present? + rule.until(until_at.in_time_zone(timezone_object)) if until_at.present? + end + end + + def timezone_object + ActiveSupport::TimeZone[timezone.presence || Time.zone.name] || Time.zone + end + + def normalize_anchor_day + return if anchor_day.blank? + + full_day = + if VALID_DAYS.include?(anchor_day.to_s.titlecase) + anchor_day.to_s.titlecase + else + day_index = DAY_ABBREVIATIONS.index(anchor_day.to_s.titlecase) + day_index.nil? ? anchor_day : VALID_DAYS[day_index] + end + + self.anchor_day = full_day + end + + def default_timezone + self.timezone = timezone.presence || Time.zone.name + end + + def sync_schedule_cache + schedule = build_ice_cube_schedule + self.ice_cube_schedule = schedule.present? ? JSON.generate(schedule.to_hash) : nil + self.next_run_at = next_occurrence_after(Time.zone.now) if active_for_scheduling? && should_refresh_next_run_at? + self.next_run_at = nil unless active_for_scheduling? + end + + def active_for_scheduling? + active? && unit&.active? + end + + def occurrence_within_unit_dates?(occurrence) + return true if unit&.end_date.blank? + + occurrence.to_date <= unit.end_date + end + + def should_refresh_next_run_at? + will_save_change_to_anchor_week? || + will_save_change_to_anchor_day? || + will_save_change_to_hour? || + will_save_change_to_minute? || + will_save_change_to_timezone? || + will_save_change_to_recurrence? || + will_save_change_to_interval? || + will_save_change_to_repeat_count? || + will_save_change_to_until_at? || + will_save_change_to_active? || + next_run_at.blank? + end + + def anchor_day_supported + return if anchor_day.blank? || VALID_DAYS.include?(anchor_day.to_s.titlecase) + + errors.add(:anchor_day, 'must be a valid day name') + end + + def timezone_supported + return if timezone.blank? || ActiveSupport::TimeZone[timezone].present? + + errors.add(:timezone, 'must be a valid timezone') + end + + def anchor_date_resolves + return if unit.blank? || anchor_week.blank? || anchor_day.blank? + return if resolved_anchor_date.present? + + errors.add(:base, 'schedule anchor could not be resolved for this unit') + end +end diff --git a/app/models/communication/email_staff_action.rb b/app/models/communication/email_staff_action.rb new file mode 100644 index 000000000..fc1c2d36f --- /dev/null +++ b/app/models/communication/email_staff_action.rb @@ -0,0 +1,15 @@ +class EmailStaffAction < CommunicationAction + validates :subject, presence: true + validates :body, presence: true + validates :email_tutors, inclusion: { in: [true, false] } + validates :email_convenors, inclusion: { in: [true, false] } + validate :staff_recipient? + + private + + def staff_recipient? + return if email_tutors || email_convenors + + errors.add(:base, 'must email tutors or convenors') + end +end diff --git a/app/models/communication/email_student_action.rb b/app/models/communication/email_student_action.rb new file mode 100644 index 000000000..fdd0ecb17 --- /dev/null +++ b/app/models/communication/email_student_action.rb @@ -0,0 +1,4 @@ +class EmailStudentAction < CommunicationAction + validates :subject, presence: true + validates :body, presence: true +end diff --git a/app/models/communication/login_status_condition.rb b/app/models/communication/login_status_condition.rb new file mode 100644 index 000000000..c972c76fd --- /dev/null +++ b/app/models/communication/login_status_condition.rb @@ -0,0 +1,4 @@ +class LoginStatusCondition < CommunicationCondition + validates :last_sign_in_at, presence: true + validates :operator, inclusion: { in: DATE_OPERATORS } +end diff --git a/app/models/communication/target_grade_condition.rb b/app/models/communication/target_grade_condition.rb new file mode 100644 index 000000000..7f0c30f56 --- /dev/null +++ b/app/models/communication/target_grade_condition.rb @@ -0,0 +1,4 @@ +class TargetGradeCondition < CommunicationCondition + validates :target_grade, presence: true + validates :operator, inclusion: { in: GRADE_OPERATORS } +end diff --git a/app/models/communication/task_definition_status_condition.rb b/app/models/communication/task_definition_status_condition.rb new file mode 100644 index 000000000..83ab40bde --- /dev/null +++ b/app/models/communication/task_definition_status_condition.rb @@ -0,0 +1,5 @@ +class TaskDefinitionStatusCondition < CommunicationCondition + validates :task_definition, presence: true + validates :operator, inclusion: { in: EQUALITY_OPERATORS } + validate :task_statuses_must_be_present +end diff --git a/app/models/communication/task_status_count_condition.rb b/app/models/communication/task_status_count_condition.rb new file mode 100644 index 000000000..13d24aaac --- /dev/null +++ b/app/models/communication/task_status_count_condition.rb @@ -0,0 +1,6 @@ +class TaskStatusCountCondition < CommunicationCondition + validates :task_status_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :task_target_grade, presence: true, inclusion: { in: GradeHelper::RANGE } + validates :operator, inclusion: { in: GRADE_OPERATORS } + validate :task_statuses_must_be_present +end diff --git a/app/models/communication/tutorial_enrolment_condition.rb b/app/models/communication/tutorial_enrolment_condition.rb new file mode 100644 index 000000000..024ed79ce --- /dev/null +++ b/app/models/communication/tutorial_enrolment_condition.rb @@ -0,0 +1,4 @@ +class TutorialEnrolmentCondition < CommunicationCondition + validates :tutorial, presence: true + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/tutorial_stream_enrolment_condition.rb b/app/models/communication/tutorial_stream_enrolment_condition.rb new file mode 100644 index 000000000..e2153037f --- /dev/null +++ b/app/models/communication/tutorial_stream_enrolment_condition.rb @@ -0,0 +1,4 @@ +class TutorialStreamEnrolmentCondition < CommunicationCondition + validates :tutorial_stream, presence: true + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/teaching_period.rb b/app/models/teaching_period.rb index eaf5d6b2c..1f37d1864 100644 --- a/app/models/teaching_period.rb +++ b/app/models/teaching_period.rb @@ -18,6 +18,7 @@ class TeachingPeriod < ApplicationRecord validate :validate_end_date_after_start_date, :validate_active_until_after_end_date after_update :propogate_date_changes + after_update :refresh_communication_schedule_caches, if: :saved_change_to_teaching_dates? # Public methods @@ -132,6 +133,13 @@ def future_teaching_periods TeachingPeriod.where("start_date > :end_date", end_date: end_date) end + def refresh_communication_schedule_caches + CommunicationSetSchedule + .joins(communication_set: :unit) + .where(units: { teaching_period_id: id }) + .find_each(&:refresh_next_run_at!) + end + private def can_destroy? @@ -160,4 +168,8 @@ def propogate_date_changes u.update(start_date: self.start_date, end_date: self.end_date) end end + + def saved_change_to_teaching_dates? + saved_change_to_start_date? || saved_change_to_end_date? + end end diff --git a/app/models/unit.rb b/app/models/unit.rb index 7647c5091..b7943ff1a 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -146,6 +146,7 @@ def role_for(user) after_update :move_files_on_code_change, if: :saved_change_to_code? after_update :propogate_date_changes_to_tasks, if: :saved_change_to_start_date? after_update :update_overdue_tasks_aip, if: :saved_change_to_mark_late_submissions_as_assess_in_portfolio? + after_update :refresh_communication_schedule_caches, if: :saved_change_to_communication_schedule_inputs? # Model associations. # When a Unit is destroyed, any TaskDefinitions, Tutorials, and ProjectConvenor instances will also be destroyed. @@ -157,6 +158,9 @@ def role_for(user) has_many :unit_roles, dependent: :destroy, inverse_of: :unit has_many :learning_outcomes, as: :context, dependent: :destroy # inverse_of: :unit has_many :marking_sessions, dependent: :destroy + has_many :communication_sets, class_name: 'CommunicationSet', dependent: :destroy + has_many :communication_rules, through: :communication_sets, class_name: 'CommunicationRule' + has_many :communication_set_schedules, through: :communication_sets, class_name: 'CommunicationSetSchedule' has_many :comments, through: :projects has_many :tasks, through: :projects @@ -238,6 +242,14 @@ def active_projects projects.where(enrolled: true) end + def refresh_communication_schedule_caches + communication_set_schedules.find_each(&:refresh_next_run_at!) + end + + def saved_change_to_communication_schedule_inputs? + saved_change_to_active? || saved_change_to_start_date? || saved_change_to_end_date? + end + def ordered_task_definitions task_definitions.order('start_date ASC, abbreviation ASC') end @@ -435,6 +447,10 @@ def rollover(teaching_period, start_date, end_date, new_code) end end + communication_sets.each do |communication_set| + communication_set.copy_to(new_unit) + end + # Now duplicate all feedback chips chip_mapping = {} @@ -1550,15 +1566,19 @@ def date_for_week_and_day(week, day) start_day_num = start_date.wday - start_date + week.weeks + (day_num - start_day_num).days + start_date + (week - 1).weeks + (day_num - start_day_num).days end end def week_number(date) + return nil if date.nil? || start_date.nil? + if teaching_period.present? teaching_period.week_number(date) else - ((date - start_date) / 1.week).floor + 1 + target_date = date.to_date + unit_start_date = start_date.to_date + ((target_date - unit_start_date).to_i / 7).floor + 1 end end diff --git a/app/sidekiq/communication_rule_job.rb b/app/sidekiq/communication_rule_job.rb new file mode 100644 index 000000000..3d1b02c6c --- /dev/null +++ b/app/sidekiq/communication_rule_job.rb @@ -0,0 +1,39 @@ +class CommunicationRuleJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first, args.last, 'communication-rule'] }, + on_conflict: :reject, + retry: 1 + + def perform(rule_id) + logger.info "Starting communication rule job..." + + at(0) + total(1) + + rule = CommunicationRule.find(rule_id) + + projects = rule.communication_set.preview_projects_for_rule(rule) + store( + result: projects.map do |project| + { + username: project.user&.username, + student_id: project.user&.student_id, + target_grade: project.target_grade, + last_sign_in_at: project.user&.last_sign_in_at + } + end + ) + + logger.info "Completed communication job" + rescue StandardError => e + logger.error e + raise e + end + +end diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb new file mode 100644 index 000000000..3865cffa1 --- /dev/null +++ b/app/sidekiq/execute_communication_set_job.rb @@ -0,0 +1,517 @@ +require 'csv' + +class ExecuteCommunicationSetJob + include Sidekiq::Job + include Sidekiq::Status::Worker + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args[0], args[1], 'communication-set'] }, + on_conflict: :reject, + retry: 1 + + def perform(communication_set_id, target_rule_id = nil) + communication_set = CommunicationSet.find(communication_set_id) + rules = communication_set.communication_rules.to_a + target_rule_id = target_rule_id&.to_i + + if target_rule_id.present? && rules.none? { |rule| rule.id == target_rule_id } + raise ActiveRecord::RecordNotFound, "CommunicationRule #{target_rule_id} not found in set #{communication_set_id}" + end + + eligible_projects = communication_set.eligible_projects + remaining_projects = eligible_projects.dup + executed_rules = [] + action_results = [] + + rules_to_process = + if target_rule_id.present? + cutoff_index = rules.index { |rule| rule.id == target_rule_id } + rules.first(cutoff_index + 1) + else + rules + end + + at(0) + total(rules_to_process.length.nonzero? || 1) + + rules_to_process.each_with_index do |rule, index| + matched_projects = rule.matching_projects(remaining_projects) + + executed_rules << { + rule_id: rule.id, + rule_name: rule.name, + matched_project_ids: matched_projects.map(&:id) + } + + should_execute_actions = target_rule_id.present? ? rule.id == target_rule_id : true + + if should_execute_actions + rule_action_results = rule.communication_actions.flat_map do |action| + execute_action(action, matched_projects, communication_set.unit, rule) + end + + action_results.concat(rule_action_results) + if rule.send_log_to_convenors? + action_results.concat( + send_action_log_to_convenors( + matched_projects, + communication_set.unit, + rule, + rule_action_results + ) + ) + end + end + + remaining_projects -= matched_projects + at(index + 1) + end + + store( + result: { + communication_set_id: communication_set.id, + target_rule_id: target_rule_id, + executed_rule_ids: executed_rules.map { |item| item[:rule_id] }, + remaining_project_ids: remaining_projects.map(&:id), + rules: executed_rules, + actions: action_results + } + ) + rescue StandardError => e + logger.error("ExecuteCommunicationSetJob failed: #{e.class} #{e.message}") + raise e + end + + private + + def execute_action(action, projects, unit, rule) + case action.type + when 'ChangeTargetGradeAction' + execute_change_target_grade_action(action, projects) + when 'EmailStudentAction' + execute_email_student_action(action, projects, unit, rule) + when 'EmailStaffAction' + execute_email_staff_action(action, projects, unit, rule) + else + [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + reason: 'unsupported action type' + }] + end + end + + def execute_change_target_grade_action(action, projects) + projects.map do |project| + previous_target_grade = project.target_grade + + project.update!(target_grade: action.target_grade) + + { + action_id: action.id, + action_type: action.type, + status: 'updated', + project_id: project.id, + username: project.user&.username, + previous_target_grade: previous_target_grade, + target_grade: action.target_grade + } + end + end + + def execute_email_student_action(action, projects, unit, rule) + projects.filter_map do |project| + recipient = project.user + sender = sender_for(unit) + + if recipient&.email.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + reason: 'student email missing' + } + end + + if sender.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + reason: 'sender email missing' + } + end + + subject = render_template(action.subject, project, unit, rule, projects.length) + body = render_template(action.body, project, unit, rule, projects.length) + + CommunicationsMailer.communication_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule + ).deliver_now + + { + action_id: action.id, + action_type: action.type, + status: 'sent', + project_id: project.id, + username: recipient.username, + recipient_email: recipient.email + } + end + end + + def execute_email_staff_action(action, projects, unit, rule) + projects.flat_map do |project| + sender = sender_for(unit) + + if sender.blank? + next [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'sender email missing' + }] + end + + staff_recipients_for(project, unit, action).map do |recipient| + subject = render_template(action.subject, project, unit, rule, projects.length) + body = render_template(action.body, project, unit, rule, projects.length) + + CommunicationsMailer.communication_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule + ).deliver_now + + { + action_id: action.id, + action_type: action.type, + status: 'sent', + project_id: project.id, + username: project.user&.username, + recipient_email: recipient.email, + recipient_username: recipient.username + } + end + end + end + + def send_action_log_to_convenors(projects, unit, rule, prior_action_results) + sender = sender_for(unit) + + if sender.blank? + return [{ + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'skipped', + reason: 'sender email missing' + }] + end + + recipients = unit.convenors.includes(:user).map(&:user).select { |user| user&.email.present? }.uniq(&:id) + + if recipients.empty? + return [{ + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'skipped', + reason: 'convenor email missing' + }] + end + + csv_content = build_action_log_csv(rule, projects, prior_action_results) + csv_filename = "communication-rule-#{rule.id}-action-log.csv" + body = action_log_email_body(rule, prior_action_results) + subject = action_log_email_subject(unit, rule) + + recipients.map do |recipient| + CommunicationsMailer.action_log_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule, + csv_content: csv_content, + csv_filename: csv_filename, + affected_students_count: projects.length + ).deliver_now + + { + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'sent', + recipient_email: recipient.email, + recipient_username: recipient.username, + attachment_filename: csv_filename, + affected_students_count: projects.length + } + end + end + + def staff_recipients_for(project, unit, action) + recipients = [] + + if action.email_tutors + recipients.concat( + project.tutorial_enrolments.filter_map do |tutorial_enrolment| + tutorial_enrolment.tutorial&.unit_role&.user + end + ) + end + + if action.email_convenors + recipients.concat(unit.convenors.includes(:user).map(&:user)) + end + + recipients.select { |recipient| recipient&.email.present? }.uniq(&:id) + end + + def render_template(template, project, unit, rule, affected_students_count, target_grade_override = nil, action_results = []) + return '' if template.blank? + + student = project&.user + target_grade_value = target_grade_override.nil? ? project&.target_grade : target_grade_override + + replacements = { + '{{student.first_name}}' => student&.first_name.to_s, + '{{student.last_name}}' => student&.last_name.to_s, + '{{student.preferred_name}}' => (student&.nickname.presence || student&.first_name).to_s, + '{{student.full_name}}' => [student&.first_name, student&.last_name].compact.join(' '), + '{{student.username}}' => student&.username.to_s, + '{{student.student_id}}' => student&.student_id.to_s, + '{{affected_students_count}}' => affected_students_count.to_s, + '{{unit.code}}' => unit.code.to_s, + '{{unit.name}}' => unit.name.to_s, + '{{rule.name}}' => rule.name.to_s, + '{{target_grade}}' => target_grade_name(target_grade_value), + '{{conditions_summary}}' => conditions_summary(rule), + '{{actions_summary}}' => actions_summary(rule, action_results) + } + + replacements.reduce(template.dup) do |rendered, (token, value)| + rendered.gsub(token, value) + end + end + + def target_grade_name(value) + GradeHelper.grade_for(value).to_s + end + + def formatted_email(user) + return nil if user&.email.blank? + + %("#{user.name}" <#{user.email}>) + end + + def sender_for(unit) + formatted_email(sender_user_for(unit)) + end + + def sender_user_for(unit) + unit.main_convenor_user || unit.convenors.includes(:user).first&.user + end + + def conditions_summary(rule) + rule.communication_conditions.map do |condition| + "- #{human_condition_summary(condition)}" + end.join("\n") + end + + def actions_summary(rule, action_results) + return rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n") if action_results.blank? + + rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n") + end + + def build_action_log_csv(rule, projects, action_results) + action_order = rule.communication_actions.each_with_index.to_h { |action, index| [action.id, index] } + ordered_results = action_results.sort_by do |result| + project = projects.find { |item| item.id == result[:project_id] } + student = project&.user + + [ + student&.username.to_s, + action_order.fetch(result[:action_id], Float::INFINITY), + result[:recipient_email].to_s + ] + end + + CSV.generate(headers: true) do |csv| + csv << [ + 'student_username', + 'student_id', + 'student_name', + 'rule_name', + 'action_type', + 'status', + 'details', + # 'previous_target_grade', + # 'new_target_grade', + 'recipient_email', + 'executed_at' + ] + + ordered_results.each do |result| + project = projects.find { |item| item.id == result[:project_id] } + student = project&.user + details = if result[:status] == 'updated' + "Changed target grade from #{target_grade_name(result[:previous_target_grade])} to #{target_grade_name(result[:target_grade])}" + elsif result[:recipient_email].present? + "Sent email to #{result[:recipient_email]}" + else + result[:reason].to_s + end + + csv << [ + student&.username, + student&.student_id, + student&.name, + rule.name, + result[:action_type], + result[:status], + details, + # target_grade_name(result[:previous_target_grade]), + # target_grade_name(result[:target_grade]), + result[:recipient_email], + Time.current.iso8601 + ] + end + end + end + + def action_log_email_subject(unit, rule) + "#{unit.code} #{rule.name} action log" + end + + def action_log_email_body(rule, action_results) + [ + action_log_conditions_intro(rule), + conditions_summary(rule), + 'The following actions have been applied to these students:', + actions_summary(rule, action_results) + ].join("\n") + end + + def action_log_conditions_intro(rule) + if rule.operator == 'or' + 'A scheduled rule has been run for students that match any of the following conditions:' + else + 'A scheduled rule has been run for students that match all of the following conditions:' + end + end + + def human_condition_summary(condition) + case condition.type + when 'TaskDefinitionStatusCondition' + predicate = condition.operator == 'not_equal_to' ? 'Not In' : 'In' + task = TaskDefinition.find_by(id: condition.task_definition_id) + task_label = if task + "Task #{task.abbreviation} #{task.name}" + else + "Task #{condition.task_definition_id}" + end + "Students that have #{task_label} #{predicate} [#{Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ')}]" + when 'TargetGradeCondition' + "Students with a Target Grade #{operator_label(condition.operator)} #{target_grade_name(condition.target_grade)}" + when 'TaskStatusCountCondition' + grade_label = target_grade_name(condition.task_target_grade) + statuses = Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ') + "Students that have #{operator_label(condition.operator)} #{condition.task_status_count} #{grade_label} tasks in [#{statuses}]" + when 'LoginStatusCondition' + "Students whose last sign in is #{condition.operator.to_s.humanize.downcase} #{condition.last_sign_in_at}" + when 'TutorialEnrolmentCondition' + tutorial = Tutorial.find_by(id: condition.tutorial_id) + tutorial_label = + if tutorial + [tutorial.abbreviation, tutorial.name].compact.join(' ') + else + "Tutorial #{condition.tutorial_id}" + end + "Students #{enrolment_label(condition.operator).downcase} #{tutorial_label}" + when 'TutorialStreamEnrolmentCondition' + tutorial_stream = TutorialStream.find_by(id: condition.tutorial_stream_id) + stream_label = + if tutorial_stream + [tutorial_stream.abbreviation, tutorial_stream.name].compact.join(' ') + else + "Tutorial Stream #{condition.tutorial_stream_id}" + end + "Students #{enrolment_label(condition.operator).downcase} #{stream_label}" + when 'CampusCondition' + campus = Campus.find_by(id: condition.campus_id) + campus_label = campus&.name || "Campus #{condition.campus_id}" + "Students #{enrolment_label(condition.operator).downcase} #{campus_label}" + else + "#{condition.type.to_s.underscore.humanize} #{condition.operator.to_s.humanize}" + end + end + + def human_action_summary(action) + case action.type + when 'EmailStudentAction' + 'Send email' + when 'EmailStaffAction' + 'Send staff email' + when 'ChangeTargetGradeAction' + "Change Target Grade to #{target_grade_name(action.target_grade)}" + else + human_action_type_name(action.type) + end + end + + def human_action_type_name(type) + case type + when 'EmailStudentAction' + 'Send email' + when 'EmailStaffAction' + 'Send staff email' + when 'ChangeTargetGradeAction' + 'Change target grade' + else + type.to_s.underscore.humanize + end + end + + def operator_label(operator) + case operator.to_s + when 'greater_than' + 'Greater Than' + when 'greater_than_or_equal_to' + 'Greater Than Or Equal To' + when 'less_than' + 'Less Than' + when 'less_than_or_equal_to' + 'Less Than Or Equal To' + when 'equal_to' + 'Equal To' + when 'not_equal_to' + 'Not Equal To' + else + operator.to_s.humanize + end + end + + def enrolment_label(operator) + operator.to_s == 'not_enrolled_in' ? 'Not Enrolled In' : 'Enrolled In' + end +end diff --git a/app/sidekiq/execute_communication_set_schedule_job.rb b/app/sidekiq/execute_communication_set_schedule_job.rb new file mode 100644 index 000000000..6960a96dd --- /dev/null +++ b/app/sidekiq/execute_communication_set_schedule_job.rb @@ -0,0 +1,24 @@ +class ExecuteCommunicationSetScheduleJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first, 'communication-set-schedule'] }, + on_conflict: :reject, + retry: 1 + + def perform(schedule_id) + schedule = CommunicationSetSchedule.find(schedule_id) + return unless schedule.active? + return unless schedule.unit.active? + + now = Time.zone.now + return unless schedule.due?(now) + + ExecuteCommunicationSetJob.perform_async(schedule.communication_set_id) + schedule.update!( + last_enqueued_at: now, + last_run_at: now, + next_run_at: schedule.next_occurrence_after(now + 1.second) + ) + end +end diff --git a/app/sidekiq/poll_communication_set_schedules_job.rb b/app/sidekiq/poll_communication_set_schedules_job.rb new file mode 100644 index 000000000..b5f0f803f --- /dev/null +++ b/app/sidekiq/poll_communication_set_schedules_job.rb @@ -0,0 +1,19 @@ +class PollCommunicationSetSchedulesJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['poll-communication-set-schedules'] }, + on_conflict: :reject, + retry: 1 + + def perform + CommunicationSetSchedule + .includes(:communication_set) + .active + .with_active_unit + .due(Time.zone.now) + .find_each do |schedule| + ExecuteCommunicationSetScheduleJob.perform_async(schedule.id) + end + end +end diff --git a/app/views/communications_mailer/action_log_email.html.erb b/app/views/communications_mailer/action_log_email.html.erb new file mode 100644 index 000000000..7df5658bf --- /dev/null +++ b/app/views/communications_mailer/action_log_email.html.erb @@ -0,0 +1,69 @@ + + + + + + +
+

<%= @doubtfire_product_name %> Notification

+ +

Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>,

+ + <% @body_paragraphs.each do |paragraph| %> +

<%= paragraph %>

+ <% end %> + +
+ +

+ <%= @affected_students_count %> + students matched this rule and were affected by the configured actions. +

+ +

A full log of actions has been attached as a CSV.

+ +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team +

+ +
+ + diff --git a/app/views/communications_mailer/action_log_email.text.erb b/app/views/communications_mailer/action_log_email.text.erb new file mode 100644 index 000000000..f980a817f --- /dev/null +++ b/app/views/communications_mailer/action_log_email.text.erb @@ -0,0 +1,18 @@ +Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>, + +<% @body_paragraphs.each do |paragraph| %> +<%= paragraph %> + +<% end %> +--- + +<%= @affected_students_count %> students matched this rule and were affected by the configured actions. + +A full log of actions has been attached as a CSV. + +Cheers, +The <%= @doubtfire_product_name %> Team + +--- + +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/communications_mailer/communication_email.html.erb b/app/views/communications_mailer/communication_email.html.erb new file mode 100644 index 000000000..7933f8247 --- /dev/null +++ b/app/views/communications_mailer/communication_email.html.erb @@ -0,0 +1,53 @@ + + + + + + +
+

<%= @doubtfire_product_name %> Notification

+ + <% @body_paragraphs.each do |paragraph| %> +

<%= paragraph %>

+ <% end %> + +
+ + diff --git a/app/views/communications_mailer/communication_email.text.erb b/app/views/communications_mailer/communication_email.text.erb new file mode 100644 index 000000000..5ad89c15f --- /dev/null +++ b/app/views/communications_mailer/communication_email.text.erb @@ -0,0 +1,12 @@ +<%# Hi <%= @recipient.nickname.presence || @recipient.first_name %> %> + +<% @body_paragraphs.each do |paragraph| %> +<%= paragraph %> + +<% end %> +<%# Cheers, +The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> %> + +--- + +Generated with <%= @doubtfire_product_name %> diff --git a/config/application.rb b/config/application.rb index 42dd1e6e2..21afadb5d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -271,6 +271,7 @@ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) config.autoload_paths << Rails.root.join('app') << Rails.root.join('app/models/comments') << + Rails.root.join('app/models/communication') << Rails.root.join('app/models/turn_it_in') << Rails.root.join('app/models/similarity') << Rails.root.join('app/models/d2l') @@ -278,6 +279,7 @@ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) config.eager_load_paths << Rails.root.join('app') << Rails.root.join('app/models/comments') << + Rails.root.join('app/models/communication') << Rails.root.join('app/models/turn_it_in') << Rails.root.join('app/models/similarity') << Rails.root.join('app/models/d2l') diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index dc00e5582..942c043b5 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -14,6 +14,11 @@ Sidekiq::Status.configure_server_middleware(config, expiration: 30.minutes.to_i) Sidekiq::Status.configure_client_middleware(config, expiration: 30.minutes.to_i) + + config.on(:startup) do + schedule_file = Rails.root.join('config/schedule.yml') + Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(schedule_file)) if File.exist?(schedule_file) + end end Sidekiq.configure_client do |config| diff --git a/config/schedule.yml b/config/schedule.yml index b0bd370f0..b26a1309d 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -16,6 +16,10 @@ refresh_moderation_feedback_timestamps: cron: "every 60 minutes" class: "RefreshModerationFeedbackTimestampsJob" +poll_communication_set_schedules: + cron: "every 5 minutes" + class: "PollCommunicationSetSchedulesJob" + # archive_old_units: # cron: "every 6 months" # class: "ArchiveOldUnitsJob" diff --git a/db/migrate/20260604070032_add_communications_feat.rb b/db/migrate/20260604070032_add_communications_feat.rb new file mode 100644 index 000000000..1d228a85f --- /dev/null +++ b/db/migrate/20260604070032_add_communications_feat.rb @@ -0,0 +1,117 @@ +class AddCommunicationsFeat < ActiveRecord::Migration[8.0] + def change + create_table :communication_sets do |t| + t.references :unit, null: false + t.string :name + t.boolean :active, null: false, default: true + t.timestamps + end + + create_table :communication_rules do |t| + t.references :communication_set, null: false + + t.integer :position, null: false, default: 0 + t.string :name + # AND | OR + t.string :operator # AND | OR + + t.boolean :send_log_to_convenors, null: false, default: false + + t.boolean :active, null: false, default: true + + t.timestamps + end + + create_table :communication_conditions do |t| + t.string :type, null: false + t.references :communication, null: false + + # TargetGradeCondition + t.integer :target_grade + # t.string :target_grade_comparison # gt|gte|lt|lte|notequal|equal + + # TaskDefinitionStatusCondition + t.references :task_definition + t.json :task_statuses + # t.string :task_status_comparison # equal|notequal + + # LoginStatusCondition + t.datetime :last_sign_in_at + # t.string :last_sign_in_comparison # before|after + + # TutorialEnrolmentCondition + t.references :tutorial + # t.string :tutorial_comparison # enrolled|notenrolled + + # TutorialStreamEnrolmentCondition + t.references :tutorial_stream + # t.string :tutorial_stream_comparison # enrolled|notenrolled + + # CampusCondition + t.references :campus + # t.string :campus_comparison # enrolled|notenrolled + + # TaskStatusCountCondition + t.integer :task_status_count + t.integer :task_target_grade + + t.string :operator, null: false + + t.timestamps + end + + create_table :communication_actions do |t| + t.string :type, null: false + t.references :communication_rule, null: false + + # EmailStudentAction / EmailStaffAction + t.string :subject + t.text :body + + # EmailStaffAction + t.boolean :email_tutors, null: false, default: false + t.boolean :email_convenors, null: false, default: false + + # ChangeTargetGradeAction + t.integer :target_grade + + t.timestamps + end + + create_table :communication_set_schedules do |t| + t.references :communication_set, null: false + t.string :name + t.boolean :active, null: false, default: true + + # Anchor the schedule to the unit's teaching calendar. The actual start + # datetime is resolved through Unit#date_for_week_and_day. + t.integer :anchor_week, null: false + t.string :anchor_day, null: false + t.integer :hour, null: false, default: 8 + t.integer :minute, null: false, default: 0 + t.string :timezone, null: false, default: "UTC" + + # Canonical recurrence settings to hydrate IceCube rules from. + t.string :recurrence, null: false, default: "none" + t.integer :interval, null: false, default: 1 + + # Optional limits for recurring schedules. + t.integer :repeat_count + t.datetime :until_at + + # Serialized payload to rebuild an IceCube schedule without guessing. + t.json :ice_cube_schedule + + # Derived state for the worker that enqueues due communication runs. + t.datetime :next_run_at + t.datetime :last_run_at + t.datetime :last_enqueued_at + + t.timestamps + end + + add_index :communication_actions, :type + add_index :communication_conditions, :type + add_index :communication_set_schedules, [:active, :next_run_at] + end +end diff --git a/db/schema.rb b/db/schema.rb index a6b03f2f2..dc1271a24 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_04_031804) do +ActiveRecord::Schema[8.0].define(version: 2026_06_04_070032) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -73,6 +73,89 @@ t.index ["user_id"], name: "index_comments_read_receipts_on_user_id" end + create_table "communication_actions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.string "type", null: false + t.bigint "communication_rule_id", null: false + t.string "subject" + t.text "body" + t.boolean "email_tutors", default: false, null: false + t.boolean "email_convenors", default: false, null: false + t.integer "target_grade" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["communication_rule_id"], name: "index_communication_actions_on_communication_rule_id" + t.index ["type"], name: "index_communication_actions_on_type" + end + + create_table "communication_conditions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.string "type", null: false + t.bigint "communication_id", null: false + t.integer "target_grade" + t.bigint "task_definition_id" + t.text "task_statuses", size: :long, collation: "utf8mb4_bin" + t.datetime "last_sign_in_at" + t.bigint "tutorial_id" + t.bigint "tutorial_stream_id" + t.bigint "campus_id" + t.integer "task_status_count" + t.integer "task_target_grade" + t.string "operator", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["campus_id"], name: "index_communication_conditions_on_campus_id" + t.index ["communication_id"], name: "index_communication_conditions_on_communication_id" + t.index ["task_definition_id"], name: "index_communication_conditions_on_task_definition_id" + t.index ["tutorial_id"], name: "index_communication_conditions_on_tutorial_id" + t.index ["tutorial_stream_id"], name: "index_communication_conditions_on_tutorial_stream_id" + t.index ["type"], name: "index_communication_conditions_on_type" + t.check_constraint "json_valid(`task_statuses`)", name: "task_statuses" + end + + create_table "communication_rules", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "communication_set_id", null: false + t.integer "position", default: 0, null: false + t.string "name" + t.string "operator" + t.boolean "send_log_to_convenors", default: false, null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["communication_set_id"], name: "index_communication_rules_on_communication_set_id" + end + + create_table "communication_set_schedules", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "communication_set_id", null: false + t.string "name" + t.boolean "active", default: true, null: false + t.integer "anchor_week", null: false + t.string "anchor_day", null: false + t.integer "hour", default: 8, null: false + t.integer "minute", default: 0, null: false + t.string "timezone", default: "UTC", null: false + t.string "recurrence", default: "none", null: false + t.integer "interval", default: 1, null: false + t.integer "repeat_count" + t.datetime "until_at" + t.text "ice_cube_schedule", size: :long, collation: "utf8mb4_bin" + t.datetime "next_run_at" + t.datetime "last_run_at" + t.datetime "last_enqueued_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active", "next_run_at"], name: "index_communication_set_schedules_on_active_and_next_run_at" + t.index ["communication_set_id"], name: "index_communication_set_schedules_on_communication_set_id" + t.check_constraint "json_valid(`ice_cube_schedule`)", name: "ice_cube_schedule" + end + + create_table "communication_sets", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.string "name" + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["unit_id"], name: "index_communication_sets_on_unit_id" + end + create_table "d2l_assessment_mappings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id", null: false t.string "org_unit_id" diff --git a/test/factories/units_factory.rb b/test/factories/units_factory.rb index 7cc6de109..a25b6102a 100644 --- a/test/factories/units_factory.rb +++ b/test/factories/units_factory.rb @@ -143,6 +143,8 @@ next end + next if campus_tutorials.empty? + if campus_tutorials.first.tutorial_stream.present? tutorial_streams.each_with_index do |ts, i| p.enrol_in ts.tutorials.where(campus_id: c.id).sample @@ -153,7 +155,7 @@ end eval.part_enrolled_student_count.times do - unit.tutorial_enrolments.joins(:project).where('projects.campus_id = :campus_id', campus_id: c.id).sample.destroy + unit.tutorial_enrolments.joins(:project).where('projects.campus_id = :campus_id', campus_id: c.id).sample&.destroy end end diff --git a/test/models/communication_condition_test.rb b/test/models/communication_condition_test.rb new file mode 100644 index 000000000..e3fb83510 --- /dev/null +++ b/test/models/communication_condition_test.rb @@ -0,0 +1,22 @@ +require 'test_helper' + +class CommunicationConditionTest < ActiveSupport::TestCase + def test_task_definition_status_condition_accepts_multiple_task_statuses + unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 0) + communication_set = unit.communication_sets.create!(name: 'Test Set', active: true) + communication_rule = communication_set.communication_rules.create!(name: 'Test Rule', operator: 'and', position: 0) + + condition = TaskDefinitionStatusCondition.new( + communication: communication_rule, + operator: 'equal_to', + task_definition: unit.task_definitions.first, + task_statuses: %w[not_started working_on_it fix_and_resubmit] + ) + + assert condition.valid?, condition.errors.full_messages + condition.save! + + condition.reload + assert_equal %w[not_started working_on_it fix_and_resubmit], condition.task_statuses + end +end diff --git a/test/models/communication_set_schedule_test.rb b/test/models/communication_set_schedule_test.rb new file mode 100644 index 000000000..435185995 --- /dev/null +++ b/test/models/communication_set_schedule_test.rb @@ -0,0 +1,74 @@ +require 'test_helper' + +class CommunicationSetScheduleTest < ActiveSupport::TestCase + def test_next_run_at_is_nil_when_next_occurrence_is_after_unit_end_date + travel_to Time.zone.local(2026, 1, 1, 9, 0, 0) do + unit = create_unit(start_date: Date.parse('2026-02-02'), end_date: Date.parse('2026-02-02')) + schedule = create_schedule(unit, anchor_day: 'Tuesday') + + assert_nil schedule.next_run_at + end + end + + def test_next_run_at_refreshes_when_unit_dates_change + travel_to Time.zone.local(2026, 1, 1, 9, 0, 0) do + unit = create_unit(start_date: Date.parse('2026-02-02'), end_date: Date.parse('2026-02-10')) + schedule = create_schedule(unit, anchor_day: 'Tuesday') + + assert_equal Time.zone.local(2026, 2, 3, 9, 30, 0), schedule.next_run_at + + unit.update!(end_date: Date.parse('2026-02-02')) + assert_nil schedule.reload.next_run_at + + unit.update!(start_date: Date.parse('2026-02-09'), end_date: Date.parse('2026-02-17')) + assert_equal Time.zone.local(2026, 2, 10, 9, 30, 0), schedule.reload.next_run_at + end + end + + def test_next_run_at_refreshes_when_unit_active_status_changes + travel_to Time.zone.local(2026, 1, 1, 9, 0, 0) do + unit = create_unit(start_date: Date.parse('2026-02-02'), end_date: Date.parse('2026-05-11')) + schedule = create_schedule(unit, anchor_day: 'Monday') + + assert_equal Time.zone.local(2026, 2, 2, 9, 30, 0), schedule.next_run_at + + unit.update!(active: false) + assert_nil schedule.reload.next_run_at + + unit.update!(active: true) + assert_equal Time.zone.local(2026, 2, 2, 9, 30, 0), schedule.reload.next_run_at + end + end + + private + + def create_unit(start_date:, end_date:) + FactoryBot.create( + :unit, + active: true, + with_students: false, + task_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 0, + campus_count: 0, + start_date: start_date, + end_date: end_date + ) + end + + def create_schedule(unit, anchor_day:) + communication_set = unit.communication_sets.create!(name: 'Scheduled communications', active: true) + communication_set.communication_set_schedules.create!( + name: 'Week 1 schedule', + active: true, + anchor_week: 1, + anchor_day: anchor_day, + hour: 9, + minute: 30, + timezone: 'UTC', + recurrence: 'none', + interval: 1 + ) + end +end diff --git a/test/models/communication_set_test.rb b/test/models/communication_set_test.rb new file mode 100644 index 000000000..7de4ea379 --- /dev/null +++ b/test/models/communication_set_test.rb @@ -0,0 +1,40 @@ +require 'test_helper' + +class CommunicationSetTest < ActiveSupport::TestCase + def test_preview_projects_for_rule_excludes_students_claimed_by_earlier_rules + unit = FactoryBot.create( + :unit, + student_count: 2, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + task_count: 1, + stream_count: 0, + tutorials: 0 + ) + communication_set = unit.communication_sets.create!(name: 'Test Set', active: true) + + first_rule = communication_set.communication_rules.create!(name: 'First Rule', operator: 'and', position: 0) + second_rule = communication_set.communication_rules.create!(name: 'Second Rule', operator: 'and', position: 1) + + first_rule.communication_conditions.create!( + type: 'TaskDefinitionStatusCondition', + operator: 'equal_to', + task_definition: unit.task_definitions.first, + task_statuses: ['not_started'] + ) + + second_rule.communication_conditions.create!( + type: 'TaskDefinitionStatusCondition', + operator: 'equal_to', + task_definition: unit.task_definitions.first, + task_statuses: ['not_started'] + ) + + first_rule_matches = communication_set.preview_projects_for_rule(first_rule) + second_rule_matches = communication_set.preview_projects_for_rule(second_rule) + + assert_equal 2, first_rule_matches.length + assert_empty second_rule_matches + end +end diff --git a/test/models/teaching_period_test.rb b/test/models/teaching_period_test.rb index 863507e2e..96b633ede 100644 --- a/test/models/teaching_period_test.rb +++ b/test/models/teaching_period_test.rb @@ -220,4 +220,40 @@ def test_create_teaching_period_with_invalid_dates tp.destroy assert tp.destroyed? end + + test 'communication schedule next run refreshes when breaks change' do + travel_to Time.zone.local(2026, 1, 1, 9, 0, 0) do + tp = TeachingPeriod.create!( + year: 2026, + period: 'T1', + start_date: Date.parse('2026-02-02'), + end_date: Date.parse('2026-05-11'), + active_until: Date.parse('2026-05-18') + ) + unit = FactoryBot.create(:unit, teaching_period: tp, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 0) + communication_set = unit.communication_sets.create!(name: 'Weekly check in', active: true) + schedule = communication_set.communication_set_schedules.create!( + name: 'Week 3 Monday', + active: true, + anchor_week: 3, + anchor_day: 'Monday', + hour: 9, + minute: 30, + timezone: 'UTC', + recurrence: 'none', + interval: 1 + ) + + assert_equal Time.zone.local(2026, 2, 16, 9, 30, 0), schedule.next_run_at + + teaching_break = tp.add_break(Date.parse('2026-02-09'), 1) + assert_equal Time.zone.local(2026, 2, 23, 9, 30, 0), schedule.reload.next_run_at + + tp.update_break(teaching_break.id, Date.parse('2026-02-23'), 1) + assert_equal Time.zone.local(2026, 2, 16, 9, 30, 0), schedule.reload.next_run_at + + teaching_break.destroy + assert_equal Time.zone.local(2026, 2, 16, 9, 30, 0), schedule.reload.next_run_at + end + end end diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index 39144c51d..eca67c675 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -237,6 +237,83 @@ def test_rollover_of_group_tasks unit2.destroy end + def test_rollover_of_communication_sets + task_definition = FactoryBot.create(:task_definition, unit: @unit, tutorial_stream: @unit.tutorial_streams.first) + communication_set = @unit.communication_sets.create!(name: 'At Risk Follow Up', active: true) + communication_set.communication_set_schedules.create!( + name: 'Weekly follow up', + active: true, + anchor_week: 1, + anchor_day: 'Monday', + hour: 9, + minute: 30, + timezone: 'UTC', + recurrence: 'weekly', + interval: 1, + last_run_at: Time.zone.now, + last_enqueued_at: Time.zone.now + ) + communication_rule = communication_set.communication_rules.create!( + name: 'Not started', + operator: 'and', + position: 0, + send_log_to_convenors: true, + active: true + ) + communication_rule.communication_conditions.create!( + type: 'TaskDefinitionStatusCondition', + operator: 'equal_to', + task_definition: task_definition, + task_statuses: ['not_started'] + ) + communication_rule.communication_conditions.create!( + type: 'TutorialStreamEnrolmentCondition', + operator: 'enrolled_in', + tutorial_stream: @unit.tutorial_streams.first + ) + communication_rule.communication_actions.create!( + type: 'EmailStudentAction', + subject: 'Please start {{unit.code}}', + body: 'Hello {{student.first_name}}' + ) + + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil + + assert_equal 1, unit2.communication_sets.count + new_set = unit2.communication_sets.first + assert_not_equal communication_set.id, new_set.id + assert_equal 'At Risk Follow Up', new_set.name + assert_equal true, new_set.active + + assert_equal 1, new_set.communication_set_schedules.count + new_schedule = new_set.communication_set_schedules.first + assert_not_equal communication_set.communication_set_schedules.first.id, new_schedule.id + assert_equal 'Weekly follow up', new_schedule.name + assert_equal 'weekly', new_schedule.recurrence + assert_nil new_schedule.last_run_at + assert_nil new_schedule.last_enqueued_at + + assert_equal 1, new_set.communication_rules.count + new_rule = new_set.communication_rules.first + assert_not_equal communication_rule.id, new_rule.id + assert_equal 'Not started', new_rule.name + assert_equal true, new_rule.send_log_to_convenors + + new_task_definition = unit2.task_definitions.find_by!(abbreviation: task_definition.abbreviation) + task_condition = new_rule.communication_conditions.find_by!(type: 'TaskDefinitionStatusCondition') + assert_equal new_task_definition, task_condition.task_definition + assert_equal ['not_started'], task_condition.task_statuses + + new_tutorial_stream = unit2.tutorial_streams.find_by!(abbreviation: @unit.tutorial_streams.first.abbreviation) + stream_condition = new_rule.communication_conditions.find_by!(type: 'TutorialStreamEnrolmentCondition') + assert_equal new_tutorial_stream, stream_condition.tutorial_stream + + assert_equal 1, new_rule.communication_actions.count + assert_equal 'Please start {{unit.code}}', new_rule.communication_actions.first.subject + + unit2.destroy + end + def test_rollover_of_tasks_have_same_start_week_and_day @unit.import_tasks_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Tasks.csv")) diff --git a/test/sidekiq/communication_set_schedule_jobs_test.rb b/test/sidekiq/communication_set_schedule_jobs_test.rb new file mode 100644 index 000000000..fe7af9719 --- /dev/null +++ b/test/sidekiq/communication_set_schedule_jobs_test.rb @@ -0,0 +1,60 @@ +require 'test_helper' + +class CommunicationSetScheduleJobsTest < ActiveSupport::TestCase + def test_poll_does_not_enqueue_schedules_for_inactive_units + travel_to Time.zone.local(2026, 2, 2, 10, 0, 0) do + active_schedule = create_due_schedule(unit_active: true) + inactive_schedule = create_due_schedule(unit_active: false) + + PollCommunicationSetSchedulesJob.new.perform + + enqueued_schedule_ids = ExecuteCommunicationSetScheduleJob.jobs.map { |job| job['args'].first } + assert_includes enqueued_schedule_ids, active_schedule.id + assert_not_includes enqueued_schedule_ids, inactive_schedule.id + end + end + + def test_schedule_execution_does_not_enqueue_communication_set_for_inactive_unit + travel_to Time.zone.local(2026, 2, 2, 10, 0, 0) do + schedule = create_due_schedule(unit_active: false) + + ExecuteCommunicationSetScheduleJob.new.perform(schedule.id) + + assert_empty ExecuteCommunicationSetJob.jobs + assert_nil schedule.reload.last_enqueued_at + assert_nil schedule.last_run_at + end + end + + private + + def create_due_schedule(unit_active:) + unit = FactoryBot.create( + :unit, + active: unit_active, + with_students: false, + task_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 0, + campus_count: 0, + start_date: Date.parse('2026-02-02'), + end_date: Date.parse('2026-05-11') + ) + communication_set = unit.communication_sets.create!(name: 'Scheduled communications', active: true) + + schedule = communication_set.communication_set_schedules.create!( + name: 'Due schedule', + active: true, + anchor_week: 1, + anchor_day: 'Monday', + hour: 9, + minute: 0, + timezone: 'UTC', + recurrence: 'none', + interval: 1 + ) + schedule.update_column(:next_run_at, Time.zone.local(2026, 2, 2, 9, 0, 0)) + schedule + end +end diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index ba1b1b7e0..e56b1b776 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,13 +6,14 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.destroy_all! Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 4, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 5, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count assert_equal 1, TiiCheckProgressJob.jobs.count assert_equal 1, ClearAccessTokensJob.jobs.count assert_equal 1, RefreshModerationFeedbackTimestampsJob.jobs.count + assert_equal 1, PollCommunicationSetSchedulesJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end