clockface 1.0.0.beta

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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +210 -0
  4. data/Rakefile +22 -0
  5. data/app/assets/config/clockface_manifest.js +2 -0
  6. data/app/assets/images/clockface/clockface.svg +34 -0
  7. data/app/assets/javascripts/clockface/application.js +17 -0
  8. data/app/assets/javascripts/clockface/flash.js +7 -0
  9. data/app/assets/javascripts/clockface/sorttable.js +494 -0
  10. data/app/assets/stylesheets/clockface/application.scss +80 -0
  11. data/app/assets/stylesheets/clockface/application/_fonts.scss +2 -0
  12. data/app/assets/stylesheets/clockface/application/colors.scss +8 -0
  13. data/app/assets/stylesheets/clockface/application/flash.scss +6 -0
  14. data/app/assets/stylesheets/clockface/application/footer.scss +37 -0
  15. data/app/assets/stylesheets/clockface/application/nav.scss +51 -0
  16. data/app/assets/stylesheets/clockface/events/delete.scss +45 -0
  17. data/app/assets/stylesheets/clockface/events/event_form.scss +62 -0
  18. data/app/assets/stylesheets/clockface/events/index.scss +56 -0
  19. data/app/assets/stylesheets/clockface/tasks/delete.scss +29 -0
  20. data/app/assets/stylesheets/clockface/tasks/index.scss +47 -0
  21. data/app/assets/stylesheets/clockface/tasks/task_form.scss +20 -0
  22. data/app/controllers/clockface/application_controller.rb +20 -0
  23. data/app/controllers/clockface/events_controller.rb +151 -0
  24. data/app/controllers/clockface/root_controller.rb +7 -0
  25. data/app/controllers/clockface/tasks_controller.rb +137 -0
  26. data/app/events/clockface/application_job.rb +4 -0
  27. data/app/helpers/clockface/application_helper.rb +4 -0
  28. data/app/helpers/clockface/config_helper.rb +32 -0
  29. data/app/helpers/clockface/events_helper.rb +37 -0
  30. data/app/helpers/clockface/logging_helper.rb +12 -0
  31. data/app/mailers/clockface/application_mailer.rb +6 -0
  32. data/app/models/clockface/application_record.rb +7 -0
  33. data/app/models/clockface/event.rb +179 -0
  34. data/app/models/clockface/task.rb +12 -0
  35. data/app/presenters/clockface/events_presenter.rb +48 -0
  36. data/app/services/clockface/event_validation_interactor.rb +35 -0
  37. data/app/services/clockface/task_validation_interactor.rb +25 -0
  38. data/app/views/clockface/application/_flash.html.erb +25 -0
  39. data/app/views/clockface/application/_footer.html.erb +15 -0
  40. data/app/views/clockface/application/_nav.html.erb +19 -0
  41. data/app/views/clockface/events/_event_form.html.erb +130 -0
  42. data/app/views/clockface/events/delete.html.erb +124 -0
  43. data/app/views/clockface/events/edit.html.erb +14 -0
  44. data/app/views/clockface/events/index.html.erb +108 -0
  45. data/app/views/clockface/events/new.html.erb +14 -0
  46. data/app/views/clockface/tasks/_task_form.html.erb +57 -0
  47. data/app/views/clockface/tasks/delete.html.erb +83 -0
  48. data/app/views/clockface/tasks/edit.html.erb +14 -0
  49. data/app/views/clockface/tasks/index.html.erb +70 -0
  50. data/app/views/clockface/tasks/new.html.erb +14 -0
  51. data/app/views/layouts/clockface/application.html.erb +27 -0
  52. data/config/locales/en.yml +158 -0
  53. data/config/routes.rb +15 -0
  54. data/db/migrate/20170528230549_create_clockface_tasks.rb +10 -0
  55. data/db/migrate/20170528234810_create_clockface_events.rb +20 -0
  56. data/lib/clockface.rb +135 -0
  57. data/lib/clockface/engine.rb +79 -0
  58. data/lib/clockface/version.rb +3 -0
  59. data/lib/clockwork/database_events/synchronizer.rb +73 -0
  60. data/lib/tasks/clockface_tasks.rake +4 -0
  61. metadata +199 -0
