motor-admin 0.1.9

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 (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