motor-admin 0.1.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +32 -0
  4. data/Rakefile +20 -0
  5. data/app/controllers/motor/alerts_controller.rb +74 -0
  6. data/app/controllers/motor/api_base_controller.rb +25 -0
  7. data/app/controllers/motor/application_controller.rb +6 -0
  8. data/app/controllers/motor/assets_controller.rb +28 -0
  9. data/app/controllers/motor/configs_controller.rb +30 -0
  10. data/app/controllers/motor/dashboards_controller.rb +54 -0
  11. data/app/controllers/motor/data_controller.rb +102 -0
  12. data/app/controllers/motor/forms_controller.rb +54 -0
  13. data/app/controllers/motor/queries_controller.rb +54 -0
  14. data/app/controllers/motor/resource_methods_controller.rb +21 -0
  15. data/app/controllers/motor/resources_controller.rb +23 -0
  16. data/app/controllers/motor/run_queries_controller.rb +45 -0
  17. data/app/controllers/motor/schemas_controller.rb +11 -0
  18. data/app/controllers/motor/send_alerts_controller.rb +24 -0
  19. data/app/controllers/motor/tags_controller.rb +11 -0
  20. data/app/controllers/motor/ui_controller.rb +19 -0
  21. data/app/jobs/motor/alert_sending_job.rb +13 -0
  22. data/app/jobs/motor/application_job.rb +6 -0
  23. data/app/mailers/motor/alerts_mailer.rb +50 -0
  24. data/app/mailers/motor/application_mailer.rb +7 -0
  25. data/app/models/motor/alert.rb +22 -0
  26. data/app/models/motor/alert_lock.rb +7 -0
  27. data/app/models/motor/application_record.rb +8 -0
  28. data/app/models/motor/config.rb +7 -0
  29. data/app/models/motor/dashboard.rb +18 -0
  30. data/app/models/motor/form.rb +14 -0
  31. data/app/models/motor/query.rb +14 -0
  32. data/app/models/motor/resource.rb +7 -0
  33. data/app/models/motor/tag.rb +7 -0
  34. data/app/models/motor/taggable_tag.rb +8 -0
  35. data/app/views/layouts/motor/application.html.erb +14 -0
  36. data/app/views/motor/alerts_mailer/alert_email.html.erb +126 -0
  37. data/app/views/motor/ui/show.html.erb +1 -0
  38. data/config/routes.rb +60 -0
  39. data/lib/generators/motor/install_generator.rb +22 -0
  40. data/lib/generators/motor/migration.rb +17 -0
  41. data/lib/generators/motor/templates/install.rb +135 -0
  42. data/lib/motor-admin.rb +3 -0
  43. data/lib/motor.rb +47 -0
  44. data/lib/motor/active_record_utils.rb +7 -0
  45. data/lib/motor/active_record_utils/fetch_methods.rb +24 -0
  46. data/lib/motor/active_record_utils/types.rb +54 -0
  47. data/lib/motor/admin.rb +12 -0
  48. data/lib/motor/alerts.rb +10 -0
  49. data/lib/motor/alerts/persistance.rb +84 -0
  50. data/lib/motor/alerts/scheduled_alerts_cache.rb +29 -0
  51. data/lib/motor/alerts/scheduler.rb +30 -0
  52. data/lib/motor/api.rb +6 -0
  53. data/lib/motor/api_query.rb +22 -0
  54. data/lib/motor/api_query/build_json.rb +109 -0
  55. data/lib/motor/api_query/build_meta.rb +17 -0
  56. data/lib/motor/api_query/filter.rb +55 -0
  57. data/lib/motor/api_query/paginate.rb +18 -0
  58. data/lib/motor/api_query/search.rb +73 -0
  59. data/lib/motor/api_query/sort.rb +27 -0
  60. data/lib/motor/assets.rb +45 -0
  61. data/lib/motor/build_schema.rb +23 -0
  62. data/lib/motor/build_schema/find_display_column.rb +60 -0
  63. data/lib/motor/build_schema/load_from_rails.rb +176 -0
  64. data/lib/motor/build_schema/merge_schema_configs.rb +77 -0
  65. data/lib/motor/build_schema/persist_resource_configs.rb +208 -0
  66. data/lib/motor/build_schema/reorder_schema.rb +52 -0
  67. data/lib/motor/build_schema/utils.rb +17 -0
  68. data/lib/motor/dashboards.rb +8 -0
  69. data/lib/motor/dashboards/persistance.rb +63 -0
  70. data/lib/motor/forms.rb +8 -0
  71. data/lib/motor/forms/persistance.rb +63 -0
  72. data/lib/motor/hash_serializer.rb +21 -0
  73. data/lib/motor/queries.rb +10 -0
  74. data/lib/motor/queries/persistance.rb +63 -0
  75. data/lib/motor/queries/postgresql_exec_query.rb +28 -0
  76. data/lib/motor/queries/run_query.rb +68 -0
  77. data/lib/motor/tags.rb +31 -0
  78. data/lib/motor/ui_configs.rb +62 -0
  79. data/lib/motor/version.rb +5 -0
  80. data/ui/dist/fonts/ionicons.woff2 +0 -0
  81. data/ui/dist/main-46621a8bdbb789e17c3f.css.gz +0 -0
  82. data/ui/dist/main-46621a8bdbb789e17c3f.js.gz +0 -0
  83. data/ui/dist/manifest.json +13 -0
  84. metadata +237 -0
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module ApiQuery
5
+ module Paginate
6
+ MAX_PER_PAGE = 500
7
+
8
+ module_function
9
+
10
+ def call(rel, params)
11
+ params ||= {}
12
+
13
+ rel = rel.limit([MAX_PER_PAGE, (params[:limit] || MAX_PER_PAGE).to_i].min)
14
+ rel.offset(params[:offset].to_i)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module ApiQuery
5
+ module Search
6
+ SELECT_COLUMNS_AMOUNT = 2
7
+ COLUMN_TYPES = BuildSchema::SEARCHABLE_COLUMN_TYPES
8
+ ID_REGEXP = /\A\d+\z/.freeze
9
+
10
+ module_function
11
+
12
+ def call(rel, keyword)
13
+ return rel if keyword.blank?
14
+
15
+ filters = fetch_filters(rel, keyword)
16
+
17
+ arel_where = build_arel_or_query(filters)
18
+
19
+ rel.where(arel_where)
20
+ end
21
+
22
+ def fetch_filters(rel, keyword)
23
+ arel_filters = []
24
+
25
+ klass = rel.klass
26
+ arel_table = klass.arel_table
27
+
28
+ arel_filters << arel_table[klass.primary_key].eq(keyword) if keyword.match?(ID_REGEXP)
29
+
30
+ string_column_names = find_searchable_columns(klass)
31
+ selected_columns = select_columns(string_column_names)
32
+
33
+ selected_columns.each { |name| arel_filters << arel_table[name].matches("%#{keyword}%") }
34
+
35
+ arel_filters
36
+ end
37
+
38
+ def build_arel_or_query(filter_array)
39
+ filter_array.reduce(nil) do |acc, filter|
40
+ next acc = filter unless acc
41
+
42
+ acc.or(filter)
43
+ end
44
+ end
45
+
46
+ def select_columns(columns)
47
+ selected_columns =
48
+ columns.select do |name|
49
+ BuildSchema::FindDisplayColumn::DISPLAY_NAME_REGEXP.match?(name)
50
+ end.presence
51
+
52
+ selected_columns ||= columns.first(SELECT_COLUMNS_AMOUNT)
53
+
54
+ selected_columns
55
+ end
56
+
57
+ def find_searchable_columns(model)
58
+ model.columns.map do |column|
59
+ next unless column.type.in?(COLUMN_TYPES)
60
+
61
+ has_inclusion_validator =
62
+ model.validators_on(column.name).any? do |e|
63
+ e.is_a?(ActiveModel::Validations::InclusionValidator)
64
+ end
65
+
66
+ next if has_inclusion_validator
67
+
68
+ column.name
69
+ end.compact
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module ApiQuery
5
+ module Sort
6
+ FIELD_PARSE_REGEXP = /\A(-)?(.*)\z/.freeze
7
+
8
+ module_function
9
+
10
+ def call(rel, params)
11
+ return rel if params.blank?
12
+
13
+ normalized_params = build_params(params)
14
+
15
+ rel.order(normalized_params)
16
+ end
17
+
18
+ def build_params(param)
19
+ param.split(',').each_with_object({}) do |field, hash|
20
+ direction, name = field.match(FIELD_PARSE_REGEXP).captures
21
+
22
+ hash[name] = direction.present? ? :desc : :asc
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Assets
5
+ InvalidPathError = Class.new(StandardError)
6
+
7
+ ASSETS_PATH = Pathname.new(__dir__).join('../../ui/dist')
8
+ MANIFEST_PATH = ASSETS_PATH.join('manifest.json')
9
+ DEV_SERVER_URL = 'http://localhost:9090/'
10
+
11
+ module_function
12
+
13
+ def manifest
14
+ JSON.parse(MANIFEST_PATH.read)
15
+ end
16
+
17
+ def asset_path(path)
18
+ Motor::Admin.routes.url_helpers.motor_asset_path(manifest[path])
19
+ end
20
+
21
+ def load_asset(filename)
22
+ if Motor.development?
23
+ load_from_dev_server(filename)
24
+ else
25
+ load_from_disk(filename)
26
+ end
27
+ end
28
+
29
+ def load_from_disk(filename)
30
+ filename += '.gz' if filename.match?(/\.(?:js|css)\z/)
31
+
32
+ path = ASSETS_PATH.join(filename)
33
+
34
+ raise InvalidPathError unless path.to_s.starts_with?(ASSETS_PATH.to_s)
35
+
36
+ path.read
37
+ end
38
+
39
+ def load_from_dev_server(filename)
40
+ uri = URI(DEV_SERVER_URL + filename)
41
+
42
+ Net::HTTP.get_response(uri).body
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './build_schema/load_from_rails'
4
+ require_relative './build_schema/find_display_column'
5
+ require_relative './build_schema/persist_resource_configs'
6
+ require_relative './build_schema/reorder_schema'
7
+ require_relative './build_schema/merge_schema_configs'
8
+ require_relative './build_schema/utils'
9
+
10
+ module Motor
11
+ module BuildSchema
12
+ SEARCHABLE_COLUMN_TYPES = %i[citext text string bitstring].freeze
13
+
14
+ module_function
15
+
16
+ def call
17
+ schema = LoadFromRails.call
18
+ schema = MergeSchemaConfigs.call(schema)
19
+
20
+ ReorderSchema.call(schema)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module BuildSchema
5
+ module FindDisplayColumn
6
+ DISPLAY_NAMES = %w[
7
+ name
8
+ full_name
9
+ fullname
10
+ last_name
11
+ lastname
12
+ first_name
13
+ firstname
14
+ fname
15
+ lname
16
+ sname
17
+ phone
18
+ phone_number
19
+ email
20
+ domain
21
+ phone
22
+ company
23
+ filename
24
+ file_name
25
+ title
26
+ url
27
+ make
28
+ brand
29
+ manufacturer
30
+ model
31
+ address
32
+ ].freeze
33
+
34
+ DISPLAY_NAME_REGEXP = Regexp.new(Regexp.union(DISPLAY_NAMES), Regexp::IGNORECASE)
35
+
36
+ module_function
37
+
38
+ def call(model)
39
+ column_names = fetch_column_names(model)
40
+
41
+ select_column_name(column_names)
42
+ end
43
+
44
+ def select_column_name(column_names)
45
+ name = column_names.find { |column_name| column_name.in?(DISPLAY_NAMES) }
46
+ name ||= column_names.find { |column_name| column_name.match?(DISPLAY_NAME_REGEXP) }
47
+
48
+ name
49
+ end
50
+
51
+ def fetch_column_names(model)
52
+ model.columns.map do |column|
53
+ next unless column.type.in?(BuildSchema::SEARCHABLE_COLUMN_TYPES)
54
+
55
+ column.name
56
+ end.compact
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module BuildSchema
5
+ module LoadFromRails
6
+ module ColumnAccessTypes
7
+ ALL = [
8
+ READ_ONLY = 'read_only',
9
+ WRITE_ONLY = 'write_only',
10
+ READ_WRITE = 'read_write',
11
+ HIDDEN = 'hidden'
12
+ ].freeze
13
+ end
14
+
15
+ COLUMN_NAME_ACCESS_TYPES = {
16
+ id: ColumnAccessTypes::READ_ONLY,
17
+ created_at: ColumnAccessTypes::READ_ONLY,
18
+ updated_at: ColumnAccessTypes::READ_ONLY,
19
+ deleted_at: ColumnAccessTypes::READ_ONLY
20
+ }.with_indifferent_access.freeze
21
+
22
+ DEFAULT_ACTIONS = [
23
+ {
24
+ name: 'create',
25
+ display_name: 'Create',
26
+ action_type: 'default',
27
+ preferences: {},
28
+ visible: true
29
+ },
30
+ {
31
+ name: 'edit',
32
+ display_name: 'Edit',
33
+ action_type: 'default',
34
+ preferences: {},
35
+ visible: true
36
+ },
37
+ {
38
+ name: 'remove',
39
+ display_name: 'Remove',
40
+ action_type: 'default',
41
+ preferences: {},
42
+ visible: true
43
+ }
44
+ ].freeze
45
+
46
+ DEFAULT_TABS = [
47
+ {
48
+ name: 'summary',
49
+ display_name: 'Summary',
50
+ tab_type: 'default',
51
+ preferences: {},
52
+ visible: true
53
+ }
54
+ ].freeze
55
+
56
+ module_function
57
+
58
+ def call
59
+ models.map do |model|
60
+ build_model_schema(model)
61
+ end
62
+ end
63
+
64
+ def models
65
+ Rails.application.eager_load!
66
+
67
+ models = load_descendants(ActiveRecord::Base).uniq
68
+ models = models.reject(&:abstract_class)
69
+
70
+ models -= Motor::ApplicationRecord.descendants
71
+ models -= [ActiveRecord::SchemaMigration] if defined?(ActiveRecord::SchemaMigration)
72
+ models -= [ActiveStorage::Blob, ActiveStorage::VariantRecord] if defined?(ActiveStorage::Blob)
73
+
74
+ models
75
+ end
76
+
77
+ def load_descendants(model)
78
+ model.descendants + model.descendants.flat_map do |klass|
79
+ load_descendants(klass)
80
+ end
81
+ end
82
+
83
+ def build_model_schema(model)
84
+ {
85
+ name: model.name.underscore,
86
+ slug: Utils.slugify(model),
87
+ table_name: model.table_name,
88
+ primary_key: model.primary_key,
89
+ display_name: model.name.titleize.pluralize,
90
+ display_column: FindDisplayColumn.call(model),
91
+ columns: fetch_columns(model),
92
+ associations: fetch_associations(model),
93
+ actions: DEFAULT_ACTIONS,
94
+ tabs: DEFAULT_TABS,
95
+ visible: true
96
+ }.with_indifferent_access
97
+ end
98
+
99
+ def fetch_columns(model)
100
+ default_attrs = model.new.attributes
101
+
102
+ model.columns.map do |column|
103
+ {
104
+ name: column.name,
105
+ display_name: column.name.humanize,
106
+ column_type: ActiveRecordUtils::Types::UNIFIED_TYPES[column.type.to_s] || column.type.to_s,
107
+ access_type: COLUMN_NAME_ACCESS_TYPES.fetch(column.name, ColumnAccessTypes::READ_WRITE),
108
+ default_value: default_attrs[column.name],
109
+ validators: fetch_validators(model, column.name),
110
+ virtual: false
111
+ }
112
+ end
113
+ end
114
+
115
+ def fetch_associations(model)
116
+ model.reflections.map do |name, ref|
117
+ next if ref.polymorphic? && !ref.belongs_to?
118
+
119
+ begin
120
+ ref.klass
121
+ rescue StandardError
122
+ next
123
+ end
124
+
125
+ next if defined?(ActiveStorage::Blob) && ref.klass == ActiveStorage::Blob
126
+
127
+ {
128
+ name: name,
129
+ display_name: name.humanize,
130
+ slug: name.underscore,
131
+ model_name: ref.klass.name.underscore,
132
+ model_slug: Utils.slugify(ref.klass),
133
+ association_type: fetch_association_type(ref),
134
+ foreign_key: ref.foreign_key,
135
+ polymorphic: ref.polymorphic?,
136
+ visible: true
137
+ }
138
+ end.compact
139
+ end
140
+
141
+ def fetch_association_type(association)
142
+ case association.association_class.to_s
143
+ when 'ActiveRecord::Associations::HasManyAssociation',
144
+ 'ActiveRecord::Associations::HasManyThroughAssociation'
145
+ 'has_many'
146
+ when 'ActiveRecord::Associations::HasOneAssociation',
147
+ 'ActiveRecord::Associations::HasOneThroughAssociation'
148
+ 'has_one'
149
+ when 'ActiveRecord::Associations::BelongsToAssociation'
150
+ 'belongs_to'
151
+ else
152
+ raise ArgumentError, 'Unknown association type'
153
+ end
154
+ end
155
+
156
+ def fetch_validators(model, column_name)
157
+ model.validators_on(column_name).map do |validator|
158
+ case validator
159
+ when ActiveModel::Validations::InclusionValidator
160
+ { includes: validator.send(:delimiter) }
161
+ when ActiveRecord::Validations::PresenceValidator
162
+ { required: true }
163
+ when ActiveModel::Validations::FormatValidator
164
+ { format: JsRegex.new(validator.options[:with]).to_h.slice(:source, :options) }
165
+ when ActiveRecord::Validations::LengthValidator
166
+ { length: validator.options }
167
+ when ActiveModel::Validations::NumericalityValidator
168
+ { numeric: validator.options }
169
+ else
170
+ next
171
+ end
172
+ end.compact
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module BuildSchema
5
+ module MergeSchemaConfigs
6
+ RESOURCE_ATTRS = PersistResourceConfigs::RESOURCE_ATTRS
7
+ COLUMN_DEFAULTS = PersistResourceConfigs::COLUMN_DEFAULTS
8
+ ACTION_DEFAULTS = PersistResourceConfigs::ACTION_DEFAULTS
9
+ TAB_DEFAULTS = PersistResourceConfigs::TAB_DEFAULTS
10
+
11
+ module_function
12
+
13
+ # @param schema [Array<HashWithIndifferentAccess>]
14
+ # @return [Array<HashWithIndifferentAccess>]
15
+ def call(schema)
16
+ configs = load_configs
17
+
18
+ schema.map do |model|
19
+ merge_model(model, configs.fetch(model[:name], {}))
20
+ end
21
+ end
22
+
23
+ # @param model [HashWithIndifferentAccess]
24
+ # @param configs [HashWithIndifferentAccess]
25
+ # @return [HashWithIndifferentAccess]
26
+ def merge_model(model, configs)
27
+ updated_model = model.merge(configs.slice(*RESOURCE_ATTRS))
28
+
29
+ updated_model[:columns] = merge_by_name(
30
+ model[:columns],
31
+ configs[:columns],
32
+ COLUMN_DEFAULTS
33
+ )
34
+
35
+ updated_model[:associations] = merge_by_name(
36
+ model[:associations],
37
+ configs[:associations]
38
+ )
39
+
40
+ updated_model[:actions] = merge_by_name(
41
+ model[:actions],
42
+ configs[:actions],
43
+ ACTION_DEFAULTS
44
+ )
45
+
46
+ updated_model[:tabs] = merge_by_name(
47
+ model[:tabs],
48
+ configs[:tabs],
49
+ ACTION_DEFAULTS
50
+ )
51
+
52
+ updated_model
53
+ end
54
+
55
+ # @param defaults [Array<HashWithIndifferentAccess>]
56
+ # @param configs [Array<HashWithIndifferentAccess>]
57
+ # @return [Array<HashWithIndifferentAccess>]
58
+ def merge_by_name(defaults, configs, default_attrs = {})
59
+ return defaults if configs.blank?
60
+
61
+ (defaults.pluck(:name) + configs.pluck(:name)).uniq.map do |name|
62
+ config_item = configs.find { |e| e[:name] == name } || {}
63
+ default_item = defaults.find { |e| e[:name] == name } || default_attrs
64
+
65
+ default_item.merge(config_item)
66
+ end
67
+ end
68
+
69
+ # @return [HashWithIndifferentAccess<String, HashWithIndifferentAccess>]
70
+ def load_configs
71
+ Motor::Resource.all.each_with_object(HashWithIndifferentAccess.new) do |resource, acc|
72
+ acc[resource.name] = resource.preferences
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end