module Eco module API module Common module People class PersonEntry DEBUG = false include Eco::Language::AuxiliarLogger # This class is meant to provide a common interface to access entries of source data that come in # different formats. # @note # - if `data` is a `Person` object, its behaviour is `serialise`. # - if `data` is **not** a `Person` object, it does a `parse`. # - currently **in rework**, so there may be subtle differences that make it temporarily unstable # (yet it is reliable). # @param data [Hash, Ecoportal::API::V1::Person] `Person` object # to be serialized or hashed entry (`CSV::Row` is accepted). # @param person_parser [Common::People::PersonParser] parser/serializer # of person attributes (it contains a set of attribute parsers). # @param attr_map [Eco::Data::Mapper] mapper to translate attribute # names from _external_ to _internal_ names and _vice versa_. # @param dependencies [Hash] hash where _keys_ are internal attribute # names. It is mostly used to deliver final dependencies # to attribute parsers/serializers. # @param logger [Common::Session::Logger, ::Logger] object to managelogs. def initialize(data, person_parser:, attr_map:, dependencies: {}, logger: ::Logger.new(IO::NULL)) msg = "Constructor needs a PersonParser. Given: #{person_parser.class}" raise msg unless person_parser.is_a?(Eco::API::Common::People::PersonParser) msg = "Expecting Mapper object. Given: #{attr_map.class}" raise msg if attr_map && !attr_map.is_a?(Eco::Data::Mapper) @source = data @person_parser = person_parser @deps = dependencies @logger = logger @attr_map = attr_map @emap = PersonEntryAttributeMapper.new( @source, person_parser: @person_parser, attr_map: @attr_map, logger: @logger ) if parsing? @external_entry = __external_entry(data) @mapped_entry = __mapped_entry(@external_entry) @internal_entry = __internal_entry(@mapped_entry) @final_entry = __final_entry(@internal_entry) else # SERIALIZING @person = data @final_entry = __final_entry(@person) @internal_entry = __internal_entry(@final_entry) @mapped_entry = __mapped_entry(@internal_entry) @external_entry = __external_entry(@mapped_entry) end (print_models; exit(1)) if DEBUG # rubocop:disable Style/Semicolon end # Generates a new entry # @return [PersonEntry] def new(data) self.class.new( data, person_parser: @person_parser, attr_map: @attr_map, dependencies: @deps, logger: @logger ) end # @note completely serialized entry. # @return [Hash] entry `Hash` with **external** attribute names, and values and types thereof. def external_entry # rubocop:disable Style/TrivialAccessors @external_entry end # @note just one step away from being completely parsed (only types parsing pending). # @return [Hash] entry `Hash` with **internal** attribute names and values, but **external** types. def internal_entry # rubocop:disable Style/TrivialAccessors @internal_entry end # @return [Hash] entry `Hash` with **internal** attribute names, but **external** types and values. def mapped_entry # rubocop:disable Style/TrivialAccessors @mapped_entry end # @note values ready to be set to a person. # @return [Hash] entry `Hash` with **internal** attribute names, values and types. def final_entry # rubocop:disable Style/TrivialAccessors @final_entry end # To know if currently the object is in parse or serialize mode. # @return [Boolean] returns `true` if we are **parsing**, `false` otherwise. def parsing? !@source.is_a?(Ecoportal::API::Internal::Person) end # To know if currently the object is in parse or serialize mode. # @return [Boolean] returns `true` if we are **serializing**, `false` otherwise. def serializing? !parsing? end # @note `Eco::API::Common::People::EntryFactory#entries` adds this `idx` (i.e. row number) # @return [Integer] the entry number in the input file def idx final_entry["idx"] end # @return [String, nil] the _internal id_ of this person if defined. def id final_entry["id"] end def id? final_entry.key?("id") end # @return [String, nil] the _external id_ of this person if defined. def external_id final_entry["external_id"] end def external_id? final_entry.key?("external_id") end # @return [String, nil] the _name_ of this person if defined. def name final_entry["name"] end def name? final_entry.key?("name") end # @return [String, nil] the _email_ of this person if defined. def email final_entry["email"] end def email? final_entry.key?("email") end # @return [String, nil] the _supervisor id_ of this person if defined. def supervisor_id final_entry["supervisor_id"] end def supervisor_id=(value) final_entry["supervisor_id"] = value end def supervisor_id? final_entry.key?("supervisor_id") end def filter_tags final_entry["filter_tags"] || [] end def filter_tags? final_entry.key?("filter_tags") end def policy_group_ids final_entry["policy_group_ids"] || [] end def policy_group_ids? final_entry.key?("policy_group_ids") end def default_tag? final_entry.key?("default_tag") end def default_tag final_entry["default_tag"] end # Provides a reference to this person entry. # @return [String] string summary of this person identity. def identify str_id = id ? "id: '#{id}'; " : "" "(row: #{idx}) '#{name}' (#{str_id}ext_id: '#{external_id}'; email: '#{email}')" end # Provides a reference of this person entry. # @return [String] string summary of this person identity. def to_s(options) options = into_a(options) if options.include?(:identify) identify else final_entry.each.map do |k, v| "'#{k}': '#{v.to_json}'" end.join(" | ") end end # Setter to fill in all the `core` properties of the `Person` that are present in the `Entry`. # @note # 1. it only sets those core properties defined in the entry. # Meaning that if an core property is not present in the entry, this will not be set on the target person. # 2. if there's an incorrect email exception, it blanks the email and logs a warning message # @param person [Ecoportal::API::V1::Person] the person we want to set the core values to. # @param exclude [String, Array] core attributes that should not be set/changed to the person. def set_core(person, exclude: nil) scoped_attrs = @emap.core_attrs(@final_entry) - into_a(exclude) scoped_attrs -= ['id'] @final_entry.slice(*scoped_attrs).each do |attr, value| set_part(person, attr, value) rescue StandardError => e raise unless attr == "email" log(:error) { "#{e} - setting blank email instead." } set_part(person, attr, nil) end end # Setter to fill in the `account` properties of the `Person` that are present in the `Entry`. # @note it only sets those account properties defined in the entry. # Meaning that if an account property is not present in the entry, this will not be set on the target person. # @param person [Ecoportal::API::Internal::Person] the person we want to set the account values to. # @param exclude [String, Array] account properties that should not be set/changed to the person. def set_account(person, exclude: nil) person.account = {} unless person.account scoped_attrs = @emap.account_attrs(@final_entry) - into_a(exclude) @final_entry.slice(*scoped_attrs).each do |attr, value| set_part(person.account, attr, value) end end # TO DO: use person.details.schema_id to switch @emap and @person_parser (or just crash if they don't match?) # Setter to fill in all the schema `details` fields of the `Person` that are present in the `Entry`. # @note it only sets those details properties defined in the entry. # Meaning that if an details property is not present in the entry, this will not be set on the target person. # @param person [Ecoportal::API::V1::Person] the person we want to set the schema fields' values to. # @param exclude [String, Array] schema field attributes that should not be set/changed to the person. def set_details(person, exclude: nil) person.add_details(@person_parser.schema) if !person.details || !person.details.schema_id scoped_attrs = @emap.details_attrs(@final_entry) - into_a(exclude) @final_entry.slice(*scoped_attrs).each do |attr, value| set_part(person.details, attr, value) end end private # @return [Hash] entry in raw: that with **external** names, values and types. def __external_entry(data) return data if parsing? _external_serializing(data) end # @return [Hash] that with **internal** names but **external** values and types. def __mapped_entry(data) return _mapped_parsing(data) if parsing? _mapped_serializing(data) end # @return [Hash] that with **internal** names and values, but **external** values and types. def __internal_entry(data) return _internal_parsing(data) if parsing? _internal_serializing(data) end # @return [Hash] that with **internal** names, values and types. def __final_entry(data) return _final_parsing(data) if parsing? _final_serializing(data) end # Serializing helper that maps internal attributes to external attribute names # @note **Serialize**: here we unaliase internal attribute names into external ones. # @param mapped_entry [Hash] that with **internal** names but **external** values and types. # @return [Hash] with **external** names, values and types. def _external_serializing(mapped_entry) target_attrs = @emap.all_model_attrs | @emap.aliased_attrs rest_keys = mapped_entry.keys - target_attrs target_attrs -= ["send_invites"] external_entry = target_attrs.each_with_object({}) do |attr, hash| unless hash.key?(ext_attr = @emap.to_external(attr)) hash[ext_attr] = mapped_entry[attr] end end merge_missing_attrs(external_entry, mapped_entry.slice(*rest_keys)) end # Parsing helper that aliases attribute names (from internal to external names) # @note **Parse**: here we aliase external attribute names into internal ones. # @param external_entry [Hash] entry in raw, with **external** names and values. # @return [Hash] entry with **internal** names, but still **external** values and types. def _mapped_parsing(external_entry) mapped_hash = @emap.aliased_attrs.each_with_object({}) do |attr, hash| hash[attr] = external_entry[@emap.to_external(attr)] end external_entry.slice(*@emap.direct_attrs).merge(mapped_hash) end # Serializing helper that **serializes values** that have a parser/serializer defined. # @note **Serializing**: # 1. here we tranform internal into external **values**. # 2. when running the serializers, it overrides existing keys. # @param internal_entry [Hash] entry with **internal** names and values, but **external** types. # @return [Hash] entry with **internal** names and **external** values and types. def _mapped_serializing(internal_entry) mapped_hash = internal_entry.merge(_serialize_values(internal_entry, :internal)) model_attrs = @person_parser.all_model_attrs - ["send_invites"] aux_hash = mapped_hash.slice(*model_attrs) merge_missing_attrs(aux_hash, mapped_hash) end # Parsing helper that just **parses the values** that have a parser/serializer defined. # @note this entry will still miss the type parsing (i.e. to `Array` if `multiple`) # @param mapped_entry [Hash] the entry with the _internal attribute_ names but the _external values_. # @return [Hash] the `internal entry` with the **internal** attributes names and values. def _internal_parsing(mapped_entry) mapped_entry.merge(_parse_values(mapped_entry, :internal)) end # Serializing helper that just creates the _internal entry_ out of a _parsed entry_ (serializes the type). # @param final_entry [Hash] the entry with all _internal_ (attributes, values and types) # @return [Hash] the `internal entry` with the **internal** attributes names and values, but external types. def _internal_serializing(final_entry) final_entry = final_entry.merge(_serialize_values(final_entry, :final)) core_account = @person_parser.target_attrs_account + @person_parser.target_attrs_core core_account_hash = core_account.reduce({}) do |hash, attr| hash.merge(hash_attr(attr, _serialize_type(attr, final_entry[attr]))) end details_hash = @person_parser.target_attrs_details.reduce({}) do |hash, attr| hash.merge(hash_attr(attr, _serialize_type(attr, final_entry[attr], schema: @person_parser.schema))) end merging(core_account_hash, details_hash) do |internal_entry| merge_missing_attrs(internal_entry, final_entry) end end # Parsing helper where attributes with custom parsers are already parsed, but # it finishes to parse the types (i.e. to `Array` if `multiple`) # @param internal_entry [Hash] the entry with the **internal** _attribute_ names and values # but the **external** types. # @return [Hash] the `parsed entry` with the **internal** final attributes names, values and types. def _final_parsing(internal_entry) core_account_attrs = @emap.account_attrs(internal_entry) + @emap.core_attrs(internal_entry) core_account_hash = internal_entry.slice(*core_account_attrs).each_with_object({}) do |(attr, value), hash| hash[attr] = _parse_type(attr, value) end details_attrs = @emap.details_attrs(internal_entry) details_hash = internal_entry.slice(*details_attrs).each_with_object({}) do |(attr, value), hash| hash[attr] = _parse_type(attr, value, schema: @person_parser.schema) end merging(core_account_hash, details_hash) do |final_entry| final_entry = merge_missing_attrs(final_entry, internal_entry) final_entry.merge(_parse_values(final_entry, :final)) end end # Serializing helper that just creates the _parsed entry_ out of a `Person` object. # @note # - when unnesting attributes, the overriding precedence for collisions is # - `core` -> _overrides_ -> `account` -> _overrides_ -> `details` # - to keep things consistent, the `internal entry` hash has keys in this order: # - `core`, `account`, `details`. # @param person [Ecoportal::API::V1::Person] the `Person` object to transform into a _parsed entry_. # @return [Hash] the `parsed entry` with the **internal** attributes names and internal typed values. def _final_serializing(person) core_hash = @person_parser.target_attrs_core.reduce({}) do |hash, attr| hash.merge(hash_attr(attr, get_part(person, attr))) end account_hash = @person_parser.target_attrs_account.reduce({}) do |hash, attr| hash.merge(hash_attr(attr, get_part(person.account, attr))) end details_hash = @person_parser.target_attrs_details.reduce({}) do |hash, attr| hash.merge(hash_attr(attr, get_part(person.details, attr))) end merging(core_hash, account_hash, details_hash) do |final_entry| final_entry["Has account?"] = !person.account.nil? final_entry.merge(_serialize_values(person, :person)) end end # HELPERS def _serialize_values(entry, phase = :person) @person_parser.active_attrs(entry, phase, process: :serialize).each_with_object({}) do |attr, hash| data = entry.is_a?(Hash)? entry.merge(hash) : entry serial_attr = @person_parser.serialize(attr, data, phase, deps: @deps[attr] || {}) hash.merge!(hash_attr(attr, serial_attr)) end end def _parse_values(entry, phase = :internal) @person_parser.active_attrs(entry, phase).each_with_object({}) do |attr, hash| parsed_attr = @person_parser.parse(attr, entry.merge(hash), phase) hash.merge!(hash_attr(attr, parsed_attr)) end end # Transforms each **typed** value into its `String` version def _serialize_type(attr, value, schema: nil) if !!schema unless (field = schema[attr]) fatal("Field '#{attr}' does not exist in details of schema: '#{schema.name}'") end value = @person_parser.serialize(:multiple, value) if field.multiple if @person_parser.defined?(field.type.to_sym) value = @person_parser.serialize(field.type.to_sym, value, deps: {"attr" => attr}) end value elsif %w[policy_group_ids filter_tags login_provider_ids starred_ids].include?(attr) @person_parser.serialize(:multiple, value) elsif %w[freemium accept_eula].include?(attr) @person_parser.serialize(:boolean, value) elsif ["subordinates"].include?(attr) @person_parser.serialize(:number, value) else value end end # Transforms each `String` value into its **typed** version def _parse_type(attr, value, schema: nil) # rubocop:disable Metrics/AbcSize value = value.strip if value.is_a?(String) value = nil if value.to_s.strip.empty? if !!schema unless (field = schema[attr]) fatal("Field '#{attr}' does not exist in details of schema: '#{schema.name}'") end value = @person_parser.parse(:multiple, value) if field.multiple if @person_parser.defined?(field.type.to_sym) value = @person_parser.parse(field.type.to_sym, value, deps: {"attr" => attr}) end value elsif attr == "email" value = value.strip.downcase if value value elsif %w[policy_group_ids filter_tags login_provider_ids starred_ids].include?(attr) value = @person_parser.parse(:multiple, value) value = value.compact.map(&:upcase) if attr == "filter_tags" value elsif %w[freemium accept_eula].include?(attr) @person_parser.parse(:boolean, value) elsif ["subordinates"].include?(attr) @person_parser.parse(:number, value) else value end end # Merges multiple hashes giving overriding perference to the first ones. # @return [Hash] with well sorted keys, as they came in the order of the input hashes. def merging(*hashes) sorted_keys = hashes.map(&:keys).flatten.uniq rev_hash = hashes.reverse.each_with_object({}) {|h, out| out.merge!(h)} merged = sorted_keys.each_with_object({}) do |k, h| h[k] = rev_hash[k] end merged = yield(merged) if block_given? merged end # Adds to `dest_entry` the `keys` it misses from `source_entry` def merge_missing_attrs(dest_entry, source_entry) keys_rest = source_entry.keys - dest_entry.keys dest_entry.merge(source_entry.slice(*keys_rest)) end def into_a(value) value = [] if value.nil? value = [].push(value) unless value.is_a?(Array) value end def get_part(obj, attr) return unless obj case obj when Ecoportal::API::V1::PersonDetails #unless field = obj.get_field(attr) # fatal("Field '#{attr}' does not exist in details of schema: '#{obj.schema_id}'") #end obj[attr] else obj.send(attr) end end def set_part(obj, attr, value) return unless obj case obj when Ecoportal::API::V1::PersonDetails msg = "Field '#{attr}' does not exist in details of schema: '#{obj.schema_id}'" fatal msg unless obj.get_field(attr) obj[attr] = value else obj.send("#{attr}=", value) end rescue StandardError => e # add more info to the error raise e.append_message " -- Entry #{to_s(:identify)}" end # @return [Hash] `value` if it was a `Hash`, and `{ attr => value}` otherwise def hash_attr(attr, value) return value if value.is_a?(Hash) { attr => value } end # LOGGER def fatal(msg) log(:fatal) { msg } raise msg end # Function to debug faste def print_models print_it = proc do |name, model| puts "#{name}:" pp model puts "*" * 30 end fin = proc { print_it.call("final_entry", @final_entry) } int = proc { print_it.call("internal_entry", @internal_entry) } mad = proc { print_it.call("mapped_entry", @mapped_entry) } ext = proc { print_it.call("external_entry", @external_entry) } call_order = parsing? ? [ext, mad, int, fin] : [fin, int, mad, ext] call_order.each(&:call) end end end end end end