Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 139 additions & 1 deletion app/controllers/admin/registrars_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
require 'net/http'
require 'securerandom'
require 'stringio'

module Admin
class RegistrarsController < BaseController # rubocop:disable Metrics/ClassLength
Expand All @@ -13,8 +15,25 @@ class RegistrarsController < BaseController # rubocop:disable Metrics/ClassLengt
def index
registrars = filter_by_status
@q = registrars.ransack(params[:q])
@registrars = @q.result(distinct: true).page(params[:page])
@registrars_scope = @q.result(distinct: true)
@registrars = @registrars_scope.page(params[:page])
@registrars = @registrars.per(params[:results_per_page]) if paginate?

respond_to do |format|
format.html
format.csv do
export_scope = selected_export_scope(@registrars_scope)
send_data(
CsvSync::Exporter.call(
model_class: Registrar,
records: export_scope,
fields: csv_export_fields
),
filename: "registrars-#{Time.zone.today}.csv",
type: 'text/csv'
)
end
end
end

def new
Expand Down Expand Up @@ -64,6 +83,65 @@ def destroy
end
end

def import
@csv_sync_fields = Registrar.csv_sync_default_import_fields
@selected_fields = @csv_sync_fields
@csv_sync_field_groups = csv_sync_field_groups
end

def import_preview
set_import_form_defaults
@result = empty_import_result

if params[:file].blank?
flash.now[:alert] = t('admin.registrars.import_preview.file_required')
return render :import
end

@result = CsvSync::Importer.preview(
model_class: Registrar,
file: params[:file],
fields: @selected_fields
)
@row_results = @result.row_results
@import_token = cache_import_file(params[:file])
render :import_preview
rescue StandardError => e
@result = empty_import_result(errors: 1, row_results: [{ line_number: '-', action: :error, key_values: {}, changes: [], error: e.message }])
flash.now[:alert] = t('admin.registrars.import_preview.failed')
render :import
end

def import_apply
set_import_form_defaults
@result = empty_import_result
file = file_from_apply_params

if file.blank?
flash[:alert] = t('admin.registrars.import_apply.file_not_found')
return redirect_to import_admin_registrars_path
end

@result = CsvSync::Importer.apply(
model_class: Registrar,
file: file,
fields: @selected_fields
)
clear_cached_import_file(params[:import_token])

flash[:notice] = t(
'admin.registrars.import_apply.completed',
created: @result.created,
updated: @result.updated,
unchanged: @result.unchanged,
errors: @result.errors
)
redirect_to admin_registrars_path
rescue StandardError => e
flash[:alert] = "#{t('admin.registrars.import_apply.failed')}: #{e.message}"
redirect_to import_admin_registrars_path
end

private

def filter_by_status
Expand Down Expand Up @@ -134,5 +212,65 @@ def allowed_method(records_param)
allowed_methods = %w[api_users white_ips]
records_param if allowed_methods.include?(records_param)
end

def csv_export_fields
params.fetch(:csv_fields, []).reject(&:blank?)
end

def selected_export_scope(scope)
return scope unless params[:export_selected].present?

selected_ids = params.fetch(:registrar_ids, []).reject(&:blank?)
return scope.none if selected_ids.empty?

scope.where(id: selected_ids)
end

def set_import_form_defaults
@csv_sync_fields = Registrar.csv_sync_default_import_fields
@selected_fields = params.fetch(:fields, []).reject(&:blank?)
@selected_fields = @csv_sync_fields if @selected_fields.empty?
@csv_sync_field_groups = csv_sync_field_groups
end

def csv_sync_field_groups
{ t('admin.csv_sync.field_checkboxes.default_group') => Registrar.csv_sync_default_import_fields }
end

def empty_import_result(errors: 0, row_results: [])
CsvSync::Importer::Result.new(
created: 0,
updated: 0,
unchanged: 0,
errors: errors,
row_results: row_results
)
end

def cache_import_file(file)
token = SecureRandom.uuid
io = file.respond_to?(:tempfile) ? file.tempfile : file
io.rewind if io.respond_to?(:rewind)
csv_payload = io.read
session[:registrars_csv_imports] ||= {}
session[:registrars_csv_imports][token] = csv_payload
token
end

def file_from_apply_params
return params[:file] if params[:file].present?
return nil if params[:import_token].blank?

csv_payload = session.fetch(:registrars_csv_imports, {})[params[:import_token]]
return nil if csv_payload.blank?

StringIO.new(csv_payload)
end

def clear_cached_import_file(token)
return if token.blank?

session[:registrars_csv_imports]&.delete(token)
end
end
end
104 changes: 104 additions & 0 deletions app/models/concerns/csv_sync/model_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
module CsvSync::ModelConfig
extend ActiveSupport::Concern

BOOLEAN_TRUE_VALUES = %w[1 true t yes y on].freeze
BOOLEAN_FALSE_VALUES = %w[0 false f no n off].freeze

class_methods do
def csv_sync_field_definitions
const_get(:FIELD_DEFINITIONS)
end

def csv_sync_fields
csv_sync_field_definitions.keys
end

def csv_sync_key_fields
csv_sync_field_definitions.filter_map { |field, config| field if config[:key] }
end