@@ -0,0 +1,20 @@
1
+ module Clockface
2
+ class ApplicationController < ActionController::Base
3
+ include ConfigHelper
4
+ include LoggingHelper
5
+
6
+ CAPTCHA_LENGTH = 5
7
+
8
+ protect_from_forgery with: :exception
9
+
10
+ private
11
+
12
+ def xhr_request?
13
+ (defined? request) && request.xhr?
14
+ end
15
+
16
+ def captcha_for(obj)
17
+ Digest::SHA1.hexdigest(obj.id.to_s).first(CAPTCHA_LENGTH)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,151 @@
1
+ module Clockface
2
+ class EventsController < ApplicationController
3
+ CAPTCHA_LENGTH = 5
4
+
5
+ def index
6
+ @events = all_events.map { |event| Clockface::EventsPresenter.new(event) }
7
+ end
8
+
9
+ def new
10
+ end
11
+
12
+ def create
13
+ event = Clockface::Event.new(events_params_for_create)
14
+ validation = validate_event(event)
15
+
16
+ if validation.failure?
17
+ flash[:error] = validation.errors
18
+ redirect_to clockface.new_event_path
19
+ return
20
+ end
21
+
22
+ event.save
23
+ flash[:success] = t("clockface.events.create.success")
24
+ clockface_log(:info, "Created Event: #{event.inspect}")
25
+ redirect_to clockface.events_path
26
+ end
27
+
28
+ def edit
29
+ @event = Clockface::Event.find_by_id(params[:id])
30
+ return if @event
31
+
32
+ redirect_to events_path
33
+ flash[:error] = t("clockface.events.edit.validation.invalid_id")
34
+ end
35
+
36
+ def update
37
+ event = Clockface::Event.find_by_id(params[:id])
38
+
39
+ unless event
40
+ flash[:error] =
41
+ t("clockface.events.update.event_not_found", id: params[:id])
42
+ redirect_to events_path
43
+ return
44
+ end
45
+
46
+ event.attributes = events_params_for_update
47
+ validation = validate_event(event)
48
+
49
+ if validation.success?
50
+ event.save
51
+ flash[:success] = t("clockface.events.update.success")
52
+ clockface_log(:info, "Updated Event: #{event.inspect}")
53
+ redirect_to clockface.events_path
54
+ else
55
+ flash[:error] = validation.errors
56
+ redirect_to clockface.edit_event_path(event)
57
+ end
58
+ end
59
+
60
+ def delete
61
+ event = Clockface::Event.find_by_id(params[:event_id])
62
+
63
+ unless event
64
+ redirect_to events_path
65
+ flash[:error] = t("clockface.events.delete.validation.invalid_id")
66
+ return
67
+ end
68
+
69
+ @event = Clockface::EventsPresenter.new(event)
70
+ @captcha = captcha_for(event)
71
+ end
72
+
73
+ def destroy
74
+ event = Clockface::Event.find_by_id(params[:id])
75
+
76
+ unless event
77
+ flash[:error] =
78
+ t("clockface.events.destroy.event_not_found", id: params[:id])
79
+ redirect_to events_path
80
+ return
81
+ end
82
+
83
+ if (params[:captcha] || "") != captcha_for(event)
84
+ flash[:error] =
85
+ t("clockface.events.destroy.validation.incorrect_captcha")
86
+ redirect_to event_delete_path(event)
87
+ return
88
+ end
89
+
90
+ unless event.destroy
91
+ flash[:error] = t("clockface.events.destroy.failure")
92
+ redirect_to event_delete_path(event)
93
+ return
94
+ end
95
+
96
+ flash[:success] = t("clockface.events.destroy.success")
97
+ clockface_log(:info, "Destroyed Event: #{event.inspect}")
98
+ redirect_to events_path
99
+ end
100
+
101
+ private
102
+
103
+ def all_events
104
+ Clockface::Event.includes(:task).order(:id)
105
+ end
106
+
107
+ def events_params_for_create
108
+ params.require(:event).permit(
109
+ :clockface_task_id,
110
+ :name,
111
+ :enabled,
112
+ :period_value,
113
+ :period_units,
114
+ :day_of_week,
115
+ :hour,
116
+ :minute,
117
+ :time_zone,
118
+ :if_condition
119
+ ).tap do |params|
120
+ params[:hour] = nil if params[:hour] == "**"
121
+ params[:minute] = nil if params[:minute] == "**"
122
+ end
123
+ end
124
+
125
+ def events_params_for_update
126
+ params.require(:event).permit(
127
+ :name,
128
+ :enabled,
129
+ :period_value,
130
+ :period_units,
131
+ :day_of_week,
132
+ :hour,
133
+ :minute,
134
+ :time_zone,
135
+ :if_condition
136
+ ).tap do |params|
137
+ params[:hour] = nil if params[:hour] == "**"
138
+ params[:minute] = nil if params[:minute] == "**"
139
+ end
140
+ end
141
+
142
+ def validate_event(event)
143
+ Clockface::EventValidationInteractor.
144
+ call(event: event, action: params[:action])
145
+ end
146
+
147
+ def captcha_for(event)
148
+ Digest::SHA1.hexdigest(event.id.to_s).first(CAPTCHA_LENGTH)
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,7 @@
1
+ module Clockface
2
+ class RootController < ApplicationController
3
+ def index
4
+ redirect_to clockface.events_path
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,137 @@
1
+ module Clockface
2
+ class TasksController < ApplicationController
3
+ def index
4
+ @tasks = all_tasks
5
+ end
6
+
7
+ def new
8
+ end
9
+
10
+ def create
11
+ task = Clockface::Task.new(tasks_params_for_create)
12
+ validation = validate_task(task)
13
+
14
+ if validation.failure?
15
+ flash[:error] = validation.errors
16
+ redirect_to clockface.new_task_path
17
+ return
18
+ end
19
+
20
+ task.save
21
+ flash[:success] = t("clockface.tasks.create.success")
22
+ clockface_log(:info, "Created Task: #{task.inspect}")
23
+ redirect_to clockface.tasks_path
24
+ end
25
+
26
+ def edit
27
+ @task = Clockface::Task.find_by_id(params[:id])
28
+ return if @task
29
+
30
+ redirect_to tasks_path
31
+ flash[:error] = t("clockface.tasks.edit.validation.invalid_id")
32
+ end
33
+
34
+ def update
35
+ task = Clockface::Task.find_by_id(params[:id])
36
+
37
+ unless task
38
+ flash[:error] =
39
+ t("clockface.tasks.update.task_not_found", id: params[:id])
40
+ redirect_to tasks_path
41
+ return
42
+ end
43
+
44
+ task.attributes = tasks_params_for_update
45
+ validation = validate_task(task)
46
+
47
+ if validation.success?
48
+ task.save
49
+ flash[:success] = t("clockface.tasks.update.success")
50
+ clockface_log(:info, "Updated Task: #{task.inspect}")
51
+ redirect_to clockface.tasks_path
52
+ else
53
+ flash[:error] = validation.errors
54
+ redirect_to clockface.edit_task_path(task)
55
+ end
56
+ end
57
+
58
+ def delete
59
+ @task = Clockface::Task.find_by_id(params[:task_id])
60
+
61
+ unless @task
62
+ redirect_to tasks_path
63
+ flash[:error] = t("clockface.tasks.delete.validation.invalid_id")
64
+ return
65
+ end
66
+
67
+ @captcha = captcha_for(@task)
68
+ end
69
+
70
+ def destroy
71
+ task = Clockface::Task.find_by_id(params[:id])
72
+
73
+ unless task
74
+ flash[:error] =
75
+ t("clockface.tasks.destroy.task_not_found", id: params[:id])
76
+ redirect_to tasks_path
77
+ return
78
+ end
79
+
80
+ # TODO: Move this to interactor
81
+
82
+ if (params[:captcha] || "") != captcha_for(task)
83
+ flash[:error] =
84
+ t("clockface.tasks.destroy.validation.incorrect_captcha")
85
+ redirect_to task_delete_path(task)
86
+ return
87
+ end
88
+
89
+ if task.events.any?
90
+ flash[:error] =
91
+ t(
92
+ "clockface.tasks.destroy.validation.events_exist",
93
+ count: task.events.count
94
+ )
95
+ redirect_to task_delete_path(task)
96
+ return
97
+ end
98
+
99
+ unless task.destroy
100
+ flash[:error] = t("clockface.tasks.destroy.failure")
101
+ redirect_to task_delete_path(task)
102
+ return
103
+ end
104
+
105
+ flash[:success] = t("clockface.tasks.destroy.success")
106
+ clockface_log(:info, "Destroyed Task: #{task.inspect}")
107
+ redirect_to tasks_path
108
+ end
109
+
110
+ private
111
+
112
+ def all_tasks
113
+ Clockface::Task.includes(:events).order(:id)
114
+ end
115
+
116
+ def tasks_params_for_create
117
+ params.require(:task).permit(
118
+ :name,
119
+ :description,
120
+ :command
121
+ )
122
+ end
123
+
124
+ def tasks_params_for_update
125
+ params.require(:task).permit(
126
+ :name,
127
+ :description,
128
+ :command
129
+ )
130
+ end
131
+
132
+ def validate_task(task)
133
+ Clockface::TaskValidationInteractor.
134
+ call(task: task, action: params[:action])
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,4 @@
1
+ module Clockface
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Clockface
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,32 @@
1
+ module Clockface
2
+ module ConfigHelper
3
+ def clockface_time_zone
4
+ tz = Clockface::Engine.config.clockface.time_zone
5
+ ActiveSupport::TimeZone::MAPPING.key?(tz) ? tz : "UTC"
6
+ end
7
+
8
+ def clockface_tenant_list
9
+ Clockface::Engine.config.clockface.tenant_list
10
+ end
11
+
12
+ def clockface_single_tenancy_enabled?
13
+ clockface_tenant_list.empty?
14
+ end
15
+
16
+ def clockface_multi_tenancy_enabled?
17
+ clockface_tenant_list.any?
18
+ end
19
+
20
+ def clockface_current_tenant
21
+ Clockface::Engine.config.clockface.current_tenant_proc.call
22
+ end
23
+
24
+ def clockface_execute_in_tenant(tenant_name, some_proc, proc_args = [])
25
+ Clockface::Engine.config.clockface.execute_in_tenant_proc.call(
26
+ tenant_name,
27
+ some_proc,
28
+ proc_args
29
+ )
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ module Clockface
2
+ module EventsHelper
3
+ include ConfigHelper
4
+
5
+ def event_form_select_options_for_name
6
+ Clockface::Task.order(:id).collect { |e| [e.name, e.id] }
7
+ end
8
+
9
+ def event_form_select_options_for_period_units
10
+ Clockface::Event::PERIOD_UNITS.map do |unit|
11
+ [t("datetime.units.#{unit}"), unit]
12
+ end
13
+ end
14
+
15
+ def event_form_select_options_for_day_of_week
16
+ t("date.day_names").each_with_index.map { |day, i| [day, i] }
17
+ end
18
+
19
+ def event_form_select_options_for_hour
20
+ (["**"] + (0..23).to_a).map { |h| [h.to_s.rjust(2, "0"), h] }
21
+ end
22
+
23
+ def event_form_select_options_for_minute
24
+ (["**"] + (0..59).to_a).map { |m| [m.to_s.rjust(2, "0"), m] }
25
+ end
26
+
27
+ def event_form_select_options_for_if_condition
28
+ Clockface::Event::IF_CONDITIONS.keys.map do |if_condition|
29
+ [
30
+ Clockface::Event.
31
+ human_attribute_name("if_condition.#{if_condition}"),
32
+ if_condition
33
+ ]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,12 @@
1
+ module Clockface
2
+ module LoggingHelper
3
+ def clockface_log(level, msg)
4
+ # Clockface logger can be a single `Logger` or an array of many `Logger`s
5
+ logs = Clockface::Engine.config.clockface.logger
6
+ logs = [logs] unless logs.is_a?(Array)
7
+
8
+ # Log to each individual logger
9
+ logs.each { |log| log.send(level, "[Clockface] #{msg}") }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ module Clockface
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module Clockface
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ include Clockface::ConfigHelper
4
+
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,179 @@
1
+ module Clockface
2
+ class Event < ApplicationRecord
3
+ extend Forwardable
4
+
5
+ PERIOD_UNITS = %w[seconds minutes hours days weeks months years].freeze
6
+ IF_CONDITIONS = {
7
+ "even_week" => ->(time) { time.strftime("%W").to_i.even? },
8
+ "odd_week" => ->(time) { time.strftime("%W").to_i.odd? },
9
+ "weekday" => ->(time) { (time.strftime("%a")[0] != "S") },
10
+ "first_of_month" => ->(time) { time.strftime("%-d").to_i == 1 },
11
+ "last_of_month" => ->(time) { (time + 1.day).strftime("%-d").to_i == 1 }
12
+ }.freeze
13
+
14
+ belongs_to(
15
+ :task,
16
+ foreign_key: "clockface_task_id",
17
+ class_name: "Clockface::Task"
18
+ )
19
+
20
+ before_validation do
21
+ self[:enabled] = false if self[:enabled].nil?
22
+ self[:time_zone] = clockface_time_zone if self[:time_zone].blank?
23
+ self[:if_condition] = nil if self[:if_condition].blank?
24
+ default_tenant_if_needed
25
+ end
26
+
27
+ # rubocop:disable LineLength
28
+ validates :period_value, presence: true, numericality: { greater_than: 0 }
29
+ validates :period_units, presence: true, inclusion: PERIOD_UNITS
30
+ validates :day_of_week, inclusion: { in: 0..6 }, allow_nil: true
31
+ validates :hour, inclusion: { in: 0..23 }, allow_nil: true
32
+ validates :minute, inclusion: { in: 0..59 }, allow_nil: true
33
+ validates :time_zone, inclusion: ActiveSupport::TimeZone::MAPPING.keys, allow_nil: true
34
+ validates :if_condition, inclusion: IF_CONDITIONS.keys, allow_nil: true
35
+ # rubocop:enable LineLength
36
+
37
+ with_options if: proc { clockface_multi_tenancy_enabled? } do |x|
38
+ x.validate :tenant_is_valid
39
+ end
40
+
41
+ with_options if: proc { !clockface_multi_tenancy_enabled? } do |x|
42
+ x.validates :tenant, absence: true
43
+ end
44
+
45
+ validate :day_of_week_must_have_timestamp
46
+
47
+ def_delegators(
48
+ :task,
49
+ :name,
50
+ :description,
51
+ :command
52
+ )
53
+
54
+ def self.find_duplicates_of(event)
55
+ Clockface::Event.where(
56
+ period_value: event.period_value,
57
+ period_units: event.period_units,
58
+ day_of_week: event.day_of_week,
59
+ hour: event.hour,
60
+ minute: event.minute,
61
+ time_zone: event.time_zone,
62
+ if_condition: event.if_condition
63
+ ).where.not(id: event.id)
64
+ end
65
+
66
+ def period
67
+ # e.g. period_value: 2, period_units: weeks
68
+ # => 2.weeks
69
+ period_value.send(period_units.to_sym)
70
+ end
71
+ # Note: Clockwork refers to this value as the period internally, but
72
+ # `sync_database_events` expects this model to respond to `:frequency`
73
+ # Keep consistent with internal language by using period, but add alias
74
+ # for compatibility.
75
+ # It's also extra confusing since in most fields (e.g. Physics) the words
76
+ # 'period' and 'frequency' are inverses of each other... oh well.
77
+ alias frequency period
78
+
79
+ def at
80
+ return nil if self[:hour].nil? && self[:minute].nil?
81
+
82
+ [
83
+ at_formatted_day_of_week,
84
+ [at_formatted_hour, at_formatted_minute].join(":")
85
+ ].compact.join(" ")
86
+ end
87
+
88
+ def tz
89
+ # Active Support stores a mapping between human readable and IANA time
90
+ # zones. These mappings can be found in `ActiveSupport::TimeZone::MAPPING`
91
+ #
92
+ # e.g.
93
+ #
94
+ # "Chennai" => "Asia/Kolkata"
95
+ # "Kathmandu" => "Asia/Kathmandu"
96
+ # "Tokyo" => "Asia/Tokyo"
97
+ #
98
+ # Since multiple human names can point to the same IANA time zone, we
99
+ # store the human readable name in the underlying `time_zone` DB field
100
+ #
101
+ # The Clockwork API dictates that the model must respond to `:tz` and
102
+ # return the IANA name (See `Clockwork::Event#convert_timezone`), so we
103
+ # convert the value using ActiveSupport Mapping first
104
+ #
105
+ ActiveSupport::TimeZone::MAPPING[self[:time_zone]]
106
+ end
107
+
108
+ def if?(time)
109
+ if self[:if_condition].present?
110
+ IF_CONDITIONS[self[:if_condition]].call(time)
111
+ else
112
+ true
113
+ end
114
+ end
115
+
116
+ def if=(if_condition)
117
+ self[:if_condition] = if_condition
118
+ end
119
+
120
+ def ignored_attributes
121
+ # Every time Clockwork reloads the models from the database it compares
122
+ # the before/after attributes to see if the model `has_changed?`. If any
123
+ # attributes have been changed, it reloads the task.
124
+ # The Clockwork API lets us selectively ignore some fields in this
125
+ # attribute comparison.
126
+ # Exclude `last_triggered_at` and `updated_at` since they will always
127
+ # change on each run
128
+ %i[last_triggered_at updated_at]
129
+ end
130
+
131
+ private
132
+
133
+ # rubocop:disable Style/GuardClause
134
+
135
+ def default_tenant_if_needed
136
+ if clockface_multi_tenancy_enabled? && self[:tenant].blank?
137
+ self[:tenant] = clockface_current_tenant
138
+ end
139
+ end
140
+
141
+ def tenant_is_valid
142
+ if self[:tenant] != clockface_current_tenant
143
+ errors.add(
144
+ :tenant,
145
+ I18n.t(
146
+ "activerecord.errors.models.clockface/event."\
147
+ "attributes.tenant.invalid"
148
+ )
149
+ )
150
+ end
151
+ end
152
+
153
+ def day_of_week_must_have_timestamp
154
+ if self[:hour].nil? && self[:minute].nil? && !self[:day_of_week].nil?
155
+ errors.add(
156
+ :day_of_week,
157
+ I18n.t(
158
+ "activerecord.errors.models.clockface/event."\
159
+ "attributes.day_of_week.day_of_week_must_have_timestamp"
160
+ )
161
+ )
162
+ end
163
+ end
164
+
165
+ # rubocop:enable Style/GuardClause
166
+
167
+ def at_formatted_day_of_week
168
+ Date::DAYNAMES[self[:day_of_week]] if self[:day_of_week].present?
169
+ end
170
+
171
+ def at_formatted_hour
172
+ self[:hour].present? ? self[:hour].to_s.rjust(2, "0") : "**"
173
+ end
174
+
175
+ def at_formatted_minute
176
+ self[:minute].present? ? self[:minute].to_s.rjust(2, "0") : "**"
177
+ end
178
+ end
179
+ end