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.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/README.md +196 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/manifest.js +2 -0
  6. data/app/assets/stylesheets/application.css +15 -0
  7. data/app/channels/application_cable/channel.rb +6 -0
  8. data/app/channels/application_cable/connection.rb +6 -0
  9. data/app/controllers/concerns/data_migration/pundit_authorization.rb +12 -0
  10. data/app/controllers/data_migration/application_controller.rb +63 -0
  11. data/app/controllers/data_migration/exports_controller.rb +68 -0
  12. data/app/controllers/data_migration/imports_controller.rb +78 -0
  13. data/app/controllers/data_migration/migration_executions_controller.rb +75 -0
  14. data/app/controllers/data_migration/migration_plans_controller.rb +103 -0
  15. data/app/controllers/data_migration/migration_steps_controller.rb +164 -0
  16. data/app/controllers/data_migration/users_controller.rb +71 -0
  17. data/app/controllers/users/sessions_controller.rb +30 -0
  18. data/app/helpers/data_migration/application_helper.rb +24 -0
  19. data/app/jobs/application_job.rb +9 -0
  20. data/app/jobs/export_job.rb +27 -0
  21. data/app/jobs/import_job.rb +28 -0
  22. data/app/mailers/application_mailer.rb +6 -0
  23. data/app/models/application_record.rb +5 -0
  24. data/app/models/data_migration_user.rb +43 -0
  25. data/app/models/migration_execution.rb +93 -0
  26. data/app/models/migration_plan.rb +23 -0
  27. data/app/models/migration_record.rb +60 -0
  28. data/app/models/migration_step.rb +150 -0
  29. data/app/policies/application_policy.rb +53 -0
  30. data/app/policies/data_migration/user_policy.rb +27 -0
  31. data/app/policies/data_migration_user_policy.rb +37 -0
  32. data/app/policies/migration_execution_policy.rb +33 -0
  33. data/app/policies/migration_plan_policy.rb +41 -0
  34. data/app/policies/migration_step_policy.rb +29 -0
  35. data/app/services/data_migration/model_registry.rb +95 -0
  36. data/app/services/exports/generator_service.rb +444 -0
  37. data/app/services/imports/processor_service.rb +457 -0
  38. data/app/services/migration_plans/export_config_service.rb +41 -0
  39. data/app/services/migration_plans/import_config_service.rb +158 -0
  40. data/app/views/data_migration/devise/registrations/edit.html.erb +41 -0
  41. data/app/views/data_migration/devise/sessions/new.html.erb +35 -0
  42. data/app/views/data_migration/devise/shared/_error_messages.html.erb +13 -0
  43. data/app/views/data_migration/devise/shared/_links.html.erb +21 -0
  44. data/app/views/data_migration/exports/new.html.erb +85 -0
  45. data/app/views/data_migration/imports/new.html.erb +70 -0
  46. data/app/views/data_migration/migration_executions/index.html.erb +78 -0
  47. data/app/views/data_migration/migration_executions/show.html.erb +338 -0
  48. data/app/views/data_migration/migration_plans/_form.html.erb +28 -0
  49. data/app/views/data_migration/migration_plans/edit.html.erb +12 -0
  50. data/app/views/data_migration/migration_plans/index.html.erb +118 -0
  51. data/app/views/data_migration/migration_plans/new.html.erb +9 -0
  52. data/app/views/data_migration/migration_plans/show.html.erb +105 -0
  53. data/app/views/data_migration/migration_steps/_form.html.erb +473 -0
  54. data/app/views/data_migration/migration_steps/edit.html.erb +12 -0
  55. data/app/views/data_migration/migration_steps/new.html.erb +9 -0
  56. data/app/views/data_migration/users/_form.html.erb +49 -0
  57. data/app/views/data_migration/users/edit.html.erb +2 -0
  58. data/app/views/data_migration/users/index.html.erb +41 -0
  59. data/app/views/data_migration/users/new.html.erb +2 -0
  60. data/app/views/data_migration/users/show.html.erb +133 -0
  61. data/app/views/layouts/_navbar.html.erb +38 -0
  62. data/app/views/layouts/data_migration.html.erb +37 -0
  63. data/app/views/layouts/mailer.html.erb +13 -0
  64. data/app/views/layouts/mailer.text.erb +1 -0
  65. data/app/views/users/registrations/edit.html.erb +41 -0
  66. data/app/views/users/sessions/new.html.erb +35 -0
  67. data/app/views/users/shared/_error_messages.html.erb +13 -0
  68. data/app/views/users/shared/_links.html.erb +21 -0
  69. data/config/initializers/assets.rb +14 -0
  70. data/config/initializers/content_security_policy.rb +27 -0
  71. data/config/initializers/devise.rb +313 -0
  72. data/config/initializers/filter_parameter_logging.rb +10 -0
  73. data/config/initializers/inflections.rb +18 -0
  74. data/config/initializers/permissions_policy.rb +15 -0
  75. data/config/initializers/warden.rb +14 -0
  76. data/config/locales/devise.en.yml +65 -0
  77. data/config/locales/en.yml +31 -0
  78. data/config/routes.rb +62 -0
  79. data/db/migrate/20251102121659_create_migration_plans.rb +13 -0
  80. data/db/migrate/20251102122012_create_migration_steps.rb +24 -0
  81. data/db/migrate/20251105215702_create_migration_executions.rb +23 -0
  82. data/db/migrate/20251105215853_create_migration_records.rb +16 -0
  83. data/db/migrate/20251115154000_remove_unused_attributes.rb +17 -0
  84. data/db/migrate/20251116120000_add_filter_params_to_migration_executions.rb +7 -0
  85. data/db/migrate/20251118140000_create_data_migration_users.rb +27 -0
  86. data/db/migrate/20251118200641_add_user_foreign_keys.rb +15 -0
  87. data/db/migrate/20251124140000_add_attachment_export_mode_to_migration_steps.rb +9 -0
  88. data/db/schema.rb +102 -0
  89. data/db/seeds.rb +19 -0
  90. data/lib/data_migration/engine.rb +28 -0
  91. data/lib/data_migration/version.rb +5 -0
  92. data/lib/data_migration.rb +8 -0
  93. data/lib/tasks/data_migration_tasks.rake +40 -0
  94. 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>