app/models/setup/scheduler.rb
module Setup
class Scheduler < Event
include HashField
include Switchable
# = Scheduler
#
# Are events triggered on a certain moment and can be optionally recurrent.
#
# Options can be specified for the moment the scheduler would be triggered for the first time,
# if and how it should be rescheduled and finally when should it stop.
#
# All this options default to a 'Start on activation, run every day' setting.
# If the scheduler should not start immediately after activation, a date can be specified for it.
#
# Schedulers can be a one-time execution, or a specific periodicity, but can also be triggered on appointed dates.
# For periodical executions choose the 'Every ...' option, enter a number in the input box and select the time span.
#
# Otherwise, for appointed dates choose the 'Select manually'.
# The first thing for manual scheduling would be to specify the time of day the scheduler should be triggered at
# and then decide on a week-based approach or a month-based approach:
#
# Week-based: Note selected weekdays and months on the year, this setting would have the scheduler trigger on
# the Last Sunday of June and December.
#
# Month-based: this setting would have the scheduler trigger on the 14th and Last day of June and December.
#
# It would be good to notice that for any specific setting on the manual approach, selecting no item is the same as selecting all of them.
# That is, if a scheduler is to be triggered everyday of the month in June, check only the month June and leave all days unchecked.
# You could off course check each individual day by yourself, but it is an unnecessary trouble. This rule applies to Week-based
# approach as well.
# Finally, task can be scheduled to end, so that it does not get queued for execution passed some date, it is very much
# like the starting date setting.
build_in_data_type.with(:namespace, :name, :expression, :activated).referenced_by(:namespace, :name)
hash_field :expression
field :activated, type: Mongoid::Boolean, default: false
has_many :delayed_messages, class_name: Setup::DelayedMessage.to_s, inverse_of: :scheduler
validates_presence_of :name
scope :activated, -> { where(activated: true) }
validate do
begin
Mongoff::Validator.validate_instance(
expression,
schema: SCHEMA,
data_type: self.class.data_type
)
rescue Exception => e
errors.add(:expression, e.message)
end
errors.blank?
end
def check_before_save
@activation_status_changed = changed_attributes.key?(:activated.to_s)
# if expression['type'] == 'cyclic'
# self.expression = { type: 'cyclic', cyclic_expression: expression['cyclic_expression'] }
# else
expression.reject! { |_, value| value.blank? }
# end
errors.blank?
end
after_save { (activated ? start : stop) if @activation_status_changed }
before_destroy { stop }
def custom_title
super + ' [' + (activated? ? 'on' : 'off') + ']' +
(origin == :admin ? ' (ADMIN)' : '')
end
def activated?
activated.present?
end
def deactivated?
!activated?
end
def start
return unless next_time
Setup::Task.where(scheduler: self).each do |task|
task.retry(action: :scheduled) unless TaskToken.where(task_id: task.id).exists?
end
Setup::Flow.where(event: self).each do |flow|
flow.process(scheduler: self) unless Setup::FlowExecution.where(flow: flow, scheduler: self).exists?
end
end
def stop
delayed_messages.each do |delayed_message|
delayed_message.update(unscheduled: true)
end
end
def activate
update(activated: true) unless activated?
end
def deactivate
update(activated: false) unless deactivated?
end
def switch
if activated?
deactivate
else
activate
end
end
def next_time
calculator = SchedulerTimePointsCalculator.new(expression, Time.now.year, Account.current.time_zone_offset)
(next_time = calculator.next_time(Time.now.utc)) && next_time.localtime
end
SCHEMA = {
type: 'object',
properties: {
cyclic_expression: {
type: 'string',
pattern: '^[1-9][0-9]*(s|m|h|d|w|M)$'
},
type: {
type: 'string',
enum: %w(cyclic appointed)
},
months_days: {
type: 'array',
items: {
type: 'integer'
},
uniqueItems: true,
maxItems: 31
},
weeks_days: {
type: 'array',
items: {
type: 'integer'
},
uniqueItems: true,
maxItems: 7
},
weeks_month: {
type: 'array',
items: {
type: 'integer'
},
uniqueItems: true,
maxItems: 5
},
months: {
type: 'array',
items: {
type: 'integer'
},
uniqueItems: true,
maxItems: 12
},
hours: {
type: 'array',
items: {
type: 'integer'
},
uniqueItems: true,
maxItems: 24
},
minutes: {
type: 'array',
items: {
type: 'integer'
},
uniqueItems: true,
maxItems: 60
},
last_day_in_month: {
type: 'boolean'
},
last_week_in_month: {
type: 'boolean'
},
start_at: {
type: 'string',
format: 'date-time'
},
frequency: {
type: 'integer'
},
end_at: {
type: 'string',
format: 'date-time'
},
max_repeat: {
type: 'integer'
}
},
required: %w(type),
additionalProperties: false
}.deep_stringify_keys
end
class SchedulerTimePointsCalculator
THIRTY_ONE_MONTHS = Set.new [1, 3, 5, 7, 8, 10, 12]
def amount_of_days_in_the_month(year, month)
if THIRTY_ONE_MONTHS.include?(month)
31
else
(Time.gm(year, month + 1, 1) - 1).day
end
end
def weeks_first_days(year, dd, m)
res = []
d1 = Time.gm(year, m, 1)
d2 = Time.gm(year, m, 21)
sunday = d1 + ((7 + dd - d1.wday) % 7) * 1.day
while sunday < d2
res << sunday.day
sunday += 1.day * 7
end
res
end
def all_days(year, dd, m)
res = []
d1 = Time.gm(year, m, 1)
d2 = Time.gm(year, m, amount_of_days_in_the_month(year, m))
sunday = d1 + ((7 + dd - d1.wday) % 7) * 1.day
while sunday < d2
res << sunday.day
sunday += 1.day * 7
end
res
end
def last_day(year, dd, m)
d1 = Time.gm(year, m, amount_of_days_in_the_month(year, m))
d1 -= 1.day while d1.wday != dd
d1
end
def weeks_last_days(year, dd, m)
res = []
d2 = Time.gm(year, m, amount_of_days_in_the_month(year, m) - 14)
sunday = last_day(year, dd, m)
while sunday > d2
res << sunday.day
sunday -= 1.day * 7
end
res
end
def days
month = @solution[0]
weeks_days = @conf[:weeks_days]
weeks_days = (0..6).to_a if weeks_days.blank?
weeks_month = @conf[:weeks_month]
weeks_month = (0..3).to_a if weeks_month.blank?
a = amount_of_days_in_the_month(@year, month)
if @conf[:type] == 'appointed_position'
months_days = []
# Retrieve days by weeks
if weeks_month.length.positive?
weeks_month.each do |wm|
if wm.positive?
# firsts one
months_days += weeks_days.collect { |wd| weeks_first_days(@year, wd, month)[wm - 1] }
else
# lasts one
months_days += weeks_days.collect { |wd| weeks_last_days(@year, wd, month)[wm.abs - 1] }
end
end
else
months_days = weeks_days.collect { |wd| all_days(@year, wd, month) }
months_days.flatten!
end
months_days << a if @conf[:last_day_in_month] && !months_days.include?(a)
else
months_days = @conf[:months_days]
end
months_days = (1..a).to_a if months_days.blank?
months_days.select { |e| e.positive? && e <= a }
end
def hours
res = @conf[:hours]
res = (0..23).to_a if res.blank?
res.select { |e| e > -1 && e <= 23 }
end
def minutes
res = @conf[:minutes]
res = (1..59).to_a if res.blank?
res.select { |e| e > -1 && e <= 59 }
end
def months
res = @conf[:months]
res = (1..12).to_a if res.blank?
res.select { |e| e.positive? && e <= 12 }
end
def initialize(conf, year, tz)
conf = JSON.parse(conf.to_s) unless conf.is_a?(Hash)
@conf = conf.deep_symbolize_keys
@actions = [->() { months }, ->() { days }, ->() { hours }, ->() { minutes }]
@year = year
@tz = tz
end
def run(now)
@solution = [now.month, now.day, now.hour, now.min]
@v = []
backtracking(0)
@v
end
def report_solution
@v << Time.new(@year, *@solution, 0, @tz)
end
def backtracking(k)
if k > 3
report_solution
else
@actions[k].call.each do |e|
@solution[k] = e
backtracking(k + 1)
end
end
end
def next_time(now)
case @conf[:type]
when 'cyclic'
a = @conf[:cyclic_expression].to_seconds_interval
b = Cenit.min_scheduler_interval || 60
now + [a, b].max
when 'appointed'
run(now)
res = @v.select { |e| e > now }.collect { |e| e - now }.min
res ? now + res : nil
else
nil # Unknown scheduling type
end
end
end
end
class String
def to_seconds_interval
case last
when 's'
1
when 'm'
60
when 'h'
60 * 60
when 'd'
24 * 60 * 60
else
0
end * chop.to_i
end
end