From 7a9ca26f5ef2a8098135e2206180801aafb91334 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Tue, 5 May 2026 17:27:01 +1000
Subject: [PATCH 01/18] feat: add communications system
---
app/api/api_root.rb | 2 +
app/api/communication_rules_api.rb | 395 ++++++++++++++++++
.../entities/communication_action_entity.rb | 12 +
.../communication_condition_entity.rb | 17 +
app/api/entities/communication_rule_entity.rb | 16 +
app/api/entities/communication_set_entity.rb | 13 +
app/models/communication/campus_condition.rb | 4 +
.../change_target_grade_action.rb | 3 +
.../communication/communication_action.rb | 11 +
.../communication/communication_condition.rb | 93 +++++
.../communication/communication_rule.rb | 91 ++++
app/models/communication/communication_set.rb | 38 ++
.../communication/email_staff_action.rb | 15 +
.../communication/email_student_action.rb | 4 +
.../communication/login_status_condition.rb | 4 +
.../communication/target_grade_condition.rb | 4 +
.../task_definition_status_condition.rb | 5 +
.../task_status_count_condition.rb | 6 +
.../tutorial_enrolment_condition.rb | 4 +
.../tutorial_stream_enrolment_condition.rb | 4 +
app/models/unit.rb | 2 +
app/sidekiq/communication_rule_job.rb | 52 +++
config/application.rb | 2 +
.../20260505022817_add_communications_feat.rb | 82 ++++
db/schema.rb | 60 ++-
test/models/communication_condition_test.rb | 22 +
test/models/communication_set_test.rb | 31 ++
27 files changed, 991 insertions(+), 1 deletion(-)
create mode 100644 app/api/communication_rules_api.rb
create mode 100644 app/api/entities/communication_action_entity.rb
create mode 100644 app/api/entities/communication_condition_entity.rb
create mode 100644 app/api/entities/communication_rule_entity.rb
create mode 100644 app/api/entities/communication_set_entity.rb
create mode 100644 app/models/communication/campus_condition.rb
create mode 100644 app/models/communication/change_target_grade_action.rb
create mode 100644 app/models/communication/communication_action.rb
create mode 100644 app/models/communication/communication_condition.rb
create mode 100644 app/models/communication/communication_rule.rb
create mode 100644 app/models/communication/communication_set.rb
create mode 100644 app/models/communication/email_staff_action.rb
create mode 100644 app/models/communication/email_student_action.rb
create mode 100644 app/models/communication/login_status_condition.rb
create mode 100644 app/models/communication/target_grade_condition.rb
create mode 100644 app/models/communication/task_definition_status_condition.rb
create mode 100644 app/models/communication/task_status_count_condition.rb
create mode 100644 app/models/communication/tutorial_enrolment_condition.rb
create mode 100644 app/models/communication/tutorial_stream_enrolment_condition.rb
create mode 100644 app/sidekiq/communication_rule_job.rb
create mode 100644 db/migrate/20260505022817_add_communications_feat.rb
create mode 100644 test/models/communication_condition_test.rb
create mode 100644 test/models/communication_set_test.rb
diff --git a/app/api/api_root.rb b/app/api/api_root.rb
index e36e21226..7dc235bb2 100644
--- a/app/api/api_root.rb
+++ b/app/api/api_root.rb
@@ -79,6 +79,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
@@ -134,6 +135,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..2c62cdc31
--- /dev/null
+++ b/app/api/communication_rules_api.rb
@@ -0,0 +1,395 @@
+require 'grape'
+require 'entities/communication_set_entity'
+require 'entities/communication_rule_entity'
+require 'entities/communication_condition_entity'
+require 'entities/communication_action_entity'
+require 'entities/sidekiq_job_entity'
+
+class CommunicationRulesApi < Grape::API
+ helpers AuthenticationHelpers
+ helpers AuthorisationHelpers
+ helpers SidekiqHelper
+
+ 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_rules: [:communication_conditions, :communication_actions]),
+ with: Entities::CommunicationSetEntity
+ 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
+ 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)
+ 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
+ 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)
+ 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 '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
+ 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)
+
+ 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
+ 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)
+
+ rule.update!(rule_params)
+ present rule, with: Entities::CommunicationRuleEntity
+ 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|
+ {
+ 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
+ }
+ 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 '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 '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..bc63728ef
--- /dev/null
+++ b/app/api/entities/communication_rule_entity.rb
@@ -0,0 +1,16 @@
+module Entities
+ class CommunicationRuleEntity < Grape::Entity
+ expose :id
+ expose :communication_set_id
+ expose :name
+ expose :operator
+ expose :position
+ expose :active
+ 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..876e6a024
--- /dev/null
+++ b/app/api/entities/communication_set_entity.rb
@@ -0,0 +1,13 @@
+require 'entities/communication_rule_entity'
+
+module Entities
+ class CommunicationSetEntity < Grape::Entity
+ expose :id
+ expose :unit_id
+ expose :name
+ expose :active
+ expose :communication_rules,
+ as: :rules,
+ using: Entities::CommunicationRuleEntity
+ 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..2c7b25d5b
--- /dev/null
+++ b/app/models/communication/communication_condition.rb
@@ -0,0 +1,93 @@
+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',
+ foreign_key: :communication_id,
+ 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..2f2d68106
--- /dev/null
+++ b/app/models/communication/communication_rule.rb
@@ -0,0 +1,91 @@
+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 do |condition|
+ case condition.type
+ when 'TargetGradeCondition'
+ case condition.operator
+ when 'greater_than' then project.target_grade > condition.target_grade
+ when 'greater_than_or_equal_to' then project.target_grade >= condition.target_grade
+ when 'less_than' then project.target_grade < condition.target_grade
+ when 'less_than_or_equal_to' then project.target_grade <= condition.target_grade
+ when 'equal_to' then project.target_grade == condition.target_grade
+ when 'not_equal_to' then project.target_grade != condition.target_grade
+ else false
+ end
+ when 'TaskDefinitionStatusCondition'
+ task = project.tasks.find { |t| t.task_definition_id == condition.task_definition_id }
+ status = task&.task_status&.status_key&.to_s
+ 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
+ when 'TaskStatusCountCondition'
+ statuses = condition.task_statuses || []
+ count = project.tasks.count do |task|
+ task.task_definition&.target_grade == condition.task_target_grade &&
+ statuses.include?(task.task_status&.status_key&.to_s)
+ end
+
+ case condition.operator
+ when 'greater_than' then count > condition.task_status_count
+ when 'greater_than_or_equal_to' then count >= condition.task_status_count
+ when 'less_than' then count < condition.task_status_count
+ when 'less_than_or_equal_to' then count <= condition.task_status_count
+ when 'equal_to' then count == condition.task_status_count
+ when 'not_equal_to' then count != condition.task_status_count
+ else false
+ end
+ when 'LoginStatusCondition'
+ 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
+ when 'TutorialEnrolmentCondition'
+ enrolled = project.tutorial_enrolments.any? { |enrolment| enrolment.tutorial_id == condition.tutorial_id }
+
+ condition.operator == 'not_enrolled_in' ? !enrolled : enrolled
+ when 'TutorialStreamEnrolmentCondition'
+ enrolled = project.tutorial_enrolments.any? do |enrolment|
+ enrolment.tutorial&.tutorial_stream_id == condition.tutorial_stream_id
+ end
+
+ condition.operator == 'not_enrolled_in' ? !enrolled : enrolled
+ when 'CampusCondition'
+ enrolled = project.campus_id == condition.campus_id
+
+ condition.operator == 'not_enrolled_in' ? !enrolled : enrolled
+ else
+ false
+ end
+ end
+
+ operator == 'or' ? matches.any? : matches.all?
+ 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..e86c4484d
--- /dev/null
+++ b/app/models/communication/communication_set.rb
@@ -0,0 +1,38 @@
+class CommunicationSet < ApplicationRecord
+ belongs_to :unit
+
+ has_many :communication_rules,
+ -> { order(:position) },
+ class_name: 'CommunicationRule',
+ 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_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
+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/unit.rb b/app/models/unit.rb
index 7647c5091..9edb30d35 100644
--- a/app/models/unit.rb
+++ b/app/models/unit.rb
@@ -157,6 +157,8 @@ 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 :comments, through: :projects
has_many :tasks, through: :projects
diff --git a/app/sidekiq/communication_rule_job.rb b/app/sidekiq/communication_rule_job.rb
new file mode 100644
index 000000000..e197ced7c
--- /dev/null
+++ b/app/sidekiq/communication_rule_job.rb
@@ -0,0 +1,52 @@
+# require_dependency Rails.root.join('app/models/communication/communication_condition').to_s
+# require_dependency Rails.root.join('app/models/communication/target_grade_condition').to_s
+# require_dependency Rails.root.join('app/models/communication/task_definition_status_condition').to_s
+# require_dependency Rails.root.join('app/models/communication/login_status_condition').to_s
+# require_dependency Rails.root.join('app/models/communication/tutorial_enrolment_condition').to_s
+# require_dependency Rails.root.join('app/models/communication/tutorial_stream_enrolment_condition').to_s
+# require_dependency Rails.root.join('app/models/communication/campus_condition').to_s
+# require_dependency Rails.root.join('app/models/communication/communication_action').to_s
+# require_dependency Rails.root.join('app/models/communication/email_student_action').to_s
+# require_dependency Rails.root.join('app/models/communication/email_staff_action').to_s
+# require_dependency Rails.root.join('app/models/communication/change_target_grade_action').to_s
+# require_dependency Rails.root.join('app/models/communication/communication_rule').to_s
+
+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 communiation job"
+ rescue StandardError => e
+ logger.error e
+ raise e
+ end
+
+end
diff --git a/config/application.rb b/config/application.rb
index 23567a646..c9995784d 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -245,6 +245,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')
@@ -252,6 +253,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/db/migrate/20260505022817_add_communications_feat.rb b/db/migrate/20260505022817_add_communications_feat.rb
new file mode 100644
index 000000000..80762ab99
--- /dev/null
+++ b/db/migrate/20260505022817_add_communications_feat.rb
@@ -0,0 +1,82 @@
+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 :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
+
+ add_index :communication_actions, :type
+ add_index :communication_conditions, :type
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1e49bb202..e4538dad7 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_03_27_041457) do
+ActiveRecord::Schema[8.0].define(version: 2026_05_05_022817) 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,64 @@
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 "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_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/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_test.rb b/test/models/communication_set_test.rb
new file mode 100644
index 000000000..d12336b79
--- /dev/null
+++ b/test/models/communication_set_test.rb
@@ -0,0 +1,31 @@
+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, 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
From 75810ca53d92a80241f93d14f8119d85f74fd03b Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Wed, 6 May 2026 19:16:26 +1000
Subject: [PATCH 02/18] fix: execute single set
---
app/api/communication_rules_api.rb | 59 ++++++++++++++++++-
app/models/communication/communication_set.rb | 6 ++
2 files changed, 64 insertions(+), 1 deletion(-)
diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb
index 2c62cdc31..d04e7e51e 100644
--- a/app/api/communication_rules_api.rb
+++ b/app/api/communication_rules_api.rb
@@ -29,6 +29,58 @@ class CommunicationRulesApi < Grape::API
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_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,
+ 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
@@ -212,10 +264,15 @@ class CommunicationRulesApi < Grape::API
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
+ last_sign_in_at: project.user&.last_sign_in_at,
+ campus: project.campus&.name
}
end
}
diff --git a/app/models/communication/communication_set.rb b/app/models/communication/communication_set.rb
index e86c4484d..230e3def5 100644
--- a/app/models/communication/communication_set.rb
+++ b/app/models/communication/communication_set.rb
@@ -21,6 +21,12 @@ def preview_projects_for_rule(target_rule)
&.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 = []
From 2e64e1e020596bab2f4804b7d3b063b1dcdf274f Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 7 May 2026 11:09:48 +1000
Subject: [PATCH 03/18] feat: ability to edit conditions and actions
---
app/api/communication_rules_api.rb | 80 ++++++++++++++++++++++++++++++
1 file changed, 80 insertions(+)
diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb
index d04e7e51e..312318d8a 100644
--- a/app/api/communication_rules_api.rb
+++ b/app/api/communication_rules_api.rb
@@ -361,6 +361,55 @@ class CommunicationRulesApi < Grape::API
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
@@ -432,6 +481,37 @@ class CommunicationRulesApi < Grape::API
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
From d9f01c403c03055a6c99bca613ff43f25230565f Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 7 May 2026 14:55:35 +1000
Subject: [PATCH 04/18] feat: enable communication set execution and mailing
---
app/api/communication_rules_api.rb | 38 +++
app/mailers/communications_mailer.rb | 16 ++
.../communication/communication_rule.rb | 15 +-
app/sidekiq/communication_rule_job.rb | 15 +-
app/sidekiq/execute_communication_set_job.rb | 260 ++++++++++++++++++
.../communication_email.html.erb | 53 ++++
.../communication_email.text.erb | 12 +
7 files changed, 389 insertions(+), 20 deletions(-)
create mode 100644 app/mailers/communications_mailer.rb
create mode 100644 app/sidekiq/execute_communication_set_job.rb
create mode 100644 app/views/communications_mailer/communication_email.html.erb
create mode 100644 app/views/communications_mailer/communication_email.text.erb
diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb
index 312318d8a..15ddee9c8 100644
--- a/app/api/communication_rules_api.rb
+++ b/app/api/communication_rules_api.rb
@@ -145,6 +145,25 @@ class CommunicationRulesApi < Grape::API
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 communication rules for a unit'
params do
requires :unit_id, type: Integer
@@ -233,6 +252,25 @@ class CommunicationRulesApi < Grape::API
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
diff --git a/app/mailers/communications_mailer.rb b/app/mailers/communications_mailer.rb
new file mode 100644
index 000000000..c23d404bd
--- /dev/null
+++ b/app/mailers/communications_mailer.rb
@@ -0,0 +1,16 @@
+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
+end
diff --git a/app/models/communication/communication_rule.rb b/app/models/communication/communication_rule.rb
index 2f2d68106..5a36e8534 100644
--- a/app/models/communication/communication_rule.rb
+++ b/app/models/communication/communication_rule.rb
@@ -23,13 +23,16 @@ def matching_projects(projects = nil)
matches = communication_conditions.map do |condition|
case condition.type
when 'TargetGradeCondition'
+ project_target_grade = project.target_grade
+ next false if project_target_grade.nil?
+
case condition.operator
- when 'greater_than' then project.target_grade > condition.target_grade
- when 'greater_than_or_equal_to' then project.target_grade >= condition.target_grade
- when 'less_than' then project.target_grade < condition.target_grade
- when 'less_than_or_equal_to' then project.target_grade <= condition.target_grade
- when 'equal_to' then project.target_grade == condition.target_grade
- when 'not_equal_to' then project.target_grade != condition.target_grade
+ when 'greater_than' then project_target_grade > condition.target_grade
+ when 'greater_than_or_equal_to' then project_target_grade >= condition.target_grade
+ when 'less_than' then project_target_grade < condition.target_grade
+ when 'less_than_or_equal_to' then project_target_grade <= condition.target_grade
+ when 'equal_to' then project_target_grade == condition.target_grade
+ when 'not_equal_to' then project_target_grade != condition.target_grade
else false
end
when 'TaskDefinitionStatusCondition'
diff --git a/app/sidekiq/communication_rule_job.rb b/app/sidekiq/communication_rule_job.rb
index e197ced7c..3d1b02c6c 100644
--- a/app/sidekiq/communication_rule_job.rb
+++ b/app/sidekiq/communication_rule_job.rb
@@ -1,16 +1,3 @@
-# require_dependency Rails.root.join('app/models/communication/communication_condition').to_s
-# require_dependency Rails.root.join('app/models/communication/target_grade_condition').to_s
-# require_dependency Rails.root.join('app/models/communication/task_definition_status_condition').to_s
-# require_dependency Rails.root.join('app/models/communication/login_status_condition').to_s
-# require_dependency Rails.root.join('app/models/communication/tutorial_enrolment_condition').to_s
-# require_dependency Rails.root.join('app/models/communication/tutorial_stream_enrolment_condition').to_s
-# require_dependency Rails.root.join('app/models/communication/campus_condition').to_s
-# require_dependency Rails.root.join('app/models/communication/communication_action').to_s
-# require_dependency Rails.root.join('app/models/communication/email_student_action').to_s
-# require_dependency Rails.root.join('app/models/communication/email_staff_action').to_s
-# require_dependency Rails.root.join('app/models/communication/change_target_grade_action').to_s
-# require_dependency Rails.root.join('app/models/communication/communication_rule').to_s
-
class CommunicationRuleJob
include Sidekiq::Job
include Sidekiq::Status::Worker
@@ -43,7 +30,7 @@ def perform(rule_id)
end
)
- logger.info "Completed communiation job"
+ logger.info "Completed communication job"
rescue StandardError => e
logger.error e
raise e
diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb
new file mode 100644
index 000000000..d9d4b7fd2
--- /dev/null
+++ b/app/sidekiq/execute_communication_set_job.rb
@@ -0,0 +1,260 @@
+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.communication_actions.each do |action|
+ action_results.concat(execute_action(action, matched_projects, communication_set.unit, rule))
+ 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)
+ body = render_template(action.body, project, unit, rule)
+
+ 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)
+ body = render_template(action.body, project, unit, rule)
+
+ 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 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)
+ return '' if template.blank?
+
+ student = project.user
+
+ 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,
+ '{{unit.code}}' => unit.code.to_s,
+ '{{unit.name}}' => unit.name.to_s,
+ '{{rule.name}}' => rule.name.to_s,
+ '{{target_grade}}' => target_grade_name(project.target_grade)
+ }
+
+ 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
+end
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 %>
From d956d4e72dc309ec799c2b0aa6ec219d2e775a58 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 7 May 2026 14:58:28 +1000
Subject: [PATCH 05/18] refactor: fix rubocop
---
.../communication/communication_condition.rb | 1 -
.../communication/communication_rule.rb | 162 ++++++++++--------
app/models/communication/communication_set.rb | 1 +
3 files changed, 96 insertions(+), 68 deletions(-)
diff --git a/app/models/communication/communication_condition.rb b/app/models/communication/communication_condition.rb
index 2c7b25d5b..bbe510cbe 100644
--- a/app/models/communication/communication_condition.rb
+++ b/app/models/communication/communication_condition.rb
@@ -40,7 +40,6 @@ class CommunicationCondition < ApplicationRecord
belongs_to :communication,
class_name: 'CommunicationRule',
- foreign_key: :communication_id,
inverse_of: :communication_conditions
belongs_to :task_definition, optional: true
diff --git a/app/models/communication/communication_rule.rb b/app/models/communication/communication_rule.rb
index 5a36e8534..fb719d698 100644
--- a/app/models/communication/communication_rule.rb
+++ b/app/models/communication/communication_rule.rb
@@ -20,75 +20,103 @@ def matching_projects(projects = nil)
return projects if communication_conditions.empty?
projects.select do |project|
- matches = communication_conditions.map do |condition|
- case condition.type
- when 'TargetGradeCondition'
- project_target_grade = project.target_grade
- next false if project_target_grade.nil?
-
- case condition.operator
- when 'greater_than' then project_target_grade > condition.target_grade
- when 'greater_than_or_equal_to' then project_target_grade >= condition.target_grade
- when 'less_than' then project_target_grade < condition.target_grade
- when 'less_than_or_equal_to' then project_target_grade <= condition.target_grade
- when 'equal_to' then project_target_grade == condition.target_grade
- when 'not_equal_to' then project_target_grade != condition.target_grade
- else false
- end
- when 'TaskDefinitionStatusCondition'
- task = project.tasks.find { |t| t.task_definition_id == condition.task_definition_id }
- status = task&.task_status&.status_key&.to_s
- 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
- when 'TaskStatusCountCondition'
- statuses = condition.task_statuses || []
- count = project.tasks.count do |task|
- task.task_definition&.target_grade == condition.task_target_grade &&
- statuses.include?(task.task_status&.status_key&.to_s)
- end
-
- case condition.operator
- when 'greater_than' then count > condition.task_status_count
- when 'greater_than_or_equal_to' then count >= condition.task_status_count
- when 'less_than' then count < condition.task_status_count
- when 'less_than_or_equal_to' then count <= condition.task_status_count
- when 'equal_to' then count == condition.task_status_count
- when 'not_equal_to' then count != condition.task_status_count
- else false
- end
- when 'LoginStatusCondition'
- 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
- when 'TutorialEnrolmentCondition'
- enrolled = project.tutorial_enrolments.any? { |enrolment| enrolment.tutorial_id == condition.tutorial_id }
-
- condition.operator == 'not_enrolled_in' ? !enrolled : enrolled
- when 'TutorialStreamEnrolmentCondition'
- enrolled = project.tutorial_enrolments.any? do |enrolment|
- enrolment.tutorial&.tutorial_stream_id == condition.tutorial_stream_id
- end
-
- condition.operator == 'not_enrolled_in' ? !enrolled : enrolled
- when 'CampusCondition'
- enrolled = project.campus_id == condition.campus_id
-
- condition.operator == 'not_enrolled_in' ? !enrolled : enrolled
- else
- false
- end
- end
+ 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
+ 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 || []
+ count = project.tasks.count do |task|
+ task.task_definition&.target_grade == condition.task_target_grade &&
+ statuses.include?(task.task_status&.status_key&.to_s)
+ 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
index 230e3def5..320ef7383 100644
--- a/app/models/communication/communication_set.rb
+++ b/app/models/communication/communication_set.rb
@@ -4,6 +4,7 @@ class CommunicationSet < ApplicationRecord
has_many :communication_rules,
-> { order(:position) },
class_name: 'CommunicationRule',
+ inverse_of: :communication_set,
dependent: :destroy
validates :name, presence: true
From a8b9fcf828e08836ba975c6976a772b6761336de Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 7 May 2026 17:03:04 +1000
Subject: [PATCH 06/18] refactor: add action log email to convenors
---
app/api/communication_rules_api.rb | 6 +-
app/api/entities/communication_rule_entity.rb | 1 +
app/mailers/communications_mailer.rb | 21 ++
.../communication/communication_rule.rb | 14 +-
app/sidekiq/execute_communication_set_job.rb | 275 +++++++++++++++++-
.../action_log_email.html.erb | 69 +++++
.../action_log_email.text.erb | 18 ++
.../20260505022817_add_communications_feat.rb | 2 +
db/schema.rb | 1 +
9 files changed, 392 insertions(+), 15 deletions(-)
create mode 100644 app/views/communications_mailer/action_log_email.html.erb
create mode 100644 app/views/communications_mailer/action_log_email.text.erb
diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb
index 15ddee9c8..307f54a02 100644
--- a/app/api/communication_rules_api.rb
+++ b/app/api/communication_rules_api.rb
@@ -206,6 +206,7 @@ class CommunicationRulesApi < Grape::API
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
@@ -218,7 +219,7 @@ class CommunicationRulesApi < Grape::API
communication_set = unit.communication_sets.find(params[:communication_set_id])
rule_params = ActionController::Parameters.new(params)
.require(:communication_rule)
- .permit(:name, :operator, :position, :active)
+ .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)
@@ -234,6 +235,7 @@ class CommunicationRulesApi < Grape::API
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
@@ -246,7 +248,7 @@ class CommunicationRulesApi < Grape::API
rule = unit.communication_rules.find(params[:id])
rule_params = ActionController::Parameters.new(params)
.require(:communication_rule)
- .permit(:name, :operator, :position, :active)
+ .permit(:name, :operator, :position, :active, :send_log_to_convenors)
rule.update!(rule_params)
present rule, with: Entities::CommunicationRuleEntity
diff --git a/app/api/entities/communication_rule_entity.rb b/app/api/entities/communication_rule_entity.rb
index bc63728ef..97f56da5a 100644
--- a/app/api/entities/communication_rule_entity.rb
+++ b/app/api/entities/communication_rule_entity.rb
@@ -6,6 +6,7 @@ class CommunicationRuleEntity < Grape::Entity
expose :operator
expose :position
expose :active
+ expose :send_log_to_convenors
expose :communication_conditions,
as: :conditions,
using: Entities::CommunicationConditionEntity
diff --git a/app/mailers/communications_mailer.rb b/app/mailers/communications_mailer.rb
index c23d404bd..4139d4f33 100644
--- a/app/mailers/communications_mailer.rb
+++ b/app/mailers/communications_mailer.rb
@@ -13,4 +13,25 @@ def communication_email(to:, from:, subject:, body:, recipient:, sender:, unit:,
mail(to: to, from: from, subject: subject)
end
+
+ def action_log_email(to:, from:, subject:, body:, recipient:, sender:, unit:, rule:, csv_content:, csv_filename:, affected_students_count:)
+ @recipient = recipient
+ @sender = sender
+ @unit = unit
+ @rule = rule
+ @body = body.to_s
+ @body_paragraphs = @body.split(/\r?\n/).map(&:strip).reject(&:blank?)
+ @affected_students_count = 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[csv_filename] = {
+ mime_type: 'text/csv',
+ content: csv_content
+ }
+
+ mail(to: to, from: from, subject: subject)
+ end
end
diff --git a/app/models/communication/communication_rule.rb b/app/models/communication/communication_rule.rb
index fb719d698..781439314 100644
--- a/app/models/communication/communication_rule.rb
+++ b/app/models/communication/communication_rule.rb
@@ -58,7 +58,7 @@ def target_grade_condition_match?(project, condition)
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
+ status = task&.task_status&.status_key&.to_s || 'not_started'
statuses = condition.task_statuses || []
case condition.operator
@@ -70,9 +70,15 @@ def task_definition_status_condition_match?(project, condition)
def task_status_count_condition_match?(project, condition)
statuses = condition.task_statuses || []
- count = project.tasks.count do |task|
- task.task_definition&.target_grade == condition.task_target_grade &&
- statuses.include?(task.task_status&.status_key&.to_s)
+ 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)
diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb
index d9d4b7fd2..3865cffa1 100644
--- a/app/sidekiq/execute_communication_set_job.rb
+++ b/app/sidekiq/execute_communication_set_job.rb
@@ -1,3 +1,5 @@
+require 'csv'
+
class ExecuteCommunicationSetJob
include Sidekiq::Job
include Sidekiq::Status::Worker
@@ -44,8 +46,20 @@ def perform(communication_set_id, target_rule_id = nil)
should_execute_actions = target_rule_id.present? ? rule.id == target_rule_id : true
if should_execute_actions
- rule.communication_actions.each do |action|
- action_results.concat(execute_action(action, matched_projects, communication_set.unit, rule))
+ 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
@@ -131,8 +145,8 @@ def execute_email_student_action(action, projects, unit, rule)
}
end
- subject = render_template(action.subject, project, unit, rule)
- body = render_template(action.body, project, unit, rule)
+ 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),
@@ -172,8 +186,8 @@ def execute_email_staff_action(action, projects, unit, rule)
end
staff_recipients_for(project, unit, action).map do |recipient|
- subject = render_template(action.subject, project, unit, rule)
- body = render_template(action.body, project, unit, rule)
+ 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),
@@ -199,6 +213,61 @@ def execute_email_staff_action(action, projects, unit, rule)
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 = []
@@ -217,10 +286,11 @@ def staff_recipients_for(project, unit, action)
recipients.select { |recipient| recipient&.email.present? }.uniq(&:id)
end
- def render_template(template, project, unit, rule)
+ def render_template(template, project, unit, rule, affected_students_count, target_grade_override = nil, action_results = [])
return '' if template.blank?
- student = project.user
+ 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,
@@ -229,10 +299,13 @@ def render_template(template, project, unit, rule)
'{{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(project.target_grade)
+ '{{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)|
@@ -257,4 +330,188 @@ def sender_for(unit)
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/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/db/migrate/20260505022817_add_communications_feat.rb b/db/migrate/20260505022817_add_communications_feat.rb
index 80762ab99..0ae4b66cc 100644
--- a/db/migrate/20260505022817_add_communications_feat.rb
+++ b/db/migrate/20260505022817_add_communications_feat.rb
@@ -15,6 +15,8 @@ def change
# 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
diff --git a/db/schema.rb b/db/schema.rb
index e4538dad7..fc2eeb70f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -116,6 +116,7 @@
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
From 85471c2c6afd5b93061258bd49c8a6f903ec8a4b Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 7 May 2026 17:05:41 +1000
Subject: [PATCH 07/18] fix: rubocop
---
app/mailers/communications_mailer.rb | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/app/mailers/communications_mailer.rb b/app/mailers/communications_mailer.rb
index 4139d4f33..95034342d 100644
--- a/app/mailers/communications_mailer.rb
+++ b/app/mailers/communications_mailer.rb
@@ -14,24 +14,24 @@ def communication_email(to:, from:, subject:, body:, recipient:, sender:, unit:,
mail(to: to, from: from, subject: subject)
end
- def action_log_email(to:, from:, subject:, body:, recipient:, sender:, unit:, rule:, csv_content:, csv_filename:, affected_students_count:)
- @recipient = recipient
- @sender = sender
- @unit = unit
- @rule = rule
- @body = body.to_s
+ 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 = affected_students_count
+ @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[csv_filename] = {
+ attachments[payload[:csv_filename]] = {
mime_type: 'text/csv',
- content: csv_content
+ content: payload[:csv_content]
}
- mail(to: to, from: from, subject: subject)
+ mail(to: payload[:to], from: payload[:from], subject: payload[:subject])
end
end
From 6dec9b491f7a6019e85f4a59374d0b3192f076fe Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Tue, 12 May 2026 16:45:41 +1000
Subject: [PATCH 08/18] feat: add scheduling system
---
Gemfile | 1 +
Gemfile.lock | 1 +
app/api/communication_rules_api.rb | 199 +++++++++++++++++-
app/api/entities/communication_set_entity.rb | 4 +
.../communication_set_schedule_entity.rb | 21 ++
app/models/communication/communication_set.rb | 5 +
.../communication_set_schedule.rb | 160 ++++++++++++++
app/models/unit.rb | 9 +-
.../execute_communication_set_schedule_job.rb | 23 ++
.../poll_communication_set_schedules_job.rb | 18 ++
config/initializers/sidekiq.rb | 5 +
config/schedule.yml | 4 +
.../20260505022817_add_communications_feat.rb | 33 +++
db/schema.rb | 33 +++
test/sidekiq/scheduled_job_test.rb | 3 +-
15 files changed, 514 insertions(+), 5 deletions(-)
create mode 100644 app/api/entities/communication_set_schedule_entity.rb
create mode 100644 app/models/communication/communication_set_schedule.rb
create mode 100644 app/sidekiq/execute_communication_set_schedule_job.rb
create mode 100644 app/sidekiq/poll_communication_set_schedules_job.rb
diff --git a/Gemfile b/Gemfile
index 90b4021f6..1475fb1ef 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 6bb903f2a..521d7ba80 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -582,6 +582,7 @@ DEPENDENCIES
grape-swagger-rails
hirb
icalendar
+ ice_cube
json-jwt
listen
minitest
diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb
index 307f54a02..2c436311c 100644
--- a/app/api/communication_rules_api.rb
+++ b/app/api/communication_rules_api.rb
@@ -3,12 +3,56 @@
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?
@@ -25,7 +69,7 @@ class CommunicationRulesApi < Grape::API
error!({ error: 'Not authorised to get unit communications' }, 403)
end
- present unit.communication_sets.includes(communication_rules: [:communication_conditions, :communication_actions]),
+ present unit.communication_sets.includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions]),
with: Entities::CommunicationSetEntity
end
@@ -42,7 +86,7 @@ class CommunicationRulesApi < Grape::API
end
communication_set = unit.communication_sets
- .includes(communication_rules: [:communication_conditions, :communication_actions])
+ .includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions])
.find(params[:id])
previews = communication_set.preview_allocations_by_rule
@@ -52,6 +96,7 @@ class CommunicationRulesApi < Grape::API
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|
{
@@ -87,6 +132,19 @@ class CommunicationRulesApi < Grape::API
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
@@ -101,6 +159,8 @@ class CommunicationRulesApi < Grape::API
.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
@@ -111,6 +171,20 @@ class CommunicationRulesApi < Grape::API
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
@@ -126,6 +200,8 @@ class CommunicationRulesApi < Grape::API
.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
@@ -164,6 +240,125 @@ class CommunicationRulesApi < Grape::API
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
diff --git a/app/api/entities/communication_set_entity.rb b/app/api/entities/communication_set_entity.rb
index 876e6a024..a9b6029b9 100644
--- a/app/api/entities/communication_set_entity.rb
+++ b/app/api/entities/communication_set_entity.rb
@@ -1,4 +1,5 @@
require 'entities/communication_rule_entity'
+require 'entities/communication_set_schedule_entity'
module Entities
class CommunicationSetEntity < Grape::Entity
@@ -6,6 +7,9 @@ class CommunicationSetEntity < Grape::Entity
expose :unit_id
expose :name
expose :active
+ expose :communication_set_schedules,
+ as: :schedules,
+ using: Entities::CommunicationSetScheduleEntity
expose :communication_rules,
as: :rules,
using: Entities::CommunicationRuleEntity
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/models/communication/communication_set.rb b/app/models/communication/communication_set.rb
index 320ef7383..1b4e5e33b 100644
--- a/app/models/communication/communication_set.rb
+++ b/app/models/communication/communication_set.rb
@@ -1,6 +1,11 @@
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',
diff --git a/app/models/communication/communication_set_schedule.rb b/app/models/communication/communication_set_schedule.rb
new file mode 100644
index 000000000..b0bf8e005
--- /dev/null
+++ b/app/models/communication/communication_set_schedule.rb
@@ -0,0 +1,160 @@
+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) }
+
+ 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)
+ if recurrence == 'none'
+ occurrence = schedule.start_time
+ occurrence >= time_for_schedule ? occurrence : nil
+ else
+ schedule.next_occurrence(time_for_schedule)
+ end
+ end
+
+ def due?(time = Time.zone.now)
+ active? && next_run_at.present? && next_run_at <= time
+ end
+
+ def refresh_next_run_at!(from_time = Time.zone.now)
+ update!(next_run_at: next_occurrence_after(from_time))
+ 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? && should_refresh_next_run_at?
+ self.next_run_at = nil unless active?
+ 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/unit.rb b/app/models/unit.rb
index 9edb30d35..06da8d180 100644
--- a/app/models/unit.rb
+++ b/app/models/unit.rb
@@ -159,6 +159,7 @@ def role_for(user)
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
@@ -1552,15 +1553,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/execute_communication_set_schedule_job.rb b/app/sidekiq/execute_communication_set_schedule_job.rb
new file mode 100644
index 000000000..62088ed0b
--- /dev/null
+++ b/app/sidekiq/execute_communication_set_schedule_job.rb
@@ -0,0 +1,23 @@
+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?
+
+ 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..1424588c8
--- /dev/null
+++ b/app/sidekiq/poll_communication_set_schedules_job.rb
@@ -0,0 +1,18 @@
+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
+ .due(Time.zone.now)
+ .find_each do |schedule|
+ ExecuteCommunicationSetScheduleJob.perform_async(schedule.id)
+ end
+ end
+end
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/20260505022817_add_communications_feat.rb b/db/migrate/20260505022817_add_communications_feat.rb
index 0ae4b66cc..1d228a85f 100644
--- a/db/migrate/20260505022817_add_communications_feat.rb
+++ b/db/migrate/20260505022817_add_communications_feat.rb
@@ -78,7 +78,40 @@ def change
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 fc2eeb70f..15e61aeca 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -123,6 +123,30 @@
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"
@@ -464,6 +488,15 @@
t.index ["user_id"], name: "index_task_comments_on_user_id"
end
+ create_table "task_completion_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "unit_id", null: false
+ t.string "snapshot_timestamp", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["unit_id", "snapshot_timestamp"], name: "idx_on_unit_id_snapshot_timestamp_e923c3ae10", unique: true
+ t.index ["unit_id"], name: "index_task_completion_snapshots_on_unit_id"
+ end
+
create_table "task_definition_grade_due_dates", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "task_definition_id", null: false
t.integer "target_grade", null: false
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
From eef470cc9dec7db23a8057cdb0f893ffbf7fd708 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Tue, 12 May 2026 16:55:11 +1000
Subject: [PATCH 09/18] fix: rubocop
---
app/models/unit.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/models/unit.rb b/app/models/unit.rb
index 06da8d180..079fa528e 100644
--- a/app/models/unit.rb
+++ b/app/models/unit.rb
@@ -1565,7 +1565,7 @@ def week_number(date)
else
target_date = date.to_date
unit_start_date = start_date.to_date
- (((target_date - unit_start_date).to_i) / 7).floor + 1
+ ((target_date - unit_start_date).to_i / 7).floor + 1
end
end
From df00daa28625219ca0295fe8a4b806baf5b3af9a Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Mon, 1 Jun 2026 09:51:57 +1000
Subject: [PATCH 10/18] fix: attempt nil tutorials fix
---
test/factories/units_factory.rb | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
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
From fcf371ce49365abfbe1f0eb4c08d42731e432053 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Mon, 1 Jun 2026 10:39:51 +1000
Subject: [PATCH 11/18] chore: fix test
---
test/models/communication_set_test.rb | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/test/models/communication_set_test.rb b/test/models/communication_set_test.rb
index d12336b79..7de4ea379 100644
--- a/test/models/communication_set_test.rb
+++ b/test/models/communication_set_test.rb
@@ -2,7 +2,16 @@
class CommunicationSetTest < ActiveSupport::TestCase
def test_preview_projects_for_rule_excludes_students_claimed_by_earlier_rules
- unit = FactoryBot.create(:unit, student_count: 2, task_count: 1, stream_count: 0, tutorials: 0)
+ 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)
From b7c6f1c02e60703a02e6f89d87072921f26cf8ac Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Mon, 1 Jun 2026 11:18:07 +1000
Subject: [PATCH 12/18] feat: enable rollover of communication sets
---
app/models/communication/communication_set.rb | 62 +++++++++++++++
app/models/unit.rb | 4 +
test/models/unit_model_test.rb | 77 +++++++++++++++++++
3 files changed, 143 insertions(+)
diff --git a/app/models/communication/communication_set.rb b/app/models/communication/communication_set.rb
index 1b4e5e33b..d59e31240 100644
--- a/app/models/communication/communication_set.rb
+++ b/app/models/communication/communication_set.rb
@@ -47,4 +47,66 @@ def preview_allocations_for_rule(target_rule)
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/unit.rb b/app/models/unit.rb
index 079fa528e..4e1d0d149 100644
--- a/app/models/unit.rb
+++ b/app/models/unit.rb
@@ -438,6 +438,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 = {}
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"))
From ad85a5d6dd8146bf6f01cc8b6b5743b4e66dc321 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Mon, 1 Jun 2026 11:34:01 +1000
Subject: [PATCH 13/18] refactor: ensure schedule next run is recalculated when
updating teaching periods
---
app/models/break.rb | 9 ++++++++
app/models/teaching_period.rb | 12 ++++++++++
test/models/teaching_period_test.rb | 36 +++++++++++++++++++++++++++++
3 files changed, 57 insertions(+)
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/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/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
From f525787e1b61486176cca6748c9212af7ee18754 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Tue, 2 Jun 2026 10:32:27 +1000
Subject: [PATCH 14/18] chore: remove schema
---
db/schema.rb | 9 ---------
1 file changed, 9 deletions(-)
diff --git a/db/schema.rb b/db/schema.rb
index 15e61aeca..e97485310 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -488,15 +488,6 @@
t.index ["user_id"], name: "index_task_comments_on_user_id"
end
- create_table "task_completion_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
- t.bigint "unit_id", null: false
- t.string "snapshot_timestamp", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["unit_id", "snapshot_timestamp"], name: "idx_on_unit_id_snapshot_timestamp_e923c3ae10", unique: true
- t.index ["unit_id"], name: "index_task_completion_snapshots_on_unit_id"
- end
-
create_table "task_definition_grade_due_dates", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "task_definition_id", null: false
t.integer "target_grade", null: false
From 81ec8fbc0058f3930c9de7c4ebb10444a2de3591 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Wed, 3 Jun 2026 12:27:11 +1000
Subject: [PATCH 15/18] feat: ensure communication sets only execute when the
unit is active
---
.../communication_set_schedule.rb | 13 ++--
app/models/unit.rb | 5 ++
.../execute_communication_set_schedule_job.rb | 1 +
.../poll_communication_set_schedules_job.rb | 1 +
.../models/communication_set_schedule_test.rb | 40 +++++++++++++
.../communication_set_schedule_jobs_test.rb | 60 +++++++++++++++++++
6 files changed, 116 insertions(+), 4 deletions(-)
create mode 100644 test/models/communication_set_schedule_test.rb
create mode 100644 test/sidekiq/communication_set_schedule_jobs_test.rb
diff --git a/app/models/communication/communication_set_schedule.rb b/app/models/communication/communication_set_schedule.rb
index b0bf8e005..0125676fa 100644
--- a/app/models/communication/communication_set_schedule.rb
+++ b/app/models/communication/communication_set_schedule.rb
@@ -25,6 +25,7 @@ class CommunicationSetSchedule < ApplicationRecord
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?
@@ -63,11 +64,11 @@ def next_occurrence_after(time = Time.zone.now)
end
def due?(time = Time.zone.now)
- active? && next_run_at.present? && next_run_at <= time
+ 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: next_occurrence_after(from_time))
+ update!(next_run_at: active_for_scheduling? ? next_occurrence_after(from_time) : nil)
end
def anchor_day_abbreviation
@@ -121,8 +122,12 @@ def default_timezone
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? && should_refresh_next_run_at?
- self.next_run_at = nil unless active?
+ 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 should_refresh_next_run_at?
diff --git a/app/models/unit.rb b/app/models/unit.rb
index 4e1d0d149..78da40967 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_active?
# Model associations.
# When a Unit is destroyed, any TaskDefinitions, Tutorials, and ProjectConvenor instances will also be destroyed.
@@ -241,6 +242,10 @@ def active_projects
projects.where(enrolled: true)
end
+ def refresh_communication_schedule_caches
+ communication_set_schedules.find_each(&:refresh_next_run_at!)
+ end
+
def ordered_task_definitions
task_definitions.order('start_date ASC, abbreviation ASC')
end
diff --git a/app/sidekiq/execute_communication_set_schedule_job.rb b/app/sidekiq/execute_communication_set_schedule_job.rb
index 62088ed0b..6960a96dd 100644
--- a/app/sidekiq/execute_communication_set_schedule_job.rb
+++ b/app/sidekiq/execute_communication_set_schedule_job.rb
@@ -9,6 +9,7 @@ class ExecuteCommunicationSetScheduleJob
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)
diff --git a/app/sidekiq/poll_communication_set_schedules_job.rb b/app/sidekiq/poll_communication_set_schedules_job.rb
index 1424588c8..b5f0f803f 100644
--- a/app/sidekiq/poll_communication_set_schedules_job.rb
+++ b/app/sidekiq/poll_communication_set_schedules_job.rb
@@ -10,6 +10,7 @@ def perform
CommunicationSetSchedule
.includes(:communication_set)
.active
+ .with_active_unit
.due(Time.zone.now)
.find_each do |schedule|
ExecuteCommunicationSetScheduleJob.perform_async(schedule.id)
diff --git a/test/models/communication_set_schedule_test.rb b/test/models/communication_set_schedule_test.rb
new file mode 100644
index 000000000..afdc2401d
--- /dev/null
+++ b/test/models/communication_set_schedule_test.rb
@@ -0,0 +1,40 @@
+require 'test_helper'
+
+class CommunicationSetScheduleTest < ActiveSupport::TestCase
+ def test_next_run_at_refreshes_when_unit_active_status_changes
+ travel_to Time.zone.local(2026, 1, 1, 9, 0, 0) do
+ unit = FactoryBot.create(
+ :unit,
+ active: true,
+ 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: 'Week 1 Monday',
+ active: true,
+ anchor_week: 1,
+ anchor_day: 'Monday',
+ hour: 9,
+ minute: 30,
+ timezone: 'UTC',
+ recurrence: 'none',
+ interval: 1
+ )
+
+ 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
+end
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
From 92f1b5e9adeee98323f88f59cbd32b924b70f5da Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Wed, 3 Jun 2026 12:33:50 +1000
Subject: [PATCH 16/18] feat: ensure communication schedules dont execute
outside of unit start/end dates
---
.../communication_set_schedule.rb | 22 +++--
app/models/unit.rb | 6 +-
.../models/communication_set_schedule_test.rb | 82 +++++++++++++------
3 files changed, 79 insertions(+), 31 deletions(-)
diff --git a/app/models/communication/communication_set_schedule.rb b/app/models/communication/communication_set_schedule.rb
index 0125676fa..26f0a4162 100644
--- a/app/models/communication/communication_set_schedule.rb
+++ b/app/models/communication/communication_set_schedule.rb
@@ -55,12 +55,16 @@ def next_occurrence_after(time = Time.zone.now)
return nil if schedule.nil?
time_for_schedule = time.in_time_zone(timezone_object)
- if recurrence == 'none'
- occurrence = schedule.start_time
- occurrence >= time_for_schedule ? occurrence : nil
- else
- schedule.next_occurrence(time_for_schedule)
- end
+ 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)
@@ -130,6 +134,12 @@ 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? ||
diff --git a/app/models/unit.rb b/app/models/unit.rb
index 78da40967..b7943ff1a 100644
--- a/app/models/unit.rb
+++ b/app/models/unit.rb
@@ -146,7 +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_active?
+ 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.
@@ -246,6 +246,10 @@ 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
diff --git a/test/models/communication_set_schedule_test.rb b/test/models/communication_set_schedule_test.rb
index afdc2401d..435185995 100644
--- a/test/models/communication_set_schedule_test.rb
+++ b/test/models/communication_set_schedule_test.rb
@@ -1,32 +1,34 @@
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 = FactoryBot.create(
- :unit,
- active: true,
- 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: 'Week 1 Monday',
- active: true,
- anchor_week: 1,
- anchor_day: 'Monday',
- hour: 9,
- minute: 30,
- timezone: 'UTC',
- recurrence: 'none',
- interval: 1
- )
+ 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
@@ -37,4 +39,36 @@ def test_next_run_at_refreshes_when_unit_active_status_changes
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
From 239da7a062e3ff9d88530fea0dd472f59d3a89c4 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 4 Jun 2026 16:59:48 +1000
Subject: [PATCH 17/18] chore: rollback migration
---
db/schema.rb | 85 +---------------------------------------------------
1 file changed, 1 insertion(+), 84 deletions(-)
diff --git a/db/schema.rb b/db/schema.rb
index e97485310..1e49bb202 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_05_05_022817) do
+ActiveRecord::Schema[8.0].define(version: 2026_03_27_041457) 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,89 +73,6 @@
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"
From 1ca9ab0cca1cd2ad5ae060d6e4b08a37bb3bc165 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 4 Jun 2026 17:01:02 +1000
Subject: [PATCH 18/18] chore: bump migration
---
...20260604070032_add_communications_feat.rb} | 0
db/schema.rb | 85 ++++++++++++++++++-
2 files changed, 84 insertions(+), 1 deletion(-)
rename db/migrate/{20260505022817_add_communications_feat.rb => 20260604070032_add_communications_feat.rb} (100%)
diff --git a/db/migrate/20260505022817_add_communications_feat.rb b/db/migrate/20260604070032_add_communications_feat.rb
similarity index 100%
rename from db/migrate/20260505022817_add_communications_feat.rb
rename to db/migrate/20260604070032_add_communications_feat.rb
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"