munawaba 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/SECURITY.md +15 -0
- data/app/assets/munawaba/INTER-LICENSE.txt +92 -0
- data/app/assets/munawaba/JETBRAINS-MONO-LICENSE.txt +93 -0
- data/app/assets/munawaba/dashboard.css +272 -0
- data/app/assets/munawaba/dashboard.js +60 -0
- data/app/assets/munawaba/inter.woff2 +0 -0
- data/app/assets/munawaba/jetbrains-mono.woff2 +0 -0
- data/app/assets/munawaba/logo.png +0 -0
- data/app/controllers/munawaba/application_controller.rb +149 -0
- data/app/controllers/munawaba/audit_events_controller.rb +31 -0
- data/app/controllers/munawaba/calendars_controller.rb +47 -0
- data/app/controllers/munawaba/frontends_controller.rb +28 -0
- data/app/controllers/munawaba/notification_deliveries_controller.rb +41 -0
- data/app/controllers/munawaba/overrides_controller.rb +52 -0
- data/app/controllers/munawaba/overviews_controller.rb +22 -0
- data/app/controllers/munawaba/people_controller.rb +73 -0
- data/app/controllers/munawaba/rotations_controller.rb +61 -0
- data/app/controllers/munawaba/schedules_controller.rb +115 -0
- data/app/controllers/munawaba/shifts_controller.rb +16 -0
- data/app/controllers/munawaba/slack_integrations_controller.rb +46 -0
- data/app/controllers/munawaba/themes_controller.rb +19 -0
- data/app/helpers/munawaba/application_helper.rb +208 -0
- data/app/jobs/munawaba/application_job.rb +7 -0
- data/app/jobs/munawaba/deliver_notification_job.rb +13 -0
- data/app/jobs/munawaba/dispatch_due_notifications_job.rb +7 -0
- data/app/jobs/munawaba/maintain_projection_job.rb +25 -0
- data/app/jobs/munawaba/maintenance_job.rb +28 -0
- data/app/models/munawaba/application_record.rb +7 -0
- data/app/models/munawaba/audit_event.rb +16 -0
- data/app/models/munawaba/notification_delivery.rb +75 -0
- data/app/models/munawaba/person.rb +23 -0
- data/app/models/munawaba/schedule.rb +85 -0
- data/app/models/munawaba/schedule_membership.rb +13 -0
- data/app/models/munawaba/shift.rb +51 -0
- data/app/models/munawaba/shift_override.rb +31 -0
- data/app/services/munawaba/audit/recorder.rb +17 -0
- data/app/services/munawaba/canonical.rb +43 -0
- data/app/services/munawaba/commands.rb +741 -0
- data/app/services/munawaba/conflicts/finder.rb +59 -0
- data/app/services/munawaba/integrations/planner_effects.rb +27 -0
- data/app/services/munawaba/integrations.rb +93 -0
- data/app/services/munawaba/notifications/context/v1.rb +65 -0
- data/app/services/munawaba/notifications/deliver.rb +94 -0
- data/app/services/munawaba/notifications/dispatcher.rb +50 -0
- data/app/services/munawaba/notifications/planner.rb +154 -0
- data/app/services/munawaba/notifications/reclaim_expired_leases.rb +27 -0
- data/app/services/munawaba/notifications/retry_failed.rb +51 -0
- data/app/services/munawaba/notifications/validity.rb +90 -0
- data/app/services/munawaba/notifications/wake_dispatcher.rb +17 -0
- data/app/services/munawaba/preview.rb +5 -0
- data/app/services/munawaba/result.rb +7 -0
- data/app/services/munawaba/shifts/normalize_future_timing.rb +172 -0
- data/app/services/munawaba/shifts/project.rb +50 -0
- data/app/services/munawaba/shifts/projection_plan.rb +89 -0
- data/app/services/munawaba/slack/client.rb +105 -0
- data/app/services/munawaba/slack/renderer.rb +48 -0
- data/app/services/munawaba/slack/webhook_validator.rb +35 -0
- data/app/services/munawaba/timing/boundary_calculator.rb +92 -0
- data/app/services/munawaba/timing/persisted_interval_verifier.rb +26 -0
- data/app/views/layouts/munawaba/application.html.erb +61 -0
- data/app/views/munawaba/audit_events/index.html.erb +85 -0
- data/app/views/munawaba/calendars/_shift.html.erb +17 -0
- data/app/views/munawaba/calendars/show.html.erb +81 -0
- data/app/views/munawaba/notification_deliveries/index.html.erb +65 -0
- data/app/views/munawaba/overrides/new.html.erb +19 -0
- data/app/views/munawaba/overviews/show.html.erb +76 -0
- data/app/views/munawaba/people/_form.html.erb +25 -0
- data/app/views/munawaba/people/edit.html.erb +2 -0
- data/app/views/munawaba/people/index.html.erb +49 -0
- data/app/views/munawaba/people/new.html.erb +2 -0
- data/app/views/munawaba/people/show.html.erb +34 -0
- data/app/views/munawaba/rotations/edit.html.erb +39 -0
- data/app/views/munawaba/schedules/_form.html.erb +49 -0
- data/app/views/munawaba/schedules/_resume_form.html.erb +24 -0
- data/app/views/munawaba/schedules/edit.html.erb +2 -0
- data/app/views/munawaba/schedules/index.html.erb +46 -0
- data/app/views/munawaba/schedules/new.html.erb +2 -0
- data/app/views/munawaba/schedules/resume.html.erb +4 -0
- data/app/views/munawaba/schedules/show.html.erb +86 -0
- data/app/views/munawaba/shared/_empty.html.erb +6 -0
- data/app/views/munawaba/shared/_errors.html.erb +23 -0
- data/app/views/munawaba/shared/_navigation.html.erb +8 -0
- data/app/views/munawaba/shared/_projection.html.erb +20 -0
- data/app/views/munawaba/shared/_proposal_details.html.erb +108 -0
- data/app/views/munawaba/shared/_shifts.html.erb +50 -0
- data/app/views/munawaba/shared/proposal.html.erb +5 -0
- data/app/views/munawaba/shifts/show.html.erb +65 -0
- data/app/views/munawaba/slack_integrations/edit.html.erb +48 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260905000001_create_munawaba_people.rb +33 -0
- data/db/migrate/20260905000002_create_munawaba_schedules.rb +73 -0
- data/db/migrate/20260905000003_create_munawaba_schedule_memberships.rb +25 -0
- data/db/migrate/20260905000004_create_munawaba_shifts.rb +51 -0
- data/db/migrate/20260905000005_create_munawaba_shift_overrides.rb +33 -0
- data/db/migrate/20260905000006_create_munawaba_notification_deliveries.rb +83 -0
- data/db/migrate/20260905000007_create_munawaba_audit_events.rb +36 -0
- data/db/migrate/20260905000008_simplify_munawaba_activity.rb +16 -0
- data/lib/generators/munawaba/install_generator.rb +34 -0
- data/lib/generators/munawaba/templates/munawaba.rb +23 -0
- data/lib/munawaba/configuration.rb +91 -0
- data/lib/munawaba/defaults.rb +20 -0
- data/lib/munawaba/engine.rb +16 -0
- data/lib/munawaba/version.rb +5 -0
- data/lib/munawaba.rb +33 -0
- metadata +287 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Notifications
|
|
5
|
+
class Validity
|
|
6
|
+
Result = Struct.new(:status, :code, :schedule, :shift, keyword_init: true) do
|
|
7
|
+
def valid? = status.nil?
|
|
8
|
+
end
|
|
9
|
+
KIND_SETTINGS = { "advance_reminder" => :notify_advance, "shift_start" => :notify_shift_start,
|
|
10
|
+
"assignment_change" => :notify_assignment_change, "next_assignment_change" => :notify_next_assignment_change }.freeze
|
|
11
|
+
|
|
12
|
+
def self.call(delivery, now: Time.current, allow_old_notification_revision: false)
|
|
13
|
+
# Bypass earlier cached reads so sending and lease recovery see committed changes.
|
|
14
|
+
ActiveRecord::Base.uncached do
|
|
15
|
+
validate(delivery, now: now, allow_old_notification_revision: allow_old_notification_revision)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.validate(delivery, now:, allow_old_notification_revision:)
|
|
20
|
+
context = Context::V1.validate!(kind: delivery.kind, context: delivery.context)
|
|
21
|
+
schedule = Schedule.find(delivery.schedule_id)
|
|
22
|
+
result = Result.new(schedule: schedule)
|
|
23
|
+
reject = ->(status, code) { result.status = status; result.code = code; result }
|
|
24
|
+
return reject.call(:stale, "expired") if delivery.expires_at <= now
|
|
25
|
+
return reject.call(:canceled,
|
|
26
|
+
"integration_disabled") unless schedule.slack_enabled && schedule.slack_webhook_configured_at
|
|
27
|
+
unless allow_old_notification_revision || delivery.notification_revision == schedule.notification_revision
|
|
28
|
+
return reject.call(:stale, "notification_changed")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
setting = KIND_SETTINGS[delivery.kind]
|
|
32
|
+
return reject.call(:canceled, "kind_disabled") if setting && !schedule.public_send(setting)
|
|
33
|
+
return result if delivery.kind == "test"
|
|
34
|
+
return reject.call(:stale, "coverage_changed") unless delivery.coverage_revision == schedule.coverage_revision
|
|
35
|
+
return reject.call(:canceled, "schedule_inactive") unless %w[scheduled active pausing].include?(schedule.state)
|
|
36
|
+
|
|
37
|
+
shift = Shift.find(delivery.shift_id || context.fetch("described_shift_id"))
|
|
38
|
+
result.shift = shift
|
|
39
|
+
return reject.call(:canceled, "shift_canceled") if shift.canceled_at
|
|
40
|
+
return reject.call(:stale,
|
|
41
|
+
"shift_changed") unless shift.schedule_id == schedule.id && shift.coverage_revision == schedule.coverage_revision
|
|
42
|
+
return reject.call(:stale, "timing_changed") unless shift.timing_version == delivery.timing_version
|
|
43
|
+
|
|
44
|
+
if delivery.kind == "next_assignment_change"
|
|
45
|
+
return reject.call(:stale, "rotation_changed") unless schedule.rotation_revision == delivery.rotation_revision
|
|
46
|
+
raise Context::V1::Invalid unless shift.base_person_id == context["new_next_person_id"] && shift.id == context["described_shift_id"]
|
|
47
|
+
|
|
48
|
+
Person.find(context.fetch("previous_next_person_id"))
|
|
49
|
+
Person.find(context.fetch("new_next_person_id"))
|
|
50
|
+
else
|
|
51
|
+
return reject.call(:stale,
|
|
52
|
+
"assignment_changed") unless delivery.assignment_version == shift.assignment_version
|
|
53
|
+
|
|
54
|
+
validate_transition!(context, shift) if delivery.kind == "assignment_change"
|
|
55
|
+
end
|
|
56
|
+
unless Timing::PersistedIntervalVerifier.valid?(shift: shift, now: now)
|
|
57
|
+
return reject.call(:stale, "timing_rules_changed")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result
|
|
61
|
+
rescue Context::V1::Invalid, ActiveRecord::RecordNotFound, KeyError
|
|
62
|
+
Result.new(status: :stale, code: "invalid_delivery_context")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.validate_transition!(context, shift)
|
|
66
|
+
previous = Person.find(context.fetch("previous_person_id"))
|
|
67
|
+
current = Person.find(context.fetch("new_person_id"))
|
|
68
|
+
raise Context::V1::Invalid unless current.id == shift.effective_person_id
|
|
69
|
+
|
|
70
|
+
replacement = ShiftOverride.find(context["override_id"]) if context["override_id"]
|
|
71
|
+
ended = ShiftOverride.find(context["ended_override_id"]) if context["ended_override_id"]
|
|
72
|
+
if replacement
|
|
73
|
+
raise Context::V1::Invalid unless replacement.shift_id == shift.id && replacement.ended_at.nil? && replacement.previous_person_id == previous.id && replacement.replacement_person_id == current.id
|
|
74
|
+
end
|
|
75
|
+
if ended
|
|
76
|
+
raise Context::V1::Invalid unless ended.shift_id == shift.id && ended.ended_at && ended.replacement_person_id == previous.id
|
|
77
|
+
|
|
78
|
+
expected_reason = { "override_superseded" => "superseded", "override_revoked" => "revoked",
|
|
79
|
+
"override_restored_to_base" => "restored_to_base" }.fetch(context["transition_type"])
|
|
80
|
+
raise Context::V1::Invalid unless ended.end_reason == expected_reason
|
|
81
|
+
end
|
|
82
|
+
if !replacement && shift.base_person_id != current.id
|
|
83
|
+
raise Context::V1::Invalid
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Notifications
|
|
5
|
+
class WakeDispatcher
|
|
6
|
+
def self.call
|
|
7
|
+
ActiveRecord.after_all_transactions_commit do
|
|
8
|
+
begin
|
|
9
|
+
DispatchDueNotificationsJob.perform_later
|
|
10
|
+
rescue StandardError
|
|
11
|
+
Munawaba.signal("dispatcher_wake_failed")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Shifts
|
|
5
|
+
class NormalizeFutureTiming
|
|
6
|
+
SCHEDULE_FIELDS = %w[state coverage_starts_at first_activated_at lifecycle_revision coverage_start_boundary
|
|
7
|
+
generated_through_boundary rotation_effective_boundary pause_effective_at].freeze
|
|
8
|
+
Prepared = Struct.new(:schedule, :virtual_schedule, :rows, :slots, :changes,
|
|
9
|
+
:clipping, :conflicts, :audit_before, :finalized, keyword_init: true) do
|
|
10
|
+
def changed? = changes.any? || SCHEDULE_FIELDS.any? { |key| schedule[key] != virtual_schedule[key] }
|
|
11
|
+
end
|
|
12
|
+
SLOT_FIELDS = %i[id schedule_id coverage_revision boundary_index starts_at ends_at base_person_id
|
|
13
|
+
effective_person_id rotation_revision assignment_version timing_version].freeze
|
|
14
|
+
|
|
15
|
+
def self.call(schedule:, now: nil)
|
|
16
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
17
|
+
schedule.lock!("FOR NO KEY UPDATE")
|
|
18
|
+
captured = Canonical.time(now || Time.current)
|
|
19
|
+
prepared = locked_preparation(schedule, captured)
|
|
20
|
+
apply!(prepared, now: captured)
|
|
21
|
+
prepared
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.locked_preparation(schedule, now)
|
|
26
|
+
live = Shift.where(schedule_id: schedule.id, coverage_revision: schedule.coverage_revision, canceled_at: nil)
|
|
27
|
+
# The latest started shift fixes the boundary with coverage already underway.
|
|
28
|
+
ids = case schedule.state
|
|
29
|
+
when "scheduled", "active"
|
|
30
|
+
future_ids = live.where("starts_at > ?", now).pluck(:id)
|
|
31
|
+
if schedule.state == "active" || schedule.coverage_starts_at <= now
|
|
32
|
+
preceding_id = live.where("starts_at <= ?", now).order(starts_at: :desc, id: :desc).pick(:id)
|
|
33
|
+
end
|
|
34
|
+
first_id = live.where(boundary_index: schedule.coverage_start_boundary).pick(:id) if schedule.state == "scheduled"
|
|
35
|
+
future_ids + [preceding_id, first_id].compact
|
|
36
|
+
when "pausing"
|
|
37
|
+
live.where(boundary_index: schedule.generated_through_boundary).pluck(:id)
|
|
38
|
+
else []
|
|
39
|
+
end
|
|
40
|
+
shifts = live.where(id: ids.uniq).order(:id).lock("FOR NO KEY UPDATE").to_a
|
|
41
|
+
ShiftOverride.where(shift_id: shifts.map(&:id), ended_at: nil).order(:id).lock("FOR NO KEY UPDATE").load
|
|
42
|
+
NotificationDelivery.where(schedule_id: schedule.id,
|
|
43
|
+
status: %w[pending enqueued
|
|
44
|
+
processing]).order(:id).lock("FOR NO KEY UPDATE").load
|
|
45
|
+
prepare(schedule: schedule, now: now, shifts: shifts)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.slot(shift)
|
|
49
|
+
SLOT_FIELDS.to_h { |key| [key, shift.public_send(key)] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.prepare(schedule:, now:, shifts: nil, provider: Timing::BoundaryCalculator.method(:timezone))
|
|
53
|
+
rows = shifts || Shift.where(schedule_id: schedule.id, coverage_revision: schedule.coverage_revision,
|
|
54
|
+
canceled_at: nil).order(:id).to_a
|
|
55
|
+
rows = rows.select { |row|
|
|
56
|
+
row.canceled_at.nil? && row.coverage_revision == schedule.coverage_revision
|
|
57
|
+
}.sort_by(&:boundary_index)
|
|
58
|
+
virtual = schedule.dup
|
|
59
|
+
virtual.id = schedule.id
|
|
60
|
+
slots = rows.map { |row| slot(row) }
|
|
61
|
+
changes = []
|
|
62
|
+
clipping = false
|
|
63
|
+
if %w[scheduled active].include?(schedule.state) && rows.any?
|
|
64
|
+
calculator = Timing::BoundaryCalculator.new(schedule, provider: provider)
|
|
65
|
+
first = rows.first
|
|
66
|
+
scheduled = schedule.state == "scheduled"
|
|
67
|
+
first_new_start = calculator.boundary(first.boundary_index).resolved_at
|
|
68
|
+
# If a changed rule moves the start into the past, begin at now instead
|
|
69
|
+
# of creating coverage for time that has already passed.
|
|
70
|
+
scheduled_moved = scheduled && first_new_start != schedule.coverage_starts_at
|
|
71
|
+
clipping = scheduled_moved && first_new_start <= now
|
|
72
|
+
slots.each_with_index do |item, index|
|
|
73
|
+
next unless item[:starts_at] > now || scheduled_moved
|
|
74
|
+
|
|
75
|
+
left = calculator.boundary(item[:boundary_index]).resolved_at
|
|
76
|
+
right = calculator.boundary(item[:boundary_index] + 1).resolved_at
|
|
77
|
+
prior = index.positive? && slots[index - 1]
|
|
78
|
+
left = prior[:ends_at] if prior && (prior[:starts_at] <= now || scheduled_moved)
|
|
79
|
+
left = now if clipping && index.zero?
|
|
80
|
+
raise ArgumentError, "Timing maintenance is required: an interval is no longer positive" unless right > left
|
|
81
|
+
|
|
82
|
+
if item[:starts_at] != left || item[:ends_at] != right
|
|
83
|
+
item[:starts_at] = left
|
|
84
|
+
item[:ends_at] = right
|
|
85
|
+
item[:timing_version] += 1
|
|
86
|
+
changes << item
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
if scheduled
|
|
90
|
+
start = slots.first[:starts_at]
|
|
91
|
+
moved = start != schedule.coverage_starts_at
|
|
92
|
+
virtual.coverage_starts_at = start
|
|
93
|
+
if start <= now
|
|
94
|
+
virtual.state = "active"
|
|
95
|
+
virtual.first_activated_at ||= start
|
|
96
|
+
end
|
|
97
|
+
virtual.lifecycle_revision += 1 if moved || virtual.state != schedule.state
|
|
98
|
+
end
|
|
99
|
+
elsif schedule.state == "pausing" && schedule.pause_effective_at <= now
|
|
100
|
+
virtual.assign_attributes(state: "paused", coverage_start_boundary: nil, coverage_starts_at: nil,
|
|
101
|
+
generated_through_boundary: nil, rotation_effective_boundary: nil, pause_effective_at: nil,
|
|
102
|
+
lifecycle_revision: schedule.lifecycle_revision + 1)
|
|
103
|
+
end
|
|
104
|
+
slots.each_cons(2) do |left, right|
|
|
105
|
+
raise ArgumentError,
|
|
106
|
+
"Timing maintenance would overlap retained intervals" if left[:ends_at] > right[:starts_at]
|
|
107
|
+
end
|
|
108
|
+
Prepared.new(schedule: schedule, virtual_schedule: virtual, rows: rows, slots: slots,
|
|
109
|
+
changes: changes, clipping: clipping)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Keep preparation separate so confirmations can verify the signed preview
|
|
113
|
+
# and its conflicts before changing stored shifts.
|
|
114
|
+
def self.apply!(prepared, now:, operation_id: SecureRandom.uuid, defer: false)
|
|
115
|
+
schedule = prepared.schedule
|
|
116
|
+
prepared.audit_before = { start: schedule.coverage_starts_at, state: schedule.state,
|
|
117
|
+
pause: schedule.pause_effective_at, first_ever: schedule.first_activated_at.nil? }
|
|
118
|
+
Shift.connection.execute("SET CONSTRAINTS ALL DEFERRED") if prepared.changes.any?
|
|
119
|
+
prepared.changes.each do |slot|
|
|
120
|
+
prepared.rows.find { |row|
|
|
121
|
+
row.id == slot[:id]
|
|
122
|
+
}.update!(starts_at: slot[:starts_at], ends_at: slot[:ends_at], timing_version: slot[:timing_version])
|
|
123
|
+
end
|
|
124
|
+
changes = SCHEDULE_FIELDS.select { |key| prepared.schedule[key] != prepared.virtual_schedule[key] }
|
|
125
|
+
prepared.schedule.update!(prepared.virtual_schedule.attributes.slice(*changes)) if changes.any?
|
|
126
|
+
finalize!(prepared, now: now, operation_id: operation_id) unless defer
|
|
127
|
+
prepared
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Combined changes publish timing effects after the final assignments are saved.
|
|
131
|
+
def self.finalize!(prepared, now:, operation_id:)
|
|
132
|
+
return if prepared.finalized
|
|
133
|
+
|
|
134
|
+
prepared.finalized = true
|
|
135
|
+
schedule = prepared.schedule
|
|
136
|
+
before_start, before_state, before_pause, first_ever = prepared.audit_before.values_at(:start, :state, :pause, :first_ever)
|
|
137
|
+
audit_attributes = { schedule: schedule, operation_id: operation_id, occurred_at: now }
|
|
138
|
+
|
|
139
|
+
if prepared.changes.any?
|
|
140
|
+
conflicts = prepared.conflicts || Conflicts::Finder.call(slots: prepared.changes)
|
|
141
|
+
rows = Shift.where(id: prepared.changes.map { |slot| slot[:id] }).order(:id).to_a
|
|
142
|
+
Notifications::Planner.call(schedule: schedule, now: now, operation: "timing", shifts: rows,
|
|
143
|
+
invalidation_shift_ids: rows.map(&:id))
|
|
144
|
+
if conflicts.any?
|
|
145
|
+
ActiveSupport::Notifications.instrument("timing.conflicts.munawaba", schedule_id: schedule.id,
|
|
146
|
+
conflict_count: conflicts.length)
|
|
147
|
+
end
|
|
148
|
+
Audit::Recorder.record!(**audit_attributes, event_type: "timing.future_projection_recomputed", metadata: {
|
|
149
|
+
changed_count: rows.length,
|
|
150
|
+
coverage_starts_at_before: Canonical.instant(before_start),
|
|
151
|
+
coverage_starts_at_after: Canonical.instant(schedule.coverage_starts_at),
|
|
152
|
+
state_before: before_state, state_after: schedule.state
|
|
153
|
+
})
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if before_state == "scheduled" && schedule.state == "active"
|
|
157
|
+
first = prepared.rows.min_by(&:boundary_index)
|
|
158
|
+
event = first_ever ? "schedule.activated" : "schedule.resumed"
|
|
159
|
+
Audit::Recorder.record!(**audit_attributes, event_type: event, shift: first, metadata: {
|
|
160
|
+
state_before: before_state, state_after: schedule.state,
|
|
161
|
+
coverage_starts_at: schedule.coverage_starts_at, next_person_id: first.base_person_id
|
|
162
|
+
})
|
|
163
|
+
elsif before_state == "pausing" && schedule.state == "paused"
|
|
164
|
+
last = prepared.rows.max_by(&:boundary_index)
|
|
165
|
+
Audit::Recorder.record!(**audit_attributes, event_type: "schedule.paused", shift: last, metadata: {
|
|
166
|
+
state_before: before_state, state_after: schedule.state, effective_at: before_pause
|
|
167
|
+
})
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Shifts
|
|
5
|
+
class Project
|
|
6
|
+
def self.call(schedule:, observed_revision: nil, now: nil)
|
|
7
|
+
operation_id = SecureRandom.uuid
|
|
8
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
9
|
+
schedule.lock!("FOR NO KEY UPDATE")
|
|
10
|
+
captured = now ? Canonical.time(now) : Canonical.time(Time.current)
|
|
11
|
+
next [] unless %w[scheduled active].include?(schedule.state)
|
|
12
|
+
next [] if observed_revision && schedule.coverage_revision != observed_revision
|
|
13
|
+
|
|
14
|
+
prepared = NormalizeFutureTiming.locked_preparation(schedule, captured)
|
|
15
|
+
NormalizeFutureTiming.apply!(prepared, now: captured, operation_id: operation_id)
|
|
16
|
+
next [] unless %w[scheduled active].include?(schedule.state)
|
|
17
|
+
|
|
18
|
+
calculator = Timing::BoundaryCalculator.new(schedule)
|
|
19
|
+
target = ProjectionPlan.target(calculator, schedule.coverage_start_boundary,
|
|
20
|
+
captured + Defaults::SHIFT_GENERATION_HORIZON)
|
|
21
|
+
cursor = schedule.generated_through_boundary
|
|
22
|
+
next [] if cursor >= target
|
|
23
|
+
|
|
24
|
+
ids = schedule.ordered_person_ids
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"A live schedule requires active rotation members" if ids.empty? || Person.active.where(id: ids).count != ids.length
|
|
27
|
+
|
|
28
|
+
last = prepared.slots.max_by { |slot| slot[:boundary_index] }
|
|
29
|
+
slots = ((cursor + 1)..target).map do |index|
|
|
30
|
+
person_id = ids[(index - schedule.rotation_effective_boundary) % ids.length]
|
|
31
|
+
start = index == cursor + 1 && last ? last[:ends_at] : calculator.boundary(index).resolved_at
|
|
32
|
+
{ id: nil, schedule_id: schedule.id, coverage_revision: schedule.coverage_revision, boundary_index: index,
|
|
33
|
+
starts_at: start, ends_at: calculator.boundary(index + 1).resolved_at,
|
|
34
|
+
base_person_id: person_id, effective_person_id: person_id, rotation_revision: schedule.rotation_revision, assignment_version: 1, timing_version: 1 }
|
|
35
|
+
end
|
|
36
|
+
conflicts = Conflicts::Finder.call(slots: slots)
|
|
37
|
+
rows = Shift.persist_projection!(schedule: schedule, slots: slots, now: captured)
|
|
38
|
+
schedule.update!(generated_through_boundary: target)
|
|
39
|
+
Notifications::Planner.call(schedule: schedule, now: captured, operation: "project", shifts: rows)
|
|
40
|
+
Audit::Recorder.record!(event_type: "timing.projection_extended", schedule: schedule,
|
|
41
|
+
operation_id: operation_id, occurred_at: captured,
|
|
42
|
+
metadata: { inserted_count: rows.length, projected_until: rows.last.ends_at })
|
|
43
|
+
ActiveSupport::Notifications.instrument("projection.conflicts.munawaba", schedule_id: schedule.id,
|
|
44
|
+
conflict_count: conflicts.length) if conflicts.any?
|
|
45
|
+
rows
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Shifts
|
|
5
|
+
class ProjectionPlan
|
|
6
|
+
MAX_BIGINT = 9_223_372_036_854_775_807
|
|
7
|
+
attr_reader :state, :coverage_revision, :rotation_revision, :first_boundary, :target_boundary,
|
|
8
|
+
:boundaries, :slots, :canonical
|
|
9
|
+
|
|
10
|
+
def initialize(schedule:, operation_kind:, now:, person_ids:, first_boundary:, target_boundary: nil,
|
|
11
|
+
conflict_check_at: nil, provider: Timing::BoundaryCalculator.method(:timezone))
|
|
12
|
+
@schedule_id = schedule.id
|
|
13
|
+
@operation_kind = operation_kind.to_s
|
|
14
|
+
@first_boundary = Integer(first_boundary)
|
|
15
|
+
@coverage_revision = schedule.coverage_revision + 1
|
|
16
|
+
@rotation_revision = schedule.rotation_revision + (@operation_kind == "resume" ? 1 : 0)
|
|
17
|
+
@conflict_check_at = conflict_check_at && Canonical.time(conflict_check_at)
|
|
18
|
+
@calculator = Timing::BoundaryCalculator.new(schedule, provider: provider)
|
|
19
|
+
@state = @operation_kind == "immediate_activation" || @calculator.boundary(@first_boundary).resolved_at == now ? "active" : "scheduled"
|
|
20
|
+
@target_boundary = target_boundary.nil? ? self.class.target(@calculator, @first_boundary,
|
|
21
|
+
now + Defaults::SHIFT_GENERATION_HORIZON) : Integer(target_boundary)
|
|
22
|
+
validate!(person_ids)
|
|
23
|
+
@boundaries = (@first_boundary..(@target_boundary + 1)).map { |index| @calculator.boundary(index) }.freeze
|
|
24
|
+
@slots = (@first_boundary..@target_boundary).map do |index|
|
|
25
|
+
left, right = @boundaries[index - @first_boundary, 2]
|
|
26
|
+
immediate = index == @first_boundary && @operation_kind == "immediate_activation"
|
|
27
|
+
base = person_ids[(index - @first_boundary) % person_ids.length]
|
|
28
|
+
{ schedule_id: @schedule_id, id: nil, coverage_revision: @coverage_revision, boundary_index: index,
|
|
29
|
+
starts_at: immediate ? @conflict_check_at : left.resolved_at, ends_at: right.resolved_at,
|
|
30
|
+
base_person_id: base, effective_person_id: base, rotation_revision: @rotation_revision,
|
|
31
|
+
assignment_version: 1, timing_version: 1, start_resolution: immediate ? nil : left.resolution,
|
|
32
|
+
start_adjustment_seconds: immediate ? nil : left.adjustment_seconds,
|
|
33
|
+
end_resolution: right.resolution, end_adjustment_seconds: right.adjustment_seconds,
|
|
34
|
+
start_basis: immediate ? "activation_conflict_check" : "canonical" }
|
|
35
|
+
end
|
|
36
|
+
@slots.each { |slot|
|
|
37
|
+
raise ArgumentError, "Invalid projected interval" unless slot[:ends_at] > slot[:starts_at]
|
|
38
|
+
}
|
|
39
|
+
Canonical.deep_freeze(@slots)
|
|
40
|
+
@canonical = [1, @operation_kind, @schedule_id, @state, @coverage_revision, @rotation_revision,
|
|
41
|
+
@first_boundary, @target_boundary, @operation_kind == "immediate_activation" ? "confirmation_now_subset" : "canonical",
|
|
42
|
+
@conflict_check_at && Canonical.micros(@conflict_check_at),
|
|
43
|
+
[schedule.cadence, schedule.time_zone, schedule.anchor_local_date.iso8601, schedule.anchor_local_seconds],
|
|
44
|
+
@boundaries.map { |b|
|
|
45
|
+
[b.index, b.nominal_local_date.iso8601, b.nominal_local_seconds, Canonical.micros(b.resolved_at), b.resolution, b.adjustment_seconds, b.utc_offset_seconds]
|
|
46
|
+
},
|
|
47
|
+
@slots.map { |s|
|
|
48
|
+
[s[:boundary_index], Canonical.micros(s[:starts_at]), Canonical.micros(s[:ends_at]), s[:base_person_id], s[:effective_person_id], s[:start_resolution], s[:start_adjustment_seconds], s[:end_resolution], s[:end_adjustment_seconds], s[:rotation_revision], s[:assignment_version], s[:timing_version], s[:start_basis]]
|
|
49
|
+
}]
|
|
50
|
+
Canonical.deep_freeze(@canonical)
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def digest = Canonical.digest(@canonical)
|
|
55
|
+
|
|
56
|
+
def persistence_slots(now:)
|
|
57
|
+
return @slots unless @operation_kind == "immediate_activation"
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"Immediate activation left its acknowledged slot" unless @conflict_check_at <= now && now < @slots.first[:ends_at]
|
|
60
|
+
|
|
61
|
+
Canonical.deep_freeze(@slots.map.with_index { |slot, index| index.zero? ? slot.merge(starts_at: now) : slot })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.target(calculator, first_boundary, cutoff)
|
|
65
|
+
cutoff = [cutoff, calculator.boundary(first_boundary + 1).resolved_at].max
|
|
66
|
+
[first_boundary, calculator.slot_at(cutoff)].max
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate!(person_ids)
|
|
72
|
+
valid = %w[immediate_activation future_activation resume].include?(@operation_kind) &&
|
|
73
|
+
@schedule_id.is_a?(Integer) && @schedule_id.between?(1, MAX_BIGINT) &&
|
|
74
|
+
@coverage_revision.between?(1, MAX_BIGINT) && @rotation_revision.between?(0, MAX_BIGINT) &&
|
|
75
|
+
@first_boundary.between?(0,
|
|
76
|
+
MAX_BIGINT - 1) && @target_boundary.between?(@first_boundary,
|
|
77
|
+
MAX_BIGINT - 1) &&
|
|
78
|
+
person_ids.present? && person_ids.uniq == person_ids && person_ids.all? { |id|
|
|
79
|
+
id.is_a?(Integer) && id.between?(1,
|
|
80
|
+
MAX_BIGINT)
|
|
81
|
+
}
|
|
82
|
+
valid &&= @operation_kind == "immediate_activation" ? !@conflict_check_at.nil? : @conflict_check_at.nil?
|
|
83
|
+
valid &&= @first_boundary.zero? if @operation_kind == "future_activation"
|
|
84
|
+
valid &&= @calculator.boundary(@first_boundary).resolved_at <= @conflict_check_at if @operation_kind == "immediate_activation"
|
|
85
|
+
raise ArgumentError, "Invalid projection plan" unless valid
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Munawaba
|
|
8
|
+
module Slack
|
|
9
|
+
class Client
|
|
10
|
+
Result = Struct.new(:status, :http_status, :error_code, :retry_after, :unknown, keyword_init: true)
|
|
11
|
+
class DeadlineExceeded < StandardError; end
|
|
12
|
+
class ResponseTooLarge < StandardError; end
|
|
13
|
+
MAX_RESPONSE_BYTES = 16_384
|
|
14
|
+
|
|
15
|
+
def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
16
|
+
|
|
17
|
+
def self.call(webhook:, payload:, deadline:)
|
|
18
|
+
new.call(webhook: webhook, payload: payload, deadline: deadline)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(webhook:, payload:, deadline:)
|
|
22
|
+
transmitted = false
|
|
23
|
+
http = nil
|
|
24
|
+
uri = WebhookValidator.validate!(webhook)
|
|
25
|
+
remaining = deadline - self.class.monotonic
|
|
26
|
+
raise DeadlineExceeded if remaining <= 0
|
|
27
|
+
|
|
28
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
29
|
+
http.use_ssl = true
|
|
30
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
31
|
+
http.max_retries = 0
|
|
32
|
+
http.open_timeout = [Defaults::SLACK_OPEN_TIMEOUT.to_f, remaining].min
|
|
33
|
+
http.read_timeout = [Defaults::SLACK_READ_TIMEOUT.to_f, remaining].min
|
|
34
|
+
http.write_timeout = [Defaults::SLACK_WRITE_TIMEOUT.to_f, remaining].min
|
|
35
|
+
request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
|
|
36
|
+
request.body = JSON.generate(payload)
|
|
37
|
+
result = nil
|
|
38
|
+
Timeout.timeout(remaining, DeadlineExceeded) do
|
|
39
|
+
http.start do
|
|
40
|
+
budget = deadline - self.class.monotonic
|
|
41
|
+
raise DeadlineExceeded if budget <= 0
|
|
42
|
+
|
|
43
|
+
http.read_timeout = [Defaults::SLACK_READ_TIMEOUT.to_f, budget].min
|
|
44
|
+
http.write_timeout = [Defaults::SLACK_WRITE_TIMEOUT.to_f, budget].min
|
|
45
|
+
transmitted = true
|
|
46
|
+
http.request(request) do |response|
|
|
47
|
+
bytes = 0
|
|
48
|
+
response.read_body do |chunk|
|
|
49
|
+
bytes += chunk.bytesize
|
|
50
|
+
raise ResponseTooLarge if bytes > MAX_RESPONSE_BYTES
|
|
51
|
+
raise DeadlineExceeded if self.class.monotonic >= deadline
|
|
52
|
+
end
|
|
53
|
+
raise DeadlineExceeded if self.class.monotonic >= deadline
|
|
54
|
+
|
|
55
|
+
code = response.code.to_i
|
|
56
|
+
retry_after = response["Retry-After"].to_s
|
|
57
|
+
seconds = retry_after.match?(/\A\d+\z/) ? retry_after.to_i : nil
|
|
58
|
+
result = classify(code, seconds)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
result
|
|
63
|
+
rescue WebhookValidator::Invalid, JSON::GeneratorError
|
|
64
|
+
Result.new(status: :failed, error_code: "invalid_webhook", unknown: false)
|
|
65
|
+
rescue OpenSSL::SSL::SSLError
|
|
66
|
+
Result.new(status: :failed, error_code: transmitted ? "delivery_outcome_unknown" : "tls_failure",
|
|
67
|
+
unknown: transmitted)
|
|
68
|
+
rescue DeadlineExceeded, Timeout::Error
|
|
69
|
+
Result.new(status: :retry, error_code: transmitted ? "delivery_outcome_unknown" : "network_timeout",
|
|
70
|
+
unknown: transmitted)
|
|
71
|
+
rescue SocketError, SystemCallError, IOError, ResponseTooLarge, Net::HTTPBadResponse, Net::ProtocolError
|
|
72
|
+
Result.new(status: :retry, error_code: transmitted ? "delivery_outcome_unknown" : "network_error",
|
|
73
|
+
unknown: transmitted)
|
|
74
|
+
ensure
|
|
75
|
+
begin
|
|
76
|
+
http.finish if http&.started?
|
|
77
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
78
|
+
# Return fixed error codes, without Slack response bodies or exception messages.
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def classify(code, retry_after)
|
|
85
|
+
case code
|
|
86
|
+
when 200..299
|
|
87
|
+
Result.new(status: :delivered, http_status: code, unknown: false)
|
|
88
|
+
when 300..399
|
|
89
|
+
Result.new(status: :failed, http_status: code, error_code: "redirect_rejected", unknown: false)
|
|
90
|
+
when 429
|
|
91
|
+
if retry_after && retry_after > Defaults::NOTIFICATION_RETRY_AFTER_CAP
|
|
92
|
+
Result.new(status: :failed, http_status: code, error_code: "retry_after_out_of_bounds", unknown: false)
|
|
93
|
+
else
|
|
94
|
+
Result.new(status: :retry, http_status: code, error_code: "rate_limited",
|
|
95
|
+
retry_after: retry_after&.positive? ? retry_after : nil, unknown: false)
|
|
96
|
+
end
|
|
97
|
+
when 408, 500..599
|
|
98
|
+
Result.new(status: :retry, http_status: code, error_code: "upstream_unavailable", unknown: false)
|
|
99
|
+
else
|
|
100
|
+
Result.new(status: :failed, http_status: code, error_code: "slack_rejected", unknown: false)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Slack
|
|
5
|
+
class Renderer
|
|
6
|
+
LABELS = { "advance_reminder" => "Upcoming on-call shift", "shift_start" => "On-call shift started",
|
|
7
|
+
"assignment_change" => "On-call assignment changed", "next_assignment_change" => "Next assignment changed",
|
|
8
|
+
"test" => "Munawaba test message" }.freeze
|
|
9
|
+
|
|
10
|
+
def self.escape(value, limit: 255)
|
|
11
|
+
value.to_s.truncate(limit, omission: "…").gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.person(person)
|
|
15
|
+
id = person.slack_member_id
|
|
16
|
+
id.present? && id.match?(/\A[A-Z][A-Z0-9]{1,31}\z/) ? "<@#{id}>" : escape(person.name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.call(delivery:, schedule:, shift: nil)
|
|
20
|
+
context = Notifications::Context::V1.validate!(kind: delivery.kind, context: delivery.context)
|
|
21
|
+
lines = ["#{LABELS.fetch(delivery.kind)} · #{escape(schedule.name, limit: 120)}"]
|
|
22
|
+
if delivery.kind == "assignment_change"
|
|
23
|
+
previous, current = Person.find(context.fetch("previous_person_id")),
|
|
24
|
+
Person.find(context.fetch("new_person_id"))
|
|
25
|
+
lines << "#{person(previous)} → #{person(current)}"
|
|
26
|
+
override = ShiftOverride.find(context["override_id"] || context.fetch("ended_override_id"))
|
|
27
|
+
lines << "Reason: #{escape(override.reason, limit: 500)}" if override.reason.present?
|
|
28
|
+
elsif delivery.kind == "next_assignment_change"
|
|
29
|
+
lines << "#{person(Person.find(context.fetch("previous_next_person_id")))} → #{person(Person.find(context.fetch("new_next_person_id")))}"
|
|
30
|
+
shift ||= Shift.find(context.fetch("described_shift_id"))
|
|
31
|
+
elsif shift
|
|
32
|
+
lines << "On call: #{person(shift.effective_person)}"
|
|
33
|
+
end
|
|
34
|
+
if shift
|
|
35
|
+
zone = ActiveSupport::TimeZone[schedule.time_zone]
|
|
36
|
+
lines << "#{shift.starts_at.in_time_zone(zone).strftime("%b %-d, %Y %H:%M")} – #{shift.ends_at.in_time_zone(zone).strftime("%b %-d, %Y %H:%M")} (#{escape(schedule.time_zone)})"
|
|
37
|
+
else
|
|
38
|
+
lines << "Timezone: #{escape(schedule.time_zone)}"
|
|
39
|
+
end
|
|
40
|
+
if Munawaba.config.application_base_url
|
|
41
|
+
base = Munawaba.config.application_base_url.sub(%r{/+\z}, "")
|
|
42
|
+
lines << "<#{base}/schedules/#{schedule.id}|View schedule>"
|
|
43
|
+
end
|
|
44
|
+
{ text: lines.join("\n"), unfurl_links: false, unfurl_media: false }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
module Munawaba
|
|
5
|
+
module Slack
|
|
6
|
+
class WebhookValidator
|
|
7
|
+
class Invalid < Munawaba::Error
|
|
8
|
+
def initialize(*) = super("Enter a valid allowed HTTPS Slack webhook")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.validate!(value)
|
|
12
|
+
raise Invalid unless value.is_a?(String) && !value.match?(/[\x00-\x20\x7f]/)
|
|
13
|
+
|
|
14
|
+
uri = URI.parse(value)
|
|
15
|
+
raise Invalid unless uri.is_a?(URI::HTTPS) && uri.port == 443 && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
|
|
16
|
+
raise Invalid unless Munawaba.config.slack_allowed_hosts.include?(uri.host&.downcase)
|
|
17
|
+
raise Invalid unless uri.path.present? && uri.path != "/" && !uri.path.match?(/%(?:0[0-9a-f]|1[0-9a-f]|7f)/i)
|
|
18
|
+
|
|
19
|
+
if %w[hooks.slack.com hooks.slack-gov.com].include?(uri.host.downcase)
|
|
20
|
+
raise Invalid unless uri.path.match?(%r{\A/services/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+\z})
|
|
21
|
+
end
|
|
22
|
+
uri
|
|
23
|
+
rescue URI::InvalidURIError
|
|
24
|
+
raise Invalid
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.valid?(value)
|
|
28
|
+
validate!(value)
|
|
29
|
+
true
|
|
30
|
+
rescue Invalid
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|