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,457 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'zlib'
|
|
6
|
+
require 'rubygems/package'
|
|
7
|
+
|
|
8
|
+
module Imports
|
|
9
|
+
class ProcessorService
|
|
10
|
+
attr_reader :migration_plan, :execution, :uploaded_file
|
|
11
|
+
|
|
12
|
+
def initialize(migration_plan, execution, uploaded_file_path)
|
|
13
|
+
@migration_plan = migration_plan
|
|
14
|
+
@execution = execution
|
|
15
|
+
@uploaded_file_path = uploaded_file_path
|
|
16
|
+
@stats = {
|
|
17
|
+
total_steps: migration_plan.migration_steps.count,
|
|
18
|
+
completed_steps: 0,
|
|
19
|
+
total_records: 0,
|
|
20
|
+
processed_records: 0,
|
|
21
|
+
created: 0,
|
|
22
|
+
updated: 0,
|
|
23
|
+
skipped: 0,
|
|
24
|
+
failed: 0,
|
|
25
|
+
total_attachments: 0,
|
|
26
|
+
processed_attachments: 0,
|
|
27
|
+
errors: []
|
|
28
|
+
}
|
|
29
|
+
@id_mapping = {} # Maps old IDs to new IDs for foreign key updates
|
|
30
|
+
@temp_dir = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
execution.update!(status: :running, started_at: Time.current)
|
|
35
|
+
|
|
36
|
+
Dir.mktmpdir do |temp_dir|
|
|
37
|
+
extract_archive(temp_dir)
|
|
38
|
+
import_all_steps(temp_dir)
|
|
39
|
+
finalize_success
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
finalize_failure(e)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def extract_archive(temp_dir)
|
|
48
|
+
Gem::Package::TarReader.new(Zlib::GzipReader.open(@uploaded_file_path)) do |tar|
|
|
49
|
+
tar.each do |entry|
|
|
50
|
+
next unless entry.file?
|
|
51
|
+
|
|
52
|
+
file_path = File.join(temp_dir, entry.full_name)
|
|
53
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
54
|
+
|
|
55
|
+
File.open(file_path, 'wb') do |f|
|
|
56
|
+
f.write(entry.read)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def import_all_steps(temp_dir)
|
|
63
|
+
@temp_dir = temp_dir
|
|
64
|
+
migration_plan.migration_steps.order(:sequence).each do |step|
|
|
65
|
+
import_step(step, temp_dir)
|
|
66
|
+
@stats[:completed_steps] += 1
|
|
67
|
+
update_progress
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def import_step(step, temp_dir)
|
|
72
|
+
csv_path = File.join(temp_dir, "#{step.source_model_name}_export.csv")
|
|
73
|
+
return unless File.exist?(csv_path)
|
|
74
|
+
|
|
75
|
+
model_class = step.source_model_name.constantize
|
|
76
|
+
rows = CSV.read(csv_path, headers: true)
|
|
77
|
+
|
|
78
|
+
@stats[:total_records] += rows.count
|
|
79
|
+
update_progress
|
|
80
|
+
|
|
81
|
+
rows.each do |row|
|
|
82
|
+
process_row(row, step, model_class)
|
|
83
|
+
@stats[:processed_records] += 1
|
|
84
|
+
update_progress if (@stats[:processed_records] % 50).zero?
|
|
85
|
+
end
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
@stats[:errors] << { step: step.source_model_name, error: e.message }
|
|
88
|
+
raise
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def process_row(row, step, model_class)
|
|
92
|
+
# Build attributes hash from CSV row
|
|
93
|
+
attributes = build_attributes(row, step, model_class)
|
|
94
|
+
|
|
95
|
+
# Update foreign key associations (pass CSV row for association attribute values)
|
|
96
|
+
update_foreign_keys(attributes, step, row)
|
|
97
|
+
|
|
98
|
+
# Find or create record
|
|
99
|
+
existing_record = find_existing_record(attributes, step, model_class)
|
|
100
|
+
|
|
101
|
+
if existing_record
|
|
102
|
+
handle_existing_record(existing_record, attributes, step, row)
|
|
103
|
+
else
|
|
104
|
+
handle_new_record(attributes, step, model_class, row)
|
|
105
|
+
end
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
@stats[:failed] += 1
|
|
108
|
+
record_migration_action(step, row, :failed, {}, e.message)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_attributes(row, _step, model_class)
|
|
112
|
+
attributes = {}
|
|
113
|
+
|
|
114
|
+
model_class.column_names.each do |column|
|
|
115
|
+
attributes[column] = row[column] if row.key?(column)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
attributes
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def update_foreign_keys(attributes, step, csv_row)
|
|
122
|
+
return unless step.association_overrides.present?
|
|
123
|
+
|
|
124
|
+
step.association_overrides.each do |fk_column, mapping_info|
|
|
125
|
+
next unless attributes[fk_column].present?
|
|
126
|
+
|
|
127
|
+
# Check if this is a polymorphic association
|
|
128
|
+
if mapping_info['polymorphic'] == true
|
|
129
|
+
handle_polymorphic_foreign_key(attributes, fk_column, mapping_info, csv_row)
|
|
130
|
+
else
|
|
131
|
+
handle_regular_foreign_key(attributes, fk_column, mapping_info, csv_row)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_regular_foreign_key(attributes, fk_column, mapping_info, csv_row)
|
|
137
|
+
old_id = attributes[fk_column].to_i
|
|
138
|
+
target_model = mapping_info['model']
|
|
139
|
+
lookup_attributes = mapping_info['lookup_attributes']
|
|
140
|
+
|
|
141
|
+
# Find the new ID based on lookup attributes
|
|
142
|
+
new_id = find_mapped_id(old_id, target_model, lookup_attributes, csv_row)
|
|
143
|
+
attributes[fk_column] = new_id if new_id
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_polymorphic_foreign_key(attributes, fk_column, mapping_info, csv_row)
|
|
147
|
+
type_column = mapping_info['type_column']
|
|
148
|
+
|
|
149
|
+
# Get the polymorphic type from the attributes
|
|
150
|
+
polymorphic_type = attributes[type_column]
|
|
151
|
+
return unless polymorphic_type.present?
|
|
152
|
+
|
|
153
|
+
# Get the lookup attributes for this specific type
|
|
154
|
+
lookup_attributes_by_type = mapping_info['lookup_attributes']
|
|
155
|
+
return unless lookup_attributes_by_type.is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
lookup_attributes = lookup_attributes_by_type[polymorphic_type]
|
|
158
|
+
return unless lookup_attributes.present?
|
|
159
|
+
|
|
160
|
+
# Find the new ID based on the polymorphic type and lookup attributes
|
|
161
|
+
old_id = attributes[fk_column].to_i
|
|
162
|
+
new_id = find_mapped_id(old_id, polymorphic_type, lookup_attributes, csv_row)
|
|
163
|
+
attributes[fk_column] = new_id if new_id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def find_mapped_id(old_id, target_model, lookup_attributes, csv_row)
|
|
167
|
+
# Check if we've already mapped this ID (cache for performance)
|
|
168
|
+
mapping_key = "#{target_model}_#{old_id}"
|
|
169
|
+
return @id_mapping[mapping_key] if @id_mapping.key?(mapping_key)
|
|
170
|
+
|
|
171
|
+
# Build conditions hash from CSV row using lookup attributes
|
|
172
|
+
# For association_overrides, the association name would be something like 'company'
|
|
173
|
+
# and CSV columns would be like 'company.name', 'company.code'
|
|
174
|
+
conditions = {}
|
|
175
|
+
|
|
176
|
+
# Determine association name from the target model
|
|
177
|
+
# e.g., "Company" -> "company", "Project" -> "project"
|
|
178
|
+
association_name = target_model.underscore
|
|
179
|
+
|
|
180
|
+
Array(lookup_attributes).each do |attr|
|
|
181
|
+
# Look for CSV column like "company.name"
|
|
182
|
+
csv_column = "#{association_name}.#{attr}"
|
|
183
|
+
conditions[attr] = csv_row[csv_column] if csv_row.key?(csv_column) && csv_row[csv_column].present?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# If we have conditions, query the database
|
|
187
|
+
if conditions.present?
|
|
188
|
+
begin
|
|
189
|
+
model_class = target_model.constantize
|
|
190
|
+
found_record = model_class.find_by(conditions)
|
|
191
|
+
|
|
192
|
+
if found_record
|
|
193
|
+
# Cache this mapping for future lookups
|
|
194
|
+
@id_mapping[mapping_key] = found_record.id
|
|
195
|
+
return found_record.id
|
|
196
|
+
else
|
|
197
|
+
Rails.logger.warn "Could not find #{target_model} with #{conditions.inspect} for old_id #{old_id}"
|
|
198
|
+
end
|
|
199
|
+
rescue NameError => e
|
|
200
|
+
Rails.logger.error "Model #{target_model} not found: #{e.message}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Fallback: return old_id (might work if IDs are the same in both DBs)
|
|
205
|
+
old_id
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def find_existing_record(attributes, step, model_class)
|
|
209
|
+
return nil if step.column_overrides.blank? || step.column_overrides['uniq_record_id_cols'].blank?
|
|
210
|
+
|
|
211
|
+
unique_columns = Array(step.column_overrides['uniq_record_id_cols'])
|
|
212
|
+
conditions = {}
|
|
213
|
+
|
|
214
|
+
unique_columns.each do |column|
|
|
215
|
+
conditions[column] = attributes[column]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
model_class.find_by(conditions)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def handle_existing_record(record, attributes, step, csv_row)
|
|
222
|
+
# Check if source record is newer
|
|
223
|
+
if should_update?(record, attributes)
|
|
224
|
+
# Remove columns that should be ignored on update
|
|
225
|
+
ignored_columns = step.column_overrides&.dig('ignore_on_update') || []
|
|
226
|
+
update_attrs = attributes.except(*ignored_columns, 'id', 'created_at')
|
|
227
|
+
|
|
228
|
+
changes = record.attributes.slice(*update_attrs.keys).to_h.reject { |k, v| v == update_attrs[k] }
|
|
229
|
+
|
|
230
|
+
if record.update(update_attrs)
|
|
231
|
+
@stats[:updated] += 1
|
|
232
|
+
record_migration_action(step, csv_row, :updated, changes)
|
|
233
|
+
|
|
234
|
+
# Import attachments if any
|
|
235
|
+
import_attachments(record, step, csv_row)
|
|
236
|
+
else
|
|
237
|
+
@stats[:failed] += 1
|
|
238
|
+
record_migration_action(step, csv_row, :failed, {}, record.errors.full_messages.join(', '))
|
|
239
|
+
end
|
|
240
|
+
else
|
|
241
|
+
@stats[:skipped] += 1
|
|
242
|
+
record_migration_action(step, csv_row, :skipped, {})
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Store ID mapping
|
|
246
|
+
return unless csv_row['id'].present?
|
|
247
|
+
|
|
248
|
+
mapping_key = "#{step.source_model_name}_#{csv_row['id']}"
|
|
249
|
+
@id_mapping[mapping_key] = record.id
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def handle_new_record(attributes, step, model_class, csv_row)
|
|
253
|
+
# Remove id from attributes to let database assign new one
|
|
254
|
+
old_id = attributes.delete('id')
|
|
255
|
+
attributes.delete('created_at')
|
|
256
|
+
attributes.delete('updated_at')
|
|
257
|
+
|
|
258
|
+
record = model_class.new(attributes)
|
|
259
|
+
|
|
260
|
+
if record.save
|
|
261
|
+
@stats[:created] += 1
|
|
262
|
+
record_migration_action(step, csv_row, :created, attributes)
|
|
263
|
+
|
|
264
|
+
# Store ID mapping
|
|
265
|
+
if old_id.present?
|
|
266
|
+
mapping_key = "#{step.source_model_name}_#{old_id}"
|
|
267
|
+
@id_mapping[mapping_key] = record.id
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Import attachments if any
|
|
271
|
+
import_attachments(record, step, csv_row)
|
|
272
|
+
else
|
|
273
|
+
@stats[:failed] += 1
|
|
274
|
+
record_migration_action(step, csv_row, :failed, {}, record.errors.full_messages.join(', '))
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def should_update?(record, attributes)
|
|
279
|
+
# If no updated_at in import data, always update
|
|
280
|
+
return true unless attributes['updated_at'].present?
|
|
281
|
+
|
|
282
|
+
source_updated_at = begin
|
|
283
|
+
Time.zone.parse(attributes['updated_at'].to_s)
|
|
284
|
+
rescue StandardError
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
return true unless source_updated_at
|
|
288
|
+
|
|
289
|
+
# Update if source is newer
|
|
290
|
+
source_updated_at > record.updated_at
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def record_migration_action(step, csv_row, action, changes, error_message = nil)
|
|
294
|
+
record_identifier = csv_row['id'].present? ? "#{step.source_model_name}##{csv_row['id']}" : 'unknown'
|
|
295
|
+
|
|
296
|
+
MigrationRecord.create!(
|
|
297
|
+
migration_execution: execution,
|
|
298
|
+
migrated_model_name: step.source_model_name,
|
|
299
|
+
record_identifier: record_identifier,
|
|
300
|
+
action: action,
|
|
301
|
+
record_changes: changes,
|
|
302
|
+
error_message: error_message
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def update_progress
|
|
307
|
+
execution.update!(stats: @stats)
|
|
308
|
+
broadcast_progress
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def broadcast_progress
|
|
312
|
+
ActionCable.server.broadcast(
|
|
313
|
+
"execution_#{execution.id}",
|
|
314
|
+
{
|
|
315
|
+
type: 'progress',
|
|
316
|
+
stats: @stats,
|
|
317
|
+
percentage: calculate_percentage,
|
|
318
|
+
message: progress_message
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def calculate_percentage
|
|
324
|
+
return 0 if @stats[:total_records].zero?
|
|
325
|
+
|
|
326
|
+
((@stats[:processed_records].to_f / @stats[:total_records]) * 100).round(2)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def progress_message
|
|
330
|
+
"Processing step #{@stats[:completed_steps]}/#{@stats[:total_steps]} - " \
|
|
331
|
+
"#{@stats[:processed_records]}/#{@stats[:total_records]} records imported " \
|
|
332
|
+
"(#{@stats[:created]} created, #{@stats[:updated]} updated, #{@stats[:skipped]} skipped, #{@stats[:failed]} failed)"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def finalize_success
|
|
336
|
+
execution.update!(
|
|
337
|
+
status: :completed,
|
|
338
|
+
completed_at: Time.current,
|
|
339
|
+
stats: @stats
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
broadcast_completion('Import completed successfully')
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def finalize_failure(error)
|
|
346
|
+
@stats[:errors] << { general: error.message }
|
|
347
|
+
|
|
348
|
+
execution.update!(
|
|
349
|
+
status: :failed,
|
|
350
|
+
completed_at: Time.current,
|
|
351
|
+
error_log: error.full_message,
|
|
352
|
+
stats: @stats
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
broadcast_completion("Import failed: #{error.message}")
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def broadcast_completion(message)
|
|
359
|
+
ActionCable.server.broadcast(
|
|
360
|
+
"execution_#{execution.id}",
|
|
361
|
+
{
|
|
362
|
+
type: 'completion',
|
|
363
|
+
status: execution.status,
|
|
364
|
+
message: message,
|
|
365
|
+
stats: @stats
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Import attachments for a record
|
|
371
|
+
def import_attachments(record, step, csv_row)
|
|
372
|
+
return if step.ignore?
|
|
373
|
+
|
|
374
|
+
model_class = record.class
|
|
375
|
+
return unless model_class.respond_to?(:reflect_on_all_attachments)
|
|
376
|
+
|
|
377
|
+
model_class.reflect_on_all_attachments.each do |attachment_reflection|
|
|
378
|
+
attachment_name = attachment_reflection.name
|
|
379
|
+
|
|
380
|
+
if step.url?
|
|
381
|
+
# Import from URL
|
|
382
|
+
url_column = "#{attachment_name}_url"
|
|
383
|
+
url = csv_row[url_column]
|
|
384
|
+
|
|
385
|
+
import_attachment_from_url(record, attachment_name, url) if url.present?
|
|
386
|
+
elsif step.raw_data?
|
|
387
|
+
# Import from raw file
|
|
388
|
+
path_column = "#{attachment_name}_path"
|
|
389
|
+
filename_column = "#{attachment_name}_filename"
|
|
390
|
+
content_type_column = "#{attachment_name}_content_type"
|
|
391
|
+
|
|
392
|
+
file_path = csv_row[path_column]
|
|
393
|
+
filename = csv_row[filename_column]
|
|
394
|
+
content_type = csv_row[content_type_column]
|
|
395
|
+
|
|
396
|
+
if file_path.present? && filename.present?
|
|
397
|
+
import_attachment_from_file(record, attachment_name, file_path, filename, content_type)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
rescue StandardError => e
|
|
402
|
+
Rails.logger.error "Failed to import attachments for record #{record.id}: #{e.message}"
|
|
403
|
+
@stats[:errors] << {
|
|
404
|
+
step: step.source_model_name,
|
|
405
|
+
record_id: record.id,
|
|
406
|
+
error: "Attachment import failed: #{e.message}"
|
|
407
|
+
}
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Import attachment from URL
|
|
411
|
+
def import_attachment_from_url(record, attachment_name, url)
|
|
412
|
+
require 'open-uri'
|
|
413
|
+
|
|
414
|
+
URI.open(url) do |file|
|
|
415
|
+
record.send(attachment_name).attach(
|
|
416
|
+
io: file,
|
|
417
|
+
filename: File.basename(URI.parse(url).path),
|
|
418
|
+
content_type: file.content_type
|
|
419
|
+
)
|
|
420
|
+
@stats[:processed_attachments] += 1
|
|
421
|
+
end
|
|
422
|
+
rescue StandardError => e
|
|
423
|
+
Rails.logger.error "Failed to download attachment from URL #{url}: #{e.message}"
|
|
424
|
+
@stats[:errors] << {
|
|
425
|
+
attachment_name: attachment_name,
|
|
426
|
+
url: url,
|
|
427
|
+
error: e.message
|
|
428
|
+
}
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Import attachment from extracted file
|
|
432
|
+
def import_attachment_from_file(record, attachment_name, file_path, filename, content_type)
|
|
433
|
+
full_path = File.join(@temp_dir, file_path)
|
|
434
|
+
|
|
435
|
+
unless File.exist?(full_path)
|
|
436
|
+
Rails.logger.warn "Attachment file not found: #{full_path}"
|
|
437
|
+
return
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
File.open(full_path, 'rb') do |file|
|
|
441
|
+
record.send(attachment_name).attach(
|
|
442
|
+
io: file,
|
|
443
|
+
filename: filename,
|
|
444
|
+
content_type: content_type
|
|
445
|
+
)
|
|
446
|
+
@stats[:processed_attachments] += 1
|
|
447
|
+
end
|
|
448
|
+
rescue StandardError => e
|
|
449
|
+
Rails.logger.error "Failed to attach file #{full_path}: #{e.message}"
|
|
450
|
+
@stats[:errors] << {
|
|
451
|
+
attachment_name: attachment_name,
|
|
452
|
+
file_path: file_path,
|
|
453
|
+
error: e.message
|
|
454
|
+
}
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MigrationPlans
|
|
4
|
+
class ExportConfigService
|
|
5
|
+
attr_reader :migration_plan
|
|
6
|
+
|
|
7
|
+
def initialize(migration_plan)
|
|
8
|
+
@migration_plan = migration_plan
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
{
|
|
13
|
+
version: '1.0',
|
|
14
|
+
exported_at: Time.current.iso8601,
|
|
15
|
+
plan: {
|
|
16
|
+
name: migration_plan.name,
|
|
17
|
+
description: migration_plan.description,
|
|
18
|
+
steps: export_steps
|
|
19
|
+
}
|
|
20
|
+
}.to_json
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def export_steps
|
|
26
|
+
migration_plan.migration_steps.order(:sequence).map do |step|
|
|
27
|
+
{
|
|
28
|
+
source_model_name: step.source_model_name,
|
|
29
|
+
sequence: step.sequence,
|
|
30
|
+
filter_query: step.filter_query,
|
|
31
|
+
column_overrides: step.column_overrides,
|
|
32
|
+
association_overrides: step.association_overrides,
|
|
33
|
+
attachment_export_mode: step.attachment_export_mode,
|
|
34
|
+
attachment_fields: step.attachment_fields,
|
|
35
|
+
dependee_sequence: step.dependee&.sequence, # Reference by sequence, not ID
|
|
36
|
+
dependee_attribute_mapping: step.dependee_attribute_mapping
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MigrationPlans
|
|
4
|
+
class ImportConfigService
|
|
5
|
+
attr_reader :config_json, :current_user, :errors
|
|
6
|
+
|
|
7
|
+
def initialize(config_json, current_user)
|
|
8
|
+
@config_json = config_json
|
|
9
|
+
@current_user = current_user
|
|
10
|
+
@errors = []
|
|
11
|
+
@step_mapping = {} # Maps sequence to step objects for dependency resolution
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
config = parse_config
|
|
16
|
+
return nil unless config
|
|
17
|
+
|
|
18
|
+
validate_config(config)
|
|
19
|
+
return nil if @errors.any?
|
|
20
|
+
|
|
21
|
+
create_plan_with_steps(config)
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
@errors << "Import failed: #{e.message}"
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def success?
|
|
28
|
+
@errors.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def parse_config
|
|
34
|
+
JSON.parse(@config_json)
|
|
35
|
+
rescue JSON::ParserError => e
|
|
36
|
+
@errors << "Invalid JSON format: #{e.message}"
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_config(config)
|
|
41
|
+
unless config['plan']
|
|
42
|
+
@errors << "Missing 'plan' section in configuration"
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
plan_data = config['plan']
|
|
47
|
+
|
|
48
|
+
@errors << 'Plan name is required' unless plan_data['name'].present?
|
|
49
|
+
|
|
50
|
+
unless plan_data['steps'].is_a?(Array)
|
|
51
|
+
@errors << 'Steps must be an array'
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validate each step
|
|
56
|
+
plan_data['steps'].each_with_index do |step_data, index|
|
|
57
|
+
validate_step(step_data, index)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_step(step_data, index)
|
|
62
|
+
unless step_data['source_model_name'].present?
|
|
63
|
+
@errors << "Step #{index + 1}: source_model_name is required"
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if model exists in this environment
|
|
68
|
+
begin
|
|
69
|
+
step_data['source_model_name'].constantize
|
|
70
|
+
rescue NameError
|
|
71
|
+
@errors << "Step #{index + 1}: Model '#{step_data['source_model_name']}' not found in this environment"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@errors << "Step #{index + 1}: sequence is required" unless step_data['sequence'].present?
|
|
75
|
+
|
|
76
|
+
# Validate attachment_export_mode if present
|
|
77
|
+
return unless step_data['attachment_export_mode'].present?
|
|
78
|
+
|
|
79
|
+
valid_modes = %w[ignore url raw_data]
|
|
80
|
+
return if valid_modes.include?(step_data['attachment_export_mode'])
|
|
81
|
+
|
|
82
|
+
@errors << "Step #{index + 1}: invalid attachment_export_mode '#{step_data['attachment_export_mode']}'"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def create_plan_with_steps(config)
|
|
86
|
+
plan_data = config['plan']
|
|
87
|
+
|
|
88
|
+
ActiveRecord::Base.transaction do
|
|
89
|
+
# Find or create the migration plan by name
|
|
90
|
+
plan = MigrationPlan.find_or_create_by!(name: plan_data['name']) do |p|
|
|
91
|
+
p.user = current_user
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Update plan attributes
|
|
95
|
+
plan.update!(
|
|
96
|
+
description: plan_data['description'],
|
|
97
|
+
user: current_user
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Create/update steps in sequence order (important for dependencies)
|
|
101
|
+
sorted_steps = plan_data['steps'].sort_by { |s| s['sequence'] }
|
|
102
|
+
|
|
103
|
+
sorted_steps.each do |step_data|
|
|
104
|
+
create_or_update_step(plan, step_data)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Resolve dependencies after all steps are created/updated
|
|
108
|
+
resolve_dependencies(plan, sorted_steps)
|
|
109
|
+
|
|
110
|
+
plan
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create_or_update_step(plan, step_data)
|
|
115
|
+
# Find or create step by plan and sequence
|
|
116
|
+
step = MigrationStep.find_or_create_by!(
|
|
117
|
+
migration_plan: plan,
|
|
118
|
+
sequence: step_data['sequence']
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Update step attributes
|
|
122
|
+
step.update!(
|
|
123
|
+
source_model_name: step_data['source_model_name'],
|
|
124
|
+
filter_query: step_data['filter_query'] || '',
|
|
125
|
+
column_overrides: step_data['column_overrides'] || {},
|
|
126
|
+
association_overrides: step_data['association_overrides'] || {},
|
|
127
|
+
attachment_export_mode: step_data['attachment_export_mode'] || 'ignore',
|
|
128
|
+
attachment_fields: step_data['attachment_fields'],
|
|
129
|
+
dependee_attribute_mapping: step_data['dependee_attribute_mapping'] || {}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Store in mapping for later dependency resolution
|
|
133
|
+
@step_mapping[step_data['sequence']] = {
|
|
134
|
+
step: step,
|
|
135
|
+
dependee_sequence: step_data['dependee_sequence']
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
step
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def resolve_dependencies(_plan, _sorted_steps)
|
|
142
|
+
@step_mapping.each do |sequence, data|
|
|
143
|
+
step = data[:step]
|
|
144
|
+
dependee_sequence = data[:dependee_sequence]
|
|
145
|
+
|
|
146
|
+
next unless dependee_sequence.present?
|
|
147
|
+
|
|
148
|
+
# Find the dependee step by sequence
|
|
149
|
+
dependee_data = @step_mapping[dependee_sequence]
|
|
150
|
+
if dependee_data
|
|
151
|
+
step.update!(dependee: dependee_data[:step])
|
|
152
|
+
else
|
|
153
|
+
Rails.logger.warn "Could not resolve dependency for step #{sequence}: dependee sequence #{dependee_sequence} not found"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<div class="row justify-content-center">
|
|
2
|
+
<div class="col-md-6">
|
|
3
|
+
<div class="card shadow">
|
|
4
|
+
<div class="card-body p-4">
|
|
5
|
+
<h2 class="card-title mb-4">Change Password</h2>
|
|
6
|
+
|
|
7
|
+
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
|
|
8
|
+
<%= render "data_migration/devise/shared/error_messages", resource: resource %>
|
|
9
|
+
|
|
10
|
+
<div class="mb-3">
|
|
11
|
+
<%= f.label :password, "New password", class: "form-label" %>
|
|
12
|
+
<%= f.password_field :password, autocomplete: "new-password", class: "form-control", placeholder: "Enter new password" %>
|
|
13
|
+
<small class="form-text text-muted">
|
|
14
|
+
(Minimum 6 characters)
|
|
15
|
+
</small>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="mb-3">
|
|
19
|
+
<%= f.label :password_confirmation, "Confirm new password", class: "form-label" %>
|
|
20
|
+
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: "form-control", placeholder: "Confirm new password" %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<hr class="my-4">
|
|
24
|
+
|
|
25
|
+
<div class="mb-4">
|
|
26
|
+
<%= f.label :current_password, class: "form-label" %>
|
|
27
|
+
<%= f.password_field :current_password, autocomplete: "current-password", class: "form-control", placeholder: "Enter current password to confirm changes" %>
|
|
28
|
+
<small class="form-text text-muted">
|
|
29
|
+
We need your current password to confirm changes
|
|
30
|
+
</small>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="d-flex gap-2">
|
|
34
|
+
<%= f.submit "Update Password", class: "btn btn-primary" %>
|
|
35
|
+
<%= link_to "Back", :back, class: "btn btn-outline-secondary" %>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|