better_model 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +211 -11
- data/lib/better_model/archivable.rb +3 -2
- data/lib/better_model/predicable.rb +73 -52
- data/lib/better_model/schedulable/occurrence_calculator.rb +1034 -0
- data/lib/better_model/schedulable/schedule_builder.rb +269 -0
- data/lib/better_model/schedulable.rb +356 -0
- data/lib/better_model/searchable.rb +4 -4
- data/lib/better_model/taggable.rb +466 -0
- data/lib/better_model/traceable.rb +123 -9
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model/version_record.rb +6 -3
- data/lib/better_model.rb +6 -0
- data/lib/generators/better_model/taggable/taggable_generator.rb +129 -0
- data/lib/generators/better_model/taggable/templates/README.tt +62 -0
- data/lib/generators/better_model/taggable/templates/migration.rb.tt +21 -0
- metadata +9 -2
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterModel
|
|
4
|
+
module Schedulable
|
|
5
|
+
# ScheduleBuilder - DSL per costruire configurazioni di schedule
|
|
6
|
+
#
|
|
7
|
+
# Questo builder implementa il DSL fluent per definire pattern di ricorrenza,
|
|
8
|
+
# eccezioni, e vincoli temporali.
|
|
9
|
+
#
|
|
10
|
+
# Esempio:
|
|
11
|
+
# builder = ScheduleBuilder.new(event)
|
|
12
|
+
# builder.recurs(:daily) do
|
|
13
|
+
# at ['09:00']
|
|
14
|
+
# except weekday: [:saturday]
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
class ScheduleBuilder
|
|
18
|
+
attr_reader :record, :config
|
|
19
|
+
|
|
20
|
+
VALID_FREQUENCIES = %w[
|
|
21
|
+
daily every_n_days
|
|
22
|
+
weekly biweekly every_n_weeks
|
|
23
|
+
monthly every_n_months
|
|
24
|
+
yearly every_n_years
|
|
25
|
+
every_n_hours every_n_minutes
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(record)
|
|
29
|
+
@record = record
|
|
30
|
+
@config = {}
|
|
31
|
+
@recurrence_builder = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Definisce un pattern di ricorrenza
|
|
35
|
+
#
|
|
36
|
+
# @param frequency [Symbol] :daily, :weekly, :monthly, :yearly, :every_n_days, etc.
|
|
37
|
+
# @yield [builder] Blocco per configurare il pattern
|
|
38
|
+
def recurs(frequency, &block)
|
|
39
|
+
freq_str = frequency.to_s
|
|
40
|
+
unless VALID_FREQUENCIES.include?(freq_str)
|
|
41
|
+
raise ArgumentError, "Invalid frequency: '#{frequency}'. Valid options: #{VALID_FREQUENCIES.join(', ')}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@config[:frequency] = freq_str
|
|
45
|
+
|
|
46
|
+
# Crea builder per la ricorrenza specifica
|
|
47
|
+
@recurrence_builder = RecurrenceBuilder.new(@config)
|
|
48
|
+
|
|
49
|
+
# Supporta due stili:
|
|
50
|
+
# 1. Con parametro: s.recurs(:daily) {|r| r.at [...] }
|
|
51
|
+
# 2. Senza parametro: s.recurs(:daily) { at [...] }
|
|
52
|
+
if block_given?
|
|
53
|
+
# DEBUG
|
|
54
|
+
# puts "Block arity: #{block.arity}"
|
|
55
|
+
|
|
56
|
+
if block.arity.zero?
|
|
57
|
+
# Nessun parametro → usa instance_eval
|
|
58
|
+
@recurrence_builder.instance_eval(&block)
|
|
59
|
+
else
|
|
60
|
+
# Con parametro → yield normale
|
|
61
|
+
yield @recurrence_builder
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Converte in hash per serializzazione
|
|
69
|
+
def to_h
|
|
70
|
+
@config
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# RecurrenceBuilder - Builder interno per pattern di ricorrenza
|
|
74
|
+
class RecurrenceBuilder
|
|
75
|
+
VALID_WEEKDAYS = %w[monday tuesday wednesday thursday friday saturday sunday].freeze
|
|
76
|
+
|
|
77
|
+
def initialize(config)
|
|
78
|
+
@config = config
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Imposta gli orari
|
|
82
|
+
# @param times [Array<String>] Array di orari tipo ['09:00', '15:00']
|
|
83
|
+
def at(times)
|
|
84
|
+
times_array = Array(times)
|
|
85
|
+
times_array.each do |time_str|
|
|
86
|
+
unless valid_time_format?(time_str)
|
|
87
|
+
raise ArgumentError, "Invalid time format: '#{time_str}'. Expected HH:MM (00:00-23:59)"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
@config[:time] = times_array
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Imposta i vincoli sui giorni
|
|
94
|
+
# @option options [Array<Symbol>] :weekday Giorni della settimana [:monday, :friday]
|
|
95
|
+
# @option options [Array<Integer>] :day Giorni del mese [1, 15, -1]
|
|
96
|
+
# @option options [Array<String>] :date Date annuali ['03-15', '12-25']
|
|
97
|
+
# @option options [Integer] :occurrence N-esimo giorno (per monthly)
|
|
98
|
+
def on(**options)
|
|
99
|
+
if options[:weekday]
|
|
100
|
+
weekdays = Array(options[:weekday]).map(&:to_s)
|
|
101
|
+
invalid = weekdays - VALID_WEEKDAYS
|
|
102
|
+
|
|
103
|
+
if invalid.any?
|
|
104
|
+
raise ArgumentError, "Invalid weekdays: #{invalid.join(', ')}. Valid options: #{VALID_WEEKDAYS.join(', ')}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@config[:weekday] = weekdays
|
|
108
|
+
|
|
109
|
+
# Se c'è occurrence, è monthly con N-esimo weekday
|
|
110
|
+
if options[:occurrence]
|
|
111
|
+
@config[:occurrence] = options[:occurrence]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if options[:day]
|
|
116
|
+
@config[:day] = Array(options[:day])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if options[:date]
|
|
120
|
+
dates = Array(options[:date])
|
|
121
|
+
dates.each do |date_str|
|
|
122
|
+
unless valid_date_format?(date_str)
|
|
123
|
+
raise ArgumentError, "Invalid date format: '#{date_str}'. Expected MM-DD (e.g., '03-15', '12-25')"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
@config[:date] = dates
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Imposta intervallo (per every_n_days, every_n_weeks, etc.)
|
|
131
|
+
# @param value [Integer] Numero di giorni/settimane/mesi/ore/minuti
|
|
132
|
+
def interval(value)
|
|
133
|
+
int_value = value.to_i
|
|
134
|
+
raise ArgumentError, "Interval must be a positive integer, got: #{value}" if int_value <= 0
|
|
135
|
+
@config[:interval] = int_value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Imposta range temporale
|
|
139
|
+
# @param start_time [String, Time, Array] Inizio (o array di time range)
|
|
140
|
+
# @param end_time [String, Time] Fine
|
|
141
|
+
def between(start_time, end_time = nil)
|
|
142
|
+
if end_time
|
|
143
|
+
# Range orario o date range
|
|
144
|
+
@config[:between] = {
|
|
145
|
+
start: start_time.is_a?(String) ? start_time : start_time.iso8601,
|
|
146
|
+
end: end_time.is_a?(String) ? end_time : end_time.iso8601
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
# Solo orario range (array)
|
|
150
|
+
if start_time.is_a?(Array)
|
|
151
|
+
# Validate time format for array ranges
|
|
152
|
+
start_time.each do |time_str|
|
|
153
|
+
next unless time_str.is_a?(String)
|
|
154
|
+
unless valid_time_format?(time_str)
|
|
155
|
+
raise ArgumentError, "Invalid time format in between range: '#{time_str}'. Expected HH:MM (00:00-23:59)"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
@config[:between] = start_time
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Imposta durata evento
|
|
164
|
+
# @param duration [ActiveSupport::Duration] Durata
|
|
165
|
+
def duration(duration)
|
|
166
|
+
@config[:duration] = duration.to_i # Salva in secondi
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Imposta data di inizio
|
|
170
|
+
# @param date [String, Date] Data di inizio
|
|
171
|
+
def starting_from(date)
|
|
172
|
+
date_str = date.is_a?(String) ? date : date.to_s
|
|
173
|
+
|
|
174
|
+
# Validate date format
|
|
175
|
+
begin
|
|
176
|
+
Date.parse(date_str) if date_str.is_a?(String)
|
|
177
|
+
rescue ArgumentError => e
|
|
178
|
+
raise ArgumentError, "Invalid starting_from date format: '#{date_str}'. #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
@config[:starting_from] = date_str
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Imposta data di fine
|
|
185
|
+
# @param date [String, Date] Data di fine
|
|
186
|
+
def ending_at(date)
|
|
187
|
+
@config[:ending_at] = date.is_a?(String) ? date : date.to_s
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Alias per ending_at
|
|
191
|
+
def for_duration(duration)
|
|
192
|
+
starting = @config[:starting_from] || Time.current.to_date.to_s
|
|
193
|
+
ending = (Date.parse(starting) + duration).to_s
|
|
194
|
+
ending_at(ending)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Imposta minuti specifici (per hourly)
|
|
198
|
+
# @param minutes [Array<Integer>] Minuti [0, 30]
|
|
199
|
+
def at_minutes(minutes)
|
|
200
|
+
@config[:at_minutes] = Array(minutes)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Imposta reminder anticipato
|
|
204
|
+
# @param advance [Integer, Array<Integer>] Giorni prima (negativi)
|
|
205
|
+
def advance_by(advance)
|
|
206
|
+
@config[:advance_by] = Array(advance).map { |v| v.is_a?(Integer) ? v : v.to_i }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Imposta eccezioni
|
|
210
|
+
# @option options [Array<Symbol>] :weekday Giorni da escludere
|
|
211
|
+
# @option options [Array<String>] :date Date da escludere
|
|
212
|
+
# @option options [Array<Integer>] :month Mesi da escludere
|
|
213
|
+
# @option options [Hash] :pattern Pattern complessi
|
|
214
|
+
def except(**options)
|
|
215
|
+
@config[:exceptions] ||= {}
|
|
216
|
+
|
|
217
|
+
if options[:weekday]
|
|
218
|
+
@config[:exceptions][:weekdays] ||= []
|
|
219
|
+
@config[:exceptions][:weekdays] += Array(options[:weekday]).map(&:to_s)
|
|
220
|
+
@config[:exceptions][:weekdays].uniq!
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if options[:date]
|
|
224
|
+
@config[:exceptions][:dates] ||= []
|
|
225
|
+
@config[:exceptions][:dates] += Array(options[:date])
|
|
226
|
+
@config[:exceptions][:dates].uniq!
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if options[:month]
|
|
230
|
+
@config[:exceptions][:months] ||= []
|
|
231
|
+
@config[:exceptions][:months] += Array(options[:month])
|
|
232
|
+
@config[:exceptions][:months].uniq!
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if options[:pattern]
|
|
236
|
+
@config[:exceptions][:patterns] ||= []
|
|
237
|
+
@config[:exceptions][:patterns] << options[:pattern]
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
# Valida formato time HH:MM
|
|
244
|
+
def valid_time_format?(time_str)
|
|
245
|
+
return false unless time_str.is_a?(String)
|
|
246
|
+
return false unless time_str.match?(/^\d{1,2}:\d{2}$/)
|
|
247
|
+
|
|
248
|
+
hour, minute = time_str.split(":").map(&:to_i)
|
|
249
|
+
hour.between?(0, 23) && minute.between?(0, 59)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Valida formato date MM-DD
|
|
253
|
+
def valid_date_format?(date_str)
|
|
254
|
+
return false unless date_str.is_a?(String)
|
|
255
|
+
return false unless date_str.match?(/^\d{1,2}-\d{1,2}$/)
|
|
256
|
+
|
|
257
|
+
month, day = date_str.split("-").map(&:to_i)
|
|
258
|
+
return false unless month.between?(1, 12)
|
|
259
|
+
|
|
260
|
+
# Usa anno bisestile (2024) per validare giorni in febbraio
|
|
261
|
+
Date.new(2024, month, day)
|
|
262
|
+
true
|
|
263
|
+
rescue ArgumentError
|
|
264
|
+
false
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Schedulable - Sistema di schedulazione ricorrente per modelli Rails
|
|
4
|
+
#
|
|
5
|
+
# Questo concern permette di definire schedule ricorrenti con pattern complessi,
|
|
6
|
+
# eccezioni, e calcolo automatico delle occorrenze.
|
|
7
|
+
#
|
|
8
|
+
# APPROCCIO OPT-IN: La schedulazione non è attiva automaticamente.
|
|
9
|
+
# Devi chiamare esplicitamente `schedulable do...end` nel tuo modello.
|
|
10
|
+
#
|
|
11
|
+
# REQUISITI DATABASE:
|
|
12
|
+
# - Colonna per salvare la configurazione schedule (JSONB o TEXT con serialize)
|
|
13
|
+
# - Colonna opzionale per timezone per-record
|
|
14
|
+
# - Colonna opzionale per cache next_occurrence_at
|
|
15
|
+
#
|
|
16
|
+
# Esempio di utilizzo:
|
|
17
|
+
# class Event < ApplicationRecord
|
|
18
|
+
# include BetterModel
|
|
19
|
+
#
|
|
20
|
+
# serialize :schedule_config, coder: JSON, type: Hash
|
|
21
|
+
#
|
|
22
|
+
# schedulable do
|
|
23
|
+
# schedule_field :schedule_config
|
|
24
|
+
# timezone_field :timezone
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# # Per-record scheduling
|
|
29
|
+
# event = Event.create!(name: "Stand-up Meeting")
|
|
30
|
+
# event.schedule do |s|
|
|
31
|
+
# s.recurs :weekly do
|
|
32
|
+
# s.on weekday: [:monday]
|
|
33
|
+
# s.at ['09:00']
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# event.next_occurrence # => 2025-11-10 09:00:00 +0100
|
|
38
|
+
# event.due_now? # => true/false
|
|
39
|
+
# Event.due_now # => [events due now]
|
|
40
|
+
#
|
|
41
|
+
module BetterModel
|
|
42
|
+
module Schedulable
|
|
43
|
+
extend ActiveSupport::Concern
|
|
44
|
+
|
|
45
|
+
included do
|
|
46
|
+
# Validazione ActiveRecord
|
|
47
|
+
unless ancestors.include?(ActiveRecord::Base)
|
|
48
|
+
raise ArgumentError, "BetterModel::Schedulable can only be included in ActiveRecord models"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Configurazione schedulable (opt-in)
|
|
52
|
+
class_attribute :schedulable_enabled, default: false
|
|
53
|
+
class_attribute :schedulable_config, default: nil
|
|
54
|
+
|
|
55
|
+
# Auto-update next_occurrence_at when schedule changes
|
|
56
|
+
after_save :auto_update_next_occurrence, if: :schedulable_enabled?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class_methods do
|
|
60
|
+
# DSL per attivare e configurare schedulable (OPT-IN)
|
|
61
|
+
#
|
|
62
|
+
# @example Attivazione base
|
|
63
|
+
# schedulable do
|
|
64
|
+
# schedule_field :schedule_config
|
|
65
|
+
# timezone_field :timezone
|
|
66
|
+
# end
|
|
67
|
+
#
|
|
68
|
+
def schedulable(&block)
|
|
69
|
+
# Attiva schedulable
|
|
70
|
+
self.schedulable_enabled = true
|
|
71
|
+
|
|
72
|
+
# Configura se passato un blocco
|
|
73
|
+
if block_given?
|
|
74
|
+
config = Configuration.new
|
|
75
|
+
config.instance_eval(&block)
|
|
76
|
+
self.schedulable_config = config.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Valida configurazione
|
|
80
|
+
validate_schedulable_config!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Verifica se schedulable è attivo
|
|
84
|
+
def schedulable_enabled?
|
|
85
|
+
schedulable_enabled == true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Trova tutti i record dovuti ora
|
|
89
|
+
#
|
|
90
|
+
# @param tolerance [ActiveSupport::Duration] Finestra di tolleranza
|
|
91
|
+
# @return [Array] Array of records (not ActiveRecord::Relation for now)
|
|
92
|
+
def due_now(tolerance: 5.minutes)
|
|
93
|
+
return [] unless schedulable_enabled?
|
|
94
|
+
|
|
95
|
+
# For now, use in-memory filtering to ensure accuracy with travel_to
|
|
96
|
+
# TODO: Optimize with next_occurrence_at cache for production
|
|
97
|
+
all.select { |record| record.due_now?(tolerance: tolerance) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Trova i prossimi N eventi in programma
|
|
101
|
+
#
|
|
102
|
+
# @param limit [Integer] Numero massimo di risultati
|
|
103
|
+
# @return [Array] Array of records sorted by next_occurrence_at
|
|
104
|
+
def upcoming(limit: 10)
|
|
105
|
+
return [] unless schedulable_enabled?
|
|
106
|
+
|
|
107
|
+
results = all.map do |record|
|
|
108
|
+
next_occ = record.next_occurrence
|
|
109
|
+
next unless next_occ
|
|
110
|
+
|
|
111
|
+
{ event: record, occurrence: next_occ }
|
|
112
|
+
end.compact
|
|
113
|
+
|
|
114
|
+
results.sort_by { |r| r[:occurrence] }.first(limit)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Trova eventi in ritardo (oltre grace period)
|
|
118
|
+
#
|
|
119
|
+
# @param grace_period [ActiveSupport::Duration] Periodo di grazia
|
|
120
|
+
# @return [Array]
|
|
121
|
+
def overdue(grace_period: 1.hour)
|
|
122
|
+
return [] unless schedulable_enabled?
|
|
123
|
+
|
|
124
|
+
# For now, use in-memory filtering to ensure accuracy with travel_to
|
|
125
|
+
# TODO: Optimize with next_occurrence_at cache for production
|
|
126
|
+
now = Time.current
|
|
127
|
+
|
|
128
|
+
all.select do |record|
|
|
129
|
+
# Get the last occurrence that should have happened
|
|
130
|
+
last_occ = record.previous_occurrence(before: now)
|
|
131
|
+
next unless last_occ
|
|
132
|
+
|
|
133
|
+
# Also get the next upcoming occurrence
|
|
134
|
+
next_occ = record.next_occurrence(after: now)
|
|
135
|
+
next unless next_occ
|
|
136
|
+
|
|
137
|
+
# Overdue if:
|
|
138
|
+
# 1. Last occurrence is beyond grace period
|
|
139
|
+
# 2. Last occurrence is closer to now than next occurrence (it was "recent")
|
|
140
|
+
past_dist = now - last_occ
|
|
141
|
+
future_dist = next_occ - now
|
|
142
|
+
|
|
143
|
+
last_occ < (now - grace_period) && past_dist < future_dist
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def validate_schedulable_config!
|
|
150
|
+
config = schedulable_config
|
|
151
|
+
return unless config
|
|
152
|
+
|
|
153
|
+
# Valida che i campi esistano
|
|
154
|
+
schedule_field = config.schedule_field
|
|
155
|
+
unless column_names.include?(schedule_field.to_s)
|
|
156
|
+
raise ArgumentError, "Schedule field #{schedule_field} does not exist in #{table_name}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if config.timezone_field
|
|
160
|
+
unless column_names.include?(config.timezone_field.to_s)
|
|
161
|
+
raise ArgumentError, "Timezone field #{config.timezone_field} does not exist in #{table_name}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ============================================================================
|
|
168
|
+
# INSTANCE METHODS
|
|
169
|
+
# ============================================================================
|
|
170
|
+
|
|
171
|
+
# DSL per configurare lo schedule del record
|
|
172
|
+
#
|
|
173
|
+
# @example
|
|
174
|
+
# event.schedule do |s|
|
|
175
|
+
# s.recurs :daily do
|
|
176
|
+
# s.at ['09:00']
|
|
177
|
+
# end
|
|
178
|
+
# end
|
|
179
|
+
#
|
|
180
|
+
def schedule(&block)
|
|
181
|
+
raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
|
|
182
|
+
|
|
183
|
+
builder = ScheduleBuilder.new(self)
|
|
184
|
+
yield builder if block_given?
|
|
185
|
+
|
|
186
|
+
# Salva configurazione
|
|
187
|
+
schedule_field = self.class.schedulable_config.schedule_field
|
|
188
|
+
self[schedule_field] = builder.to_h
|
|
189
|
+
|
|
190
|
+
if persisted?
|
|
191
|
+
save!
|
|
192
|
+
update_next_occurrence!
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Calcola la prossima occorrenza dopo un momento specifico
|
|
197
|
+
#
|
|
198
|
+
# @param after [Time] Momento di riferimento (default: Time.current)
|
|
199
|
+
# @return [Time, nil]
|
|
200
|
+
def next_occurrence(after: Time.current)
|
|
201
|
+
raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
|
|
202
|
+
|
|
203
|
+
schedule_config = get_schedule_config
|
|
204
|
+
return nil if schedule_config.blank?
|
|
205
|
+
|
|
206
|
+
calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
|
|
207
|
+
calculator.next_occurrence(after: after)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Calcola l'occorrenza precedente prima di un momento specifico
|
|
211
|
+
#
|
|
212
|
+
# @param before [Time] Momento di riferimento (default: Time.current)
|
|
213
|
+
# @return [Time, nil]
|
|
214
|
+
def previous_occurrence(before: Time.current)
|
|
215
|
+
raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
|
|
216
|
+
|
|
217
|
+
schedule_config = get_schedule_config
|
|
218
|
+
return nil if schedule_config.blank?
|
|
219
|
+
|
|
220
|
+
calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
|
|
221
|
+
calculator.previous_occurrence(before: before)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Genera array di occorrenze in un range temporale
|
|
225
|
+
#
|
|
226
|
+
# @param start_time [Time] Inizio range
|
|
227
|
+
# @param end_time [Time] Fine range
|
|
228
|
+
# @param limit [Integer] Limite numero occorrenze
|
|
229
|
+
# @return [Array<Time>]
|
|
230
|
+
def occurrences_between(start_time, end_time, limit: 100)
|
|
231
|
+
raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
|
|
232
|
+
|
|
233
|
+
schedule_config = get_schedule_config
|
|
234
|
+
return [] if schedule_config.blank?
|
|
235
|
+
|
|
236
|
+
calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
|
|
237
|
+
calculator.occurrences_between(start_time, end_time, limit: limit)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Verifica se l'evento occorre in un momento specifico
|
|
241
|
+
#
|
|
242
|
+
# @param time [Time] Momento da verificare
|
|
243
|
+
# @return [Boolean]
|
|
244
|
+
def occurs_at?(time)
|
|
245
|
+
raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
|
|
246
|
+
|
|
247
|
+
schedule_config = get_schedule_config
|
|
248
|
+
return false if schedule_config.blank?
|
|
249
|
+
|
|
250
|
+
calculator = OccurrenceCalculator.new(schedule_config, record_timezone)
|
|
251
|
+
calculator.occurs_at?(time)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Verifica se l'evento è dovuto ora (entro finestra di tolleranza)
|
|
255
|
+
#
|
|
256
|
+
# @param tolerance [ActiveSupport::Duration] Finestra di tolleranza
|
|
257
|
+
# @return [Boolean]
|
|
258
|
+
def due_now?(tolerance: 5.minutes)
|
|
259
|
+
raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
|
|
260
|
+
|
|
261
|
+
now = Time.current
|
|
262
|
+
|
|
263
|
+
# Check if next occurrence is within tolerance window (forward or backward)
|
|
264
|
+
# This handles both: event happening now AND event coming up soon
|
|
265
|
+
next_occ = next_occurrence(after: now - tolerance)
|
|
266
|
+
return false unless next_occ
|
|
267
|
+
|
|
268
|
+
next_occ <= (now + tolerance)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Aggiorna la cache next_occurrence_at (se la colonna esiste)
|
|
272
|
+
def update_next_occurrence!
|
|
273
|
+
return unless self.class.schedulable_enabled?
|
|
274
|
+
return unless respond_to?(:next_occurrence_at=)
|
|
275
|
+
|
|
276
|
+
next_occ = next_occurrence
|
|
277
|
+
update_column(:next_occurrence_at, next_occ)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Callback: auto-update next_occurrence_at after save
|
|
281
|
+
def auto_update_next_occurrence
|
|
282
|
+
return unless respond_to?(:next_occurrence_at=)
|
|
283
|
+
|
|
284
|
+
# Check if schedule_config changed
|
|
285
|
+
schedule_field = self.class.schedulable_config.schedule_field
|
|
286
|
+
if saved_change_to_attribute?(schedule_field)
|
|
287
|
+
next_occ = next_occurrence
|
|
288
|
+
update_column(:next_occurrence_at, next_occ)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Restituisce una descrizione leggibile dello schedule
|
|
293
|
+
#
|
|
294
|
+
# @return [String]
|
|
295
|
+
def schedule_summary
|
|
296
|
+
raise SchedulableNotEnabledError unless self.class.schedulable_enabled?
|
|
297
|
+
|
|
298
|
+
schedule_config = get_schedule_config
|
|
299
|
+
return "No schedule configured" if schedule_config.blank?
|
|
300
|
+
|
|
301
|
+
# TODO: Implementare formatter human-readable
|
|
302
|
+
schedule_config.inspect
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def get_schedule_config
|
|
308
|
+
schedule_field = self.class.schedulable_config.schedule_field
|
|
309
|
+
config = self[schedule_field]
|
|
310
|
+
|
|
311
|
+
# Gestisci sia Hash che String (se serializzato)
|
|
312
|
+
return config if config.is_a?(Hash)
|
|
313
|
+
return JSON.parse(config) if config.is_a?(String)
|
|
314
|
+
|
|
315
|
+
{}
|
|
316
|
+
rescue JSON::ParserError
|
|
317
|
+
{}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def record_timezone
|
|
321
|
+
timezone_field = self.class.schedulable_config&.timezone_field
|
|
322
|
+
return "UTC" unless timezone_field
|
|
323
|
+
|
|
324
|
+
self[timezone_field] || "UTC"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Configurazione Schedulable
|
|
329
|
+
class Schedulable::Configuration
|
|
330
|
+
def initialize
|
|
331
|
+
@schedule_field = :schedule_config
|
|
332
|
+
@timezone_field = nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Dual-purpose getter/setter for schedule_field
|
|
336
|
+
def schedule_field(field_name = nil)
|
|
337
|
+
return @schedule_field if field_name.nil?
|
|
338
|
+
@schedule_field = field_name.to_sym
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Dual-purpose getter/setter for timezone_field
|
|
342
|
+
def timezone_field(field_name = nil)
|
|
343
|
+
return @timezone_field if field_name.nil?
|
|
344
|
+
@timezone_field = field_name.to_sym
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Errori custom
|
|
349
|
+
class SchedulableError < StandardError; end
|
|
350
|
+
|
|
351
|
+
class SchedulableNotEnabledError < SchedulableError
|
|
352
|
+
def initialize(msg = nil)
|
|
353
|
+
super(msg || "Schedulable is not enabled. Add 'schedulable do...end' to your model.")
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -227,13 +227,13 @@ module BetterModel
|
|
|
227
227
|
next if value.nil?
|
|
228
228
|
next if value.respond_to?(:empty?) && value.empty?
|
|
229
229
|
|
|
230
|
-
#
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
elsif value.is_a?(Array)
|
|
230
|
+
# v2.0.0: ALL predicates require parameters
|
|
231
|
+
# Apply scope with parameter
|
|
232
|
+
scope = if value.is_a?(Array)
|
|
234
233
|
# Splat array values for predicates like _between that expect multiple args
|
|
235
234
|
scope.public_send(predicate_scope, *value)
|
|
236
235
|
else
|
|
236
|
+
# Pass value as parameter (all predicates require at least one parameter)
|
|
237
237
|
scope.public_send(predicate_scope, value)
|
|
238
238
|
end
|
|
239
239
|
end
|