data_migration_for_rails 0.1.1
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/LICENSE +17 -0
- data/README.md +196 -0
- data/Rakefile +8 -0
- data/app/assets/config/manifest.js +2 -0
- data/app/assets/stylesheets/application.css +15 -0
- data/app/channels/application_cable/channel.rb +6 -0
- data/app/channels/application_cable/connection.rb +6 -0
- data/app/controllers/concerns/data_migration/pundit_authorization.rb +12 -0
- data/app/controllers/data_migration/application_controller.rb +63 -0
- data/app/controllers/data_migration/exports_controller.rb +68 -0
- data/app/controllers/data_migration/imports_controller.rb +78 -0
- data/app/controllers/data_migration/migration_executions_controller.rb +75 -0
- data/app/controllers/data_migration/migration_plans_controller.rb +103 -0
- data/app/controllers/data_migration/migration_steps_controller.rb +164 -0
- data/app/controllers/data_migration/users_controller.rb +71 -0
- data/app/controllers/users/sessions_controller.rb +30 -0
- data/app/helpers/data_migration/application_helper.rb +24 -0
- data/app/jobs/application_job.rb +9 -0
- data/app/jobs/export_job.rb +27 -0
- data/app/jobs/import_job.rb +28 -0
- data/app/mailers/application_mailer.rb +6 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/data_migration_user.rb +43 -0
- data/app/models/migration_execution.rb +93 -0
- data/app/models/migration_plan.rb +23 -0
- data/app/models/migration_record.rb +60 -0
- data/app/models/migration_step.rb +150 -0
- data/app/policies/application_policy.rb +53 -0
- data/app/policies/data_migration/user_policy.rb +27 -0
- data/app/policies/data_migration_user_policy.rb +37 -0
- data/app/policies/migration_execution_policy.rb +33 -0
- data/app/policies/migration_plan_policy.rb +41 -0
- data/app/policies/migration_step_policy.rb +29 -0
- data/app/services/data_migration/model_registry.rb +95 -0
- data/app/services/exports/generator_service.rb +444 -0
- data/app/services/imports/processor_service.rb +457 -0
- data/app/services/migration_plans/export_config_service.rb +41 -0
- data/app/services/migration_plans/import_config_service.rb +158 -0
- data/app/views/data_migration/devise/registrations/edit.html.erb +41 -0
- data/app/views/data_migration/devise/sessions/new.html.erb +35 -0
- data/app/views/data_migration/devise/shared/_error_messages.html.erb +13 -0
- data/app/views/data_migration/devise/shared/_links.html.erb +21 -0
- data/app/views/data_migration/exports/new.html.erb +85 -0
- data/app/views/data_migration/imports/new.html.erb +70 -0
- data/app/views/data_migration/migration_executions/index.html.erb +78 -0
- data/app/views/data_migration/migration_executions/show.html.erb +338 -0
- data/app/views/data_migration/migration_plans/_form.html.erb +28 -0
- data/app/views/data_migration/migration_plans/edit.html.erb +12 -0
- data/app/views/data_migration/migration_plans/index.html.erb +118 -0
- data/app/views/data_migration/migration_plans/new.html.erb +9 -0
- data/app/views/data_migration/migration_plans/show.html.erb +105 -0
- data/app/views/data_migration/migration_steps/_form.html.erb +473 -0
- data/app/views/data_migration/migration_steps/edit.html.erb +12 -0
- data/app/views/data_migration/migration_steps/new.html.erb +9 -0
- data/app/views/data_migration/users/_form.html.erb +49 -0
- data/app/views/data_migration/users/edit.html.erb +2 -0
- data/app/views/data_migration/users/index.html.erb +41 -0
- data/app/views/data_migration/users/new.html.erb +2 -0
- data/app/views/data_migration/users/show.html.erb +133 -0
- data/app/views/layouts/_navbar.html.erb +38 -0
- data/app/views/layouts/data_migration.html.erb +37 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/users/registrations/edit.html.erb +41 -0
- data/app/views/users/sessions/new.html.erb +35 -0
- data/app/views/users/shared/_error_messages.html.erb +13 -0
- data/app/views/users/shared/_links.html.erb +21 -0
- data/config/initializers/assets.rb +14 -0
- data/config/initializers/content_security_policy.rb +27 -0
- data/config/initializers/devise.rb +313 -0
- data/config/initializers/filter_parameter_logging.rb +10 -0
- data/config/initializers/inflections.rb +18 -0
- data/config/initializers/permissions_policy.rb +15 -0
- data/config/initializers/warden.rb +14 -0
- data/config/locales/devise.en.yml +65 -0
- data/config/locales/en.yml +31 -0
- data/config/routes.rb +62 -0
- data/db/migrate/20251102121659_create_migration_plans.rb +13 -0
- data/db/migrate/20251102122012_create_migration_steps.rb +24 -0
- data/db/migrate/20251105215702_create_migration_executions.rb +23 -0
- data/db/migrate/20251105215853_create_migration_records.rb +16 -0
- data/db/migrate/20251115154000_remove_unused_attributes.rb +17 -0
- data/db/migrate/20251116120000_add_filter_params_to_migration_executions.rb +7 -0
- data/db/migrate/20251118140000_create_data_migration_users.rb +27 -0
- data/db/migrate/20251118200641_add_user_foreign_keys.rb +15 -0
- data/db/migrate/20251124140000_add_attachment_export_mode_to_migration_steps.rb +9 -0
- data/db/schema.rb +102 -0
- data/db/seeds.rb +19 -0
- data/lib/data_migration/engine.rb +28 -0
- data/lib/data_migration/version.rb +5 -0
- data/lib/data_migration.rb +8 -0
- data/lib/tasks/data_migration_tasks.rake +40 -0
- metadata +279 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataMigration
|
|
4
|
+
class MigrationStepsController < ApplicationController
|
|
5
|
+
include DataMigration::PunditAuthorization
|
|
6
|
+
|
|
7
|
+
before_action :set_migration_plan
|
|
8
|
+
before_action :set_migration_step, only: %i[show edit update destroy]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@migration_steps = policy_scope(MigrationStep).where(migration_plan_id: params[:migration_plan_id]).order(:sequence)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
authorize @migration_step
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def new
|
|
19
|
+
@migration_step = @migration_plan.migration_steps.new
|
|
20
|
+
authorize @migration_step
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create
|
|
24
|
+
@migration_step = @migration_plan.migration_steps.new(migration_step_params)
|
|
25
|
+
authorize @migration_step
|
|
26
|
+
|
|
27
|
+
# Validate JSON fields before saving
|
|
28
|
+
validation_errors = validate_json_fields(@migration_step)
|
|
29
|
+
if validation_errors.any?
|
|
30
|
+
validation_errors.each { |error| @migration_step.errors.add(:base, error) }
|
|
31
|
+
render :new, status: :unprocessable_entity
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if @migration_step.save
|
|
36
|
+
redirect_to "/data_migration/migration_plans/#{@migration_plan.id}",
|
|
37
|
+
notice: 'Migration step was successfully created.'
|
|
38
|
+
else
|
|
39
|
+
render :new, status: :unprocessable_entity
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def edit
|
|
44
|
+
authorize @migration_step
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def update
|
|
48
|
+
authorize @migration_step
|
|
49
|
+
|
|
50
|
+
# Assign attributes first
|
|
51
|
+
@migration_step.assign_attributes(migration_step_params)
|
|
52
|
+
|
|
53
|
+
# Validate JSON fields before saving
|
|
54
|
+
validation_errors = validate_json_fields(@migration_step)
|
|
55
|
+
if validation_errors.any?
|
|
56
|
+
validation_errors.each { |error| @migration_step.errors.add(:base, error) }
|
|
57
|
+
render :edit, status: :unprocessable_entity
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if @migration_step.save
|
|
62
|
+
redirect_to "/data_migration/migration_plans/#{@migration_plan.id}",
|
|
63
|
+
notice: 'Migration step was successfully updated.'
|
|
64
|
+
else
|
|
65
|
+
render :edit, status: :unprocessable_entity
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def destroy
|
|
70
|
+
authorize @migration_step
|
|
71
|
+
|
|
72
|
+
@migration_step.destroy
|
|
73
|
+
redirect_to "/data_migration/migration_plans/#{@migration_plan.id}",
|
|
74
|
+
notice: 'Migration step was successfully destroyed.'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def set_migration_plan
|
|
80
|
+
@migration_plan = MigrationPlan.find(params[:migration_plan_id])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def set_migration_step
|
|
84
|
+
@migration_step = @migration_plan.migration_steps.find(params[:id])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def migration_step_params
|
|
88
|
+
params.require(:migration_step).permit(
|
|
89
|
+
:source_model_name, :sequence, :filter_query, :dependee_id, :migration_plan_id,
|
|
90
|
+
:dependee_attribute_mapping, :column_overrides, :association_overrides,
|
|
91
|
+
:attachment_export_mode, :attachment_fields
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_json_fields(step)
|
|
96
|
+
errors = []
|
|
97
|
+
|
|
98
|
+
# Validate column_overrides
|
|
99
|
+
if step.column_overrides.present?
|
|
100
|
+
begin
|
|
101
|
+
parsed = step.column_overrides.is_a?(String) ? JSON.parse(step.column_overrides) : step.column_overrides
|
|
102
|
+
errors << 'Column Overrides must be a JSON object/hash' unless parsed.is_a?(Hash)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
errors << 'Column Overrides contains invalid JSON'
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Validate association_overrides
|
|
109
|
+
if step.association_overrides.present?
|
|
110
|
+
begin
|
|
111
|
+
parsed = step.association_overrides.is_a?(String) ? JSON.parse(step.association_overrides) : step.association_overrides
|
|
112
|
+
if parsed.is_a?(Hash)
|
|
113
|
+
# Validate structure: each value should be a hash with proper keys
|
|
114
|
+
parsed.each do |key, value|
|
|
115
|
+
unless value.is_a?(Hash)
|
|
116
|
+
errors << "Association Overrides: '#{key}' must be a hash"
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check for polymorphic associations
|
|
121
|
+
if value['polymorphic'] == true
|
|
122
|
+
# Polymorphic requires type_column and lookup_attributes hash
|
|
123
|
+
if value['type_column'].blank?
|
|
124
|
+
errors << "Association Overrides: '#{key}' (polymorphic) must have a 'type_column' key"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if value['lookup_attributes'].blank?
|
|
128
|
+
errors << "Association Overrides: '#{key}' (polymorphic) must have 'lookup_attributes' key"
|
|
129
|
+
elsif !value['lookup_attributes'].is_a?(Hash)
|
|
130
|
+
errors << "Association Overrides: '#{key}' (polymorphic) 'lookup_attributes' must be a hash with model types as keys"
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
# Non-polymorphic requires model and lookup_attributes
|
|
134
|
+
errors << "Association Overrides: '#{key}' must have a 'model' key" if value['model'].blank?
|
|
135
|
+
|
|
136
|
+
if value['lookup_attributes'].blank?
|
|
137
|
+
errors << "Association Overrides: '#{key}' must have 'lookup_attributes' key"
|
|
138
|
+
elsif !value['lookup_attributes'].is_a?(Array)
|
|
139
|
+
errors << "Association Overrides: '#{key}' 'lookup_attributes' must be an array"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
errors << 'Association Overrides must be a JSON object/hash'
|
|
145
|
+
end
|
|
146
|
+
rescue JSON::ParserError
|
|
147
|
+
errors << 'Association Overrides contains invalid JSON'
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Validate dependee_attribute_mapping
|
|
152
|
+
if step.dependee_attribute_mapping.present?
|
|
153
|
+
begin
|
|
154
|
+
parsed = step.dependee_attribute_mapping.is_a?(String) ? JSON.parse(step.dependee_attribute_mapping) : step.dependee_attribute_mapping
|
|
155
|
+
errors << 'Dependee Attribute Mapping must be a JSON object/hash' unless parsed.is_a?(Hash)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
errors << 'Dependee Attribute Mapping contains invalid JSON'
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
errors
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataMigration
|
|
4
|
+
class UsersController < ApplicationController
|
|
5
|
+
include DataMigration::PunditAuthorization
|
|
6
|
+
|
|
7
|
+
before_action :set_user, only: %i[edit update destroy]
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
authorize DataMigrationUser
|
|
11
|
+
@users = policy_scope(DataMigrationUser).ordered_by_email
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
authorize DataMigrationUser
|
|
16
|
+
@user = DataMigrationUser.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create
|
|
20
|
+
authorize DataMigrationUser
|
|
21
|
+
@user = DataMigrationUser.new(user_params)
|
|
22
|
+
|
|
23
|
+
if @user.save
|
|
24
|
+
redirect_to users_path, notice: 'User created successfully.'
|
|
25
|
+
else
|
|
26
|
+
render :new, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def edit
|
|
31
|
+
authorize @user
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def update
|
|
35
|
+
authorize @user
|
|
36
|
+
|
|
37
|
+
# Remove password if blank
|
|
38
|
+
params_to_update = if user_params[:password].blank?
|
|
39
|
+
user_params.except(:password, :password_confirmation)
|
|
40
|
+
else
|
|
41
|
+
user_params
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if @user.update(params_to_update)
|
|
45
|
+
redirect_to users_path, notice: 'User updated successfully.'
|
|
46
|
+
else
|
|
47
|
+
render :edit, status: :unprocessable_entity
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def destroy
|
|
52
|
+
authorize @user
|
|
53
|
+
|
|
54
|
+
if @user.destroy
|
|
55
|
+
redirect_to users_path, notice: 'User deleted successfully.'
|
|
56
|
+
else
|
|
57
|
+
redirect_to users_path, alert: @user.errors.full_messages.join(', ')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def set_user
|
|
64
|
+
@user = DataMigrationUser.find(params[:id])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def user_params
|
|
68
|
+
params.require(:user).permit(:name, :email, :password, :password_confirmation, :role)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Users
|
|
4
|
+
class SessionsController < Devise::SessionsController
|
|
5
|
+
# Skip CSRF verification for the new action to handle Devise FailureApp redirects
|
|
6
|
+
skip_before_action :verify_authenticity_token, only: [:new]
|
|
7
|
+
|
|
8
|
+
# GET /resource/sign_in
|
|
9
|
+
# def new
|
|
10
|
+
# super
|
|
11
|
+
# end
|
|
12
|
+
|
|
13
|
+
# POST /resource/sign_in
|
|
14
|
+
# def create
|
|
15
|
+
# super
|
|
16
|
+
# end
|
|
17
|
+
|
|
18
|
+
# DELETE /resource/sign_out
|
|
19
|
+
# def destroy
|
|
20
|
+
# super
|
|
21
|
+
# end
|
|
22
|
+
|
|
23
|
+
# protected
|
|
24
|
+
|
|
25
|
+
# If you have extra params to permit, append them to the sanitizer.
|
|
26
|
+
# def configure_sign_in_params
|
|
27
|
+
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
|
|
28
|
+
# end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataMigration
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
def execution_status_color(status)
|
|
6
|
+
case status.to_sym
|
|
7
|
+
when :pending
|
|
8
|
+
'secondary'
|
|
9
|
+
when :running
|
|
10
|
+
'primary'
|
|
11
|
+
when :completed
|
|
12
|
+
'success'
|
|
13
|
+
when :failed
|
|
14
|
+
'danger'
|
|
15
|
+
else
|
|
16
|
+
'secondary'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def execution_type_icon(type)
|
|
21
|
+
type.to_sym == :export ? '📤' : '📥'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ApplicationJob < ActiveJob::Base
|
|
4
|
+
# Automatically retry jobs that encountered a deadlock
|
|
5
|
+
# retry_on ActiveRecord::Deadlocked
|
|
6
|
+
|
|
7
|
+
# Most jobs are safe to ignore if the underlying records are no longer available
|
|
8
|
+
# discard_on ActiveJob::DeserializationError
|
|
9
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ExportJob < ApplicationJob
|
|
4
|
+
queue_as :default
|
|
5
|
+
|
|
6
|
+
def perform(execution_id)
|
|
7
|
+
execution = MigrationExecution.find(execution_id)
|
|
8
|
+
migration_plan = execution.migration_plan
|
|
9
|
+
|
|
10
|
+
Exports::GeneratorService.new(migration_plan, execution).call
|
|
11
|
+
rescue StandardError => e
|
|
12
|
+
Rails.logger.error("ExportJob failed for execution #{execution_id}: #{e.message}")
|
|
13
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
14
|
+
|
|
15
|
+
# Only update if the service didn't already mark it as failed
|
|
16
|
+
if execution.reload.status != 'failed'
|
|
17
|
+
execution.update!(
|
|
18
|
+
status: :failed,
|
|
19
|
+
completed_at: Time.current,
|
|
20
|
+
error_log: e.full_message
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Don't re-raise - allow the job to complete so Sidekiq doesn't retry
|
|
25
|
+
# The error is already logged and saved to the execution record
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ImportJob < ApplicationJob
|
|
4
|
+
queue_as :default
|
|
5
|
+
|
|
6
|
+
def perform(execution_id)
|
|
7
|
+
execution = MigrationExecution.find(execution_id)
|
|
8
|
+
migration_plan = execution.migration_plan
|
|
9
|
+
uploaded_file_path = execution.file_path
|
|
10
|
+
|
|
11
|
+
Imports::ProcessorService.new(migration_plan, execution, uploaded_file_path).call
|
|
12
|
+
rescue StandardError => e
|
|
13
|
+
Rails.logger.error("ImportJob failed for execution #{execution_id}: #{e.message}")
|
|
14
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
15
|
+
|
|
16
|
+
# Only update if the service didn't already mark it as failed
|
|
17
|
+
if execution.reload.status != 'failed'
|
|
18
|
+
execution.update!(
|
|
19
|
+
status: :failed,
|
|
20
|
+
completed_at: Time.current,
|
|
21
|
+
error_log: e.full_message
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Don't re-raise - allow the job to complete so Sidekiq doesn't retry
|
|
26
|
+
# The error is already logged and saved to the execution record
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DataMigrationUser < ApplicationRecord
|
|
4
|
+
self.table_name = 'data_migration_users'
|
|
5
|
+
|
|
6
|
+
# Devise modules
|
|
7
|
+
devise :database_authenticatable, :registerable,
|
|
8
|
+
:recoverable, :rememberable, :validatable
|
|
9
|
+
|
|
10
|
+
# Associations
|
|
11
|
+
has_many :migration_plans, dependent: :restrict_with_error
|
|
12
|
+
has_many :migration_executions, dependent: :restrict_with_error
|
|
13
|
+
|
|
14
|
+
# Validations
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
|
|
17
|
+
# Role enum
|
|
18
|
+
enum role: { viewer: 0, operator: 1, admin: 2 }
|
|
19
|
+
|
|
20
|
+
# Callbacks
|
|
21
|
+
after_initialize :set_default_role, if: :new_record?
|
|
22
|
+
|
|
23
|
+
# Scopes
|
|
24
|
+
scope :ordered_by_email, -> { order(:email) }
|
|
25
|
+
scope :admins, -> { where(role: :admin) }
|
|
26
|
+
scope :operators, -> { where(role: :operator) }
|
|
27
|
+
scope :viewers, -> { where(role: :viewer) }
|
|
28
|
+
|
|
29
|
+
# Permission methods
|
|
30
|
+
def can_execute_migrations?
|
|
31
|
+
operator? || admin?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def can_manage_users?
|
|
35
|
+
admin?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def set_default_role
|
|
41
|
+
self.role ||= :viewer
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class MigrationExecution < ApplicationRecord
|
|
4
|
+
# Associations
|
|
5
|
+
belongs_to :migration_plan
|
|
6
|
+
belongs_to :user, class_name: 'DataMigrationUser'
|
|
7
|
+
has_many :migration_records, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
# Enums
|
|
10
|
+
enum execution_type: { export: 0, import: 1 }
|
|
11
|
+
enum status: { pending: 0, running: 1, completed: 2, failed: 3 }
|
|
12
|
+
|
|
13
|
+
# Validations
|
|
14
|
+
validates :execution_type, presence: true
|
|
15
|
+
validates :status, presence: true
|
|
16
|
+
|
|
17
|
+
# Callbacks to set defaults
|
|
18
|
+
after_initialize :set_defaults, if: :new_record?
|
|
19
|
+
|
|
20
|
+
# Scopes
|
|
21
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
22
|
+
scope :by_user, ->(user) { where(user: user) }
|
|
23
|
+
scope :by_plan, ->(plan) { where(migration_plan: plan) }
|
|
24
|
+
|
|
25
|
+
# Instance methods
|
|
26
|
+
def duration
|
|
27
|
+
return nil unless started_at && completed_at
|
|
28
|
+
|
|
29
|
+
completed_at - started_at
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def progress_percentage
|
|
33
|
+
return 0 if stats.blank? || stats['total'].to_i.zero?
|
|
34
|
+
|
|
35
|
+
((stats['processed'].to_f / stats['total']) * 100).round(2)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def display_name
|
|
39
|
+
"#{execution_type.titleize} - #{migration_plan.name}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Custom getter for filter_params
|
|
43
|
+
def filter_params
|
|
44
|
+
value = read_attribute(:filter_params)
|
|
45
|
+
return {} if value.blank?
|
|
46
|
+
return value if value.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
JSON.parse(value)
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
Rails.logger.error "Failed to parse filter_params for MigrationExecution #{id}: #{e.message}"
|
|
51
|
+
{}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Custom setter for filter_params
|
|
55
|
+
def filter_params=(value)
|
|
56
|
+
if value.is_a?(String)
|
|
57
|
+
write_attribute(:filter_params, value)
|
|
58
|
+
elsif value.is_a?(Hash)
|
|
59
|
+
write_attribute(:filter_params, value.to_json)
|
|
60
|
+
else
|
|
61
|
+
write_attribute(:filter_params, {}.to_json)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Custom getter for stats
|
|
66
|
+
def stats
|
|
67
|
+
value = read_attribute(:stats)
|
|
68
|
+
return {} if value.blank?
|
|
69
|
+
return value if value.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
JSON.parse(value)
|
|
72
|
+
rescue JSON::ParserError => e
|
|
73
|
+
Rails.logger.error "Failed to parse stats for MigrationExecution #{id}: #{e.message}"
|
|
74
|
+
{}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Custom setter for stats
|
|
78
|
+
def stats=(value)
|
|
79
|
+
if value.is_a?(String)
|
|
80
|
+
write_attribute(:stats, value)
|
|
81
|
+
elsif value.is_a?(Hash)
|
|
82
|
+
write_attribute(:stats, value.to_json)
|
|
83
|
+
else
|
|
84
|
+
write_attribute(:stats, {}.to_json)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def set_defaults
|
|
91
|
+
# Defaults are handled by getters
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class MigrationPlan < ApplicationRecord
|
|
4
|
+
# Associations
|
|
5
|
+
belongs_to :user, class_name: 'DataMigrationUser'
|
|
6
|
+
has_many :migration_steps, dependent: :destroy
|
|
7
|
+
has_many :migration_executions, dependent: :restrict_with_error
|
|
8
|
+
|
|
9
|
+
# Validations
|
|
10
|
+
validates :name, presence: true, uniqueness: true
|
|
11
|
+
|
|
12
|
+
# Scopes
|
|
13
|
+
scope :ordered_by_name, -> { order(:name) }
|
|
14
|
+
|
|
15
|
+
# Instance methods
|
|
16
|
+
def can_be_deleted?
|
|
17
|
+
migration_executions.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def last_execution
|
|
21
|
+
migration_executions.recent.first
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class MigrationRecord < ApplicationRecord
|
|
4
|
+
# Associations
|
|
5
|
+
belongs_to :migration_execution
|
|
6
|
+
|
|
7
|
+
# Enums
|
|
8
|
+
enum action: { created: 0, updated: 1, skipped: 2, failed: 3 }
|
|
9
|
+
|
|
10
|
+
# Validations
|
|
11
|
+
validates :migrated_model_name, presence: true
|
|
12
|
+
validates :record_identifier, presence: true
|
|
13
|
+
validates :action, presence: true
|
|
14
|
+
|
|
15
|
+
# Callbacks to set defaults
|
|
16
|
+
after_initialize :set_defaults, if: :new_record?
|
|
17
|
+
|
|
18
|
+
# Scopes
|
|
19
|
+
scope :by_action, ->(action) { where(action: action) }
|
|
20
|
+
scope :by_model, ->(model_name) { where(migrated_model_name: model_name) }
|
|
21
|
+
scope :errors_only, -> { where(action: :failed) }
|
|
22
|
+
|
|
23
|
+
# Custom getter for record_changes
|
|
24
|
+
def record_changes
|
|
25
|
+
value = read_attribute(:record_changes)
|
|
26
|
+
return {} if value.blank?
|
|
27
|
+
return value if value.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
JSON.parse(value)
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
Rails.logger.error "Failed to parse record_changes for MigrationRecord #{id}: #{e.message}"
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Custom setter for record_changes
|
|
36
|
+
def record_changes=(value)
|
|
37
|
+
if value.is_a?(String)
|
|
38
|
+
write_attribute(:record_changes, value)
|
|
39
|
+
elsif value.is_a?(Hash)
|
|
40
|
+
write_attribute(:record_changes, value.to_json)
|
|
41
|
+
else
|
|
42
|
+
write_attribute(:record_changes, {}.to_json)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Instance methods
|
|
47
|
+
def display_name
|
|
48
|
+
"#{migrated_model_name} #{record_identifier}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def success?
|
|
52
|
+
!failed?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def set_defaults
|
|
58
|
+
# Defaults are handled by getters
|
|
59
|
+
end
|
|
60
|
+
end
|