def csv_sync_default_export_fields
csv_sync_field_definitions.filter_map { |field, config| field if config[:default_export] }
end

def csv_sync_default_import_fields
csv_sync_field_definitions.filter_map { |field, config| field if config[:default_import] }
end

def csv_sync_type_for(field)
csv_sync_field_definitions.fetch(field.to_sym).fetch(:type)
end

def csv_sync_import_value(field, raw_value)
csv_sync_deserialize(raw_value, type: csv_sync_type_for(field))
end

def csv_sync_export_value(field, value)
csv_sync_serialize(value, type: csv_sync_type_for(field))
end

private

def csv_sync_serialize(value, type:)
return nil if value.nil?

case type
when :boolean
value ? 'true' : 'false'
when :decimal
value.to_s
when :datetime
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
when :json
value.to_json
else
value.to_s
end
end

def csv_sync_deserialize(raw_value, type:)
value = raw_value.is_a?(String) ? raw_value.strip : raw_value
return nil if value.blank?

case type
when :boolean
parse_csv_sync_boolean(value)
when :decimal
BigDecimal(value.to_s)
when :datetime
Time.zone.parse(value.to_s)
when :json
parse_csv_sync_json(value)
else
value
end
end

def parse_csv_sync_boolean(value)
normalized = value.to_s.strip.downcase
return true if BOOLEAN_TRUE_VALUES.include?(normalized)
return false if BOOLEAN_FALSE_VALUES.include?(normalized)

raise ArgumentError, "Invalid boolean value: #{value.inspect}"
end

def parse_csv_sync_json(value)
case value
when Hash
value
else
JSON.parse(value.to_s)
end
rescue JSON::ParserError => e
raise ArgumentError, "Invalid JSON value: #{e.message}"
end
end

def csv_sync_export_value(field)
self.class.csv_sync_export_value(field, public_send(field))
end

def csv_sync_import_value(field, raw_value)
self.class.csv_sync_import_value(field, raw_value)
end
end
38 changes: 38 additions & 0 deletions app/models/concerns/registrar/csv_sync.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
module Registrar::CsvSync
extend ActiveSupport::Concern
include CsvSync::ModelConfig

FIELD_DEFINITIONS = {
code: {
key: true,
default_export: true,
default_import: false,
type: :string,
},
name: { default_export: true, default_import: true, type: :string },
reg_no: { default_export: true, default_import: true, type: :string },
email: { default_export: true, default_import: true, type: :string },
billing_email: { default_export: true, default_import: true, type: :string },
phone: { default_export: true, default_import: true, type: :string },
website: { default_export: true, default_import: true, type: :string },
language: { default_export: true, default_import: true, type: :string },
test_registrar: { default_export: true, default_import: false, type: :boolean },
address_street: { default_export: true, default_import: true, type: :string },
address_zip: { default_export: true, default_import: true, type: :string },
address_city: { default_export: true, default_import: true, type: :string },
address_state: { default_export: true, default_import: true, type: :string },
address_country_code: { default_export: true, default_import: true, type: :string },
vat_no: { default_export: true, default_import: true, type: :string },
vat_rate: { default_export: true, default_import: true, type: :decimal },
iban: { default_export: true, default_import: true, type: :string },
accounting_customer_code: { default_export: true, default_import: false, type: :string },
reference_no: { default_export: true, default_import: false, type: :string },
legaldoc_optout: { default_export: true, default_import: true, type: :boolean },
legaldoc_optout_comment: { default_export: true, default_import: true, type: :string },
accept_pdf_invoices: { default_export: true, default_import: true, type: :boolean },
settings: { default_export: true, default_import: true, type: :json },
accreditation_date: { default_export: true, default_import: false, type: :datetime },
accreditation_expire_date: { default_export: true, default_import: false, type: :datetime },
}.freeze

end
1 change: 1 addition & 0 deletions app/models/registrar.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Registrar < ApplicationRecord # rubocop:disable Metrics/ClassLength
include Versions # version/registrar_version.rb
include Registrar::BookKeeping
include Registrar::CsvSync
include EmailVerifable
include Registrar::LegalDoc

Expand Down
38 changes: 38 additions & 0 deletions app/services/csv_sync/exporter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
require 'csv'

class CsvSync::Exporter
def self.call(model_class:, records:, fields: nil)
new(model_class: model_class, records: records, fields: fields).to_csv
end

def initialize(model_class:, records:, fields: nil)
@model_class = model_class
@records = records
@fields = normalize_fields(fields)
end

def to_csv
CSV.generate do |csv|
csv << headers
records.find_each do |record|
csv << fields.map { |field| model_class.csv_sync_export_value(field, record.public_send(field)) }
end
end
end

private

attr_reader :model_class, :records, :fields

def normalize_fields(fields)
allowed_fields = model_class.csv_sync_fields
selected_fields = Array(fields).map(&:to_sym).select { |field| allowed_fields.include?(field) }
selected_fields = model_class.csv_sync_default_export_fields if selected_fields.empty?

(model_class.csv_sync_key_fields + selected_fields).uniq
end

def headers
fields.map(&:to_s)
end
end
Loading