eco-helpers 2.0.18 → 2.0.19

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/lib/eco/api/common/people/entry_factory.rb +26 -9
  4. data/lib/eco/api/common/people/person_entry.rb +1 -0
  5. data/lib/eco/api/common/session.rb +1 -0
  6. data/lib/eco/api/common/session/base_session.rb +2 -0
  7. data/lib/eco/api/common/session/helpers.rb +30 -0
  8. data/lib/eco/api/common/session/helpers/prompt_user.rb +34 -0
  9. data/lib/eco/api/common/version_patches/ecoportal_api/external_person.rb +1 -1
  10. data/lib/eco/api/common/version_patches/ecoportal_api/internal_person.rb +7 -4
  11. data/lib/eco/api/microcases/with_each.rb +67 -6
  12. data/lib/eco/api/microcases/with_each_present.rb +4 -2
  13. data/lib/eco/api/microcases/with_each_starter.rb +4 -2
  14. data/lib/eco/api/organization.rb +1 -1
  15. data/lib/eco/api/organization/people.rb +92 -23
  16. data/lib/eco/api/organization/people_similarity.rb +112 -0
  17. data/lib/eco/api/organization/person_schemas.rb +5 -1
  18. data/lib/eco/api/organization/policy_groups.rb +5 -1
  19. data/lib/eco/api/session.rb +5 -2
  20. data/lib/eco/api/session/batch.rb +7 -5
  21. data/lib/eco/api/usecases/default_cases/analyse_people_case.rb +12 -35
  22. data/lib/eco/api/usecases/default_cases/to_csv_case.rb +81 -36
  23. data/lib/eco/api/usecases/default_cases/to_csv_detailed_case.rb +3 -4
  24. data/lib/eco/api/usecases/ooze_samples/ooze_update_case.rb +3 -2
  25. data/lib/eco/cli/config/default/options.rb +2 -1
  26. data/lib/eco/cli/config/default/usecases.rb +2 -0
  27. data/lib/eco/cli/config/default/workflow.rb +4 -1
  28. data/lib/eco/csv.rb +4 -2
  29. data/lib/eco/data/fuzzy_match.rb +63 -21
  30. data/lib/eco/data/fuzzy_match/ngrams_score.rb +7 -2
  31. data/lib/eco/data/fuzzy_match/pairing.rb +0 -1
  32. data/lib/eco/data/fuzzy_match/result.rb +7 -1
  33. data/lib/eco/data/fuzzy_match/results.rb +12 -6
  34. data/lib/eco/version.rb +1 -1
  35. metadata +4 -2
  36. data/lib/eco/api/organization/people_analytics.rb +0 -60
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 678fc1c610a5aafd8321a74367a6aaca67aa70bf159f638209b45da0361bb575
4
- data.tar.gz: 280a74a3a3877c5d58bfbe84d0f6a8f8b6f8eda5c68f376fea87522b03a59e42
3
+ metadata.gz: 14260868c76936513a93d4d104eacebbd11e47ed05806d4102ee76196a300d2b
4
+ data.tar.gz: 35784d03a18f89d2ce8bf5c4105e0eaa647dd10b4e1fee03897319d9ad838760
5
5
  SHA512:
6
- metadata.gz: 30c74c7aa8b7f59a4610cb4da804714a165a095d6280b756dcec17df37a81f2b5438a7178cc279750e5f97a43ae6d0041e464d9ed7965af39c39fc71ce4efc99
7
- data.tar.gz: 1abe7f74b0a0452565c1add7778086030372f63912fe2c6279a2d40eb9f908d07746421507241d4905bbdf546e944899fa0f63fa3e3fe086caf1663c83a2624d
6
+ metadata.gz: 514d71e93bfa4fb854d9062be03306e154a4dfd184256ab03da30e2bf4bb2a45fb305efd5e2206821e522dc7cc0bfbc69e75285e3c6292dca78f359bd166f52a
7
+ data.tar.gz: c99a424905916cef61333c18bb31726e90fb9759a4a1b747b72ab123f4bc09ecc6962205a966b3ea46aa1f0a4cc3dd7f72a8c53829cc764a7993d64ad45495bf
data/CHANGELOG.md CHANGED
@@ -1,13 +1,26 @@
1
1
  # Change Log
2
2
  All notable changes to this project will be documented in this file.
3
3
 
4
+ ## [2.0.19] - 2021-05-xx
5
+
6
+ ### Added
7
+ - Better error message for people searches & **offer** to select among the candidates:
8
+ - `Eco::API::Organization::People::MultipleSearchResults`, triggered from `Eco::API::Organization::People#find`
9
+ - `Eco::API::MicroCases#with_each` will offer the selection of candidates
10
+
11
+ ### Changed
12
+ - **renamed** and repurposed `Eco::API::Organization::PeopleAnalytics` to `PeopleSimilarity`
13
+
14
+ ### Fixed
15
+ - `Eco::Data::FuzzyMatch` adjustments for configuration propagation + some fixes
16
+ - Command option `-entries-from` can still be useful when used to obtain `-get-partial` of people base for `:export` use cases !!
17
+
4
18
  ## [2.0.18] - 2021-05-25
5
19
 
6
20
  ### Added
7
21
  - **`-one-off`** option to not having to type the `-api-key` every time you launch one-off scripts
8
22
  - `-api-key` will store the key to the `./.env_one_off` file (supports update and multi-environment)
9
23
 
10
- ### Changed
11
24
  ### Fixed
12
25
  - patched `Exception#patch_full_message` to do not enter into a cyclic error rescue
13
26
  - also rescue on `workflow.rescue`
@@ -2,6 +2,10 @@ module Eco
2
2
  module API
3
3
  module Common
4
4
  module People
5
+ # TODO: EntryFactory should suppport multiple schemas itself
6
+ # => currently, it's through session.entry_factory(schema: id), but this is wrong
7
+ # => This way, Entries and PersonEntry will be able to refer to attr_map and person_parser linked to schema_id
8
+ # => "schema_id" should be an optional column in the input file, or parsable via a custom parser to scope the schema
5
9
  # Helper factory class to generate entries (input entries).
6
10
  # @attr_reader schema [Ecoportal::API::V1::PersonSchema] person schema to be used in this entry factory
7
11
  class EntryFactory < Eco::API::Common::Session::BaseSession
@@ -12,7 +16,7 @@ module Eco
12
16
  # @param schema [Ecoportal::API::V1::PersonSchema] schema of person details that the parser will be based upon.
13
17
  # @param person_parser [nil, Eco::API::Common::People::PersonParser] set of attribute, type and format parsers/serializers.
14
18
  # @param attr_map [nil, Eco::Data::Mapper] attribute names mapper to translate external names into internal ones and _vice versa_.
15
- def initialize(e, schema:, person_parser: nil, attr_map: nil)
19
+ def initialize(e, schema:, person_parser: nil, default_parser: nil, attr_map: nil)
16
20
  fatal "Constructor needs a PersonSchema. Given: #{schema}" if !schema.is_a?(Ecoportal::API::V1::PersonSchema)
17
21
  fatal "Expecting PersonParser. Given: #{person_parser}" if person_parser && !person_parser.is_a?(Eco::API::Common::People::PersonParser)
18
22
  fatal "Expecting Mapper object. Given: #{fields_mapper}" if attr_map && !attr_map.is_a?(Eco::Data::Mapper)
@@ -22,14 +26,25 @@ module Eco
22
26
  @source_person_parser = person_parser
23
27
 
24
28
  # load default parser + custom parsers
25
- base_parser = Eco::API::Common::People::DefaultParsers.new(schema: @schema).merge(@source_person_parser)
29
+ @default_parser = default_parser&.new(schema: @schema) || Eco::API::Common::People::DefaultParsers.new(schema: @schema)
30
+ base_parser = @default_parser.merge(@source_person_parser)
26
31
  # new parser with linked schema
27
32
  @person_parser = @source_person_parser.new(schema: @schema).merge(base_parser)
28
33
  @person_parser_patch_version = @source_person_parser.patch_version
29
-
30
34
  @attr_map = attr_map
31
35
  end
32
36
 
37
+ def newFactory(schema: nil)
38
+ self.class.new(
39
+ environment,
40
+ schema: schema,
41
+ person_parser: @source_person_parser,
42
+ default_parser: @default_parser,
43
+ attr_map: @attr_map
44
+ )
45
+ end
46
+
47
+
33
48
  # provides with a Eco::API::Common::People::PersonParser object (collection of attribute parsers)
34
49
  # @note if the custom person parser has changed, it updates the copy of this EntryFactory instance
35
50
  # @return [Eco::API::Common::People::PersonParser] set of attribute, type and format parsers/serializers.
@@ -43,7 +58,7 @@ module Eco
43
58
 
44
59
  # key method to generate objects of `PersonEntry` that share dependencies via this `EntryFactory` environment.
45
60
  # @note this method is necessary to make the factory object work as a if it was a class `PersonEntry` you can call `new` on.
46
- # @param data [Array<Hash>] data to be parsed. The external hashed entry.
61
+ # @param data [Hash, Person] data to be parsed/serialized. Parsed: the external hashed entry. Serialized: a Person object.
47
62
  # @return [Eco::API::Common::People::PersonEntry]
48
63
  def new(data, dependencies: {})
49
64
  PersonEntry.new(
@@ -104,7 +119,7 @@ module Eco
104
119
  # @param format [Symbol] it specifies the format of the output `file:` (i.e. `:xml`, `:csv`). There must be a parser/serializer defined for it.
105
120
  # @param encoding [String] optional parameter to geneate `file:` content by unsing certain encoding.
106
121
  # @return [Void].
107
- def export(data:, file: "export", format: :csv, encoding: "utf-8")
122
+ def export(data:, file: "export", format: :csv, encoding: "utf-8", internal_names: false)
108
123
  fatal("data: Expected Eco::API::Organization::People object. Given: #{data.class}") unless data.is_a?(Eco::API::Organization::People)
109
124
  fatal("A file should be specified.") unless !file.to_s.strip.empty?
110
125
  fatal("Format should be a Symbol. Given '#{format}'") if format && !format.is_a?(Symbol)
@@ -112,16 +127,18 @@ module Eco
112
127
 
113
128
  run = true
114
129
  if Eco::API::Common::Session::FileManager.file_exists?(file)
115
- print "The file '#{file}' already exists. Do you want to overwrite it? (Y/n): "
116
- res = STDIN.gets.strip
117
- run = ["y", "Y", ""].include?(res)
130
+ prompt_user("The file '#{file}' already exists. Do you want to overwrite it? (Y/n):", default: "Y") do |response|
131
+ run = (response == "") || reponse.upcase.start_with?("Y")
132
+ end
118
133
  end
119
134
 
120
135
  if run
121
136
  deps = {"supervisor_id" => {people: data}}
122
137
 
123
138
  data_entries = data.map do |person|
124
- self.new(person, dependencies: deps).external_entry
139
+ self.new(person, dependencies: deps).yield_self do |entry|
140
+ internal_names ? entry.mapped_entry : entry.external_entry
141
+ end
125
142
  end
126
143
 
127
144
  File.open(file, "w", enconding: encoding) do |fd|
@@ -216,6 +216,7 @@ module Eco
216
216
  end
217
217
  end
218
218
 
219
+ # TO DO: use person.details.schema_id to switch @emap and @person_parser (or just crash if they don't match?)
219
220
  # Setter to fill in all the schema `details` fields of the `Person` that are present in the `Entry`.
220
221
  # @note it only sets those details properties defined in the entry.
221
222
  # Meaning that if an details property is not present in the entry, this will not be set on the target person.
@@ -13,4 +13,5 @@ require_relative 'session/sftp'
13
13
  require_relative 'session/s3_uploader'
14
14
  require_relative 'session/file_manager'
15
15
  require_relative 'session/environment'
16
+ require_relative 'session/helpers'
16
17
  require_relative 'session/base_session'
@@ -11,6 +11,8 @@ module Eco
11
11
  attr_reader :api, :file_manager, :logger
12
12
  alias_method :fm, :file_manager
13
13
 
14
+ include Session::Helpers
15
+
14
16
  def initialize(e)
15
17
  raise "Expected object Eco::API::Common::Session::Environment. Given: #{e.class}" unless e.is_a?(Environment)
16
18
  self.environment = e
@@ -0,0 +1,30 @@
1
+ require_relative 'helpers/prompt_user'
2
+
3
+ module Eco
4
+ module API
5
+ module Common
6
+ module Session
7
+ module Helpers
8
+
9
+ class << self
10
+ def included(base)
11
+ base.send(:include, InstanceMethods)
12
+ base.extend(ClassMethods)
13
+ end
14
+ end
15
+
16
+
17
+ module ClassMethods
18
+
19
+ end
20
+
21
+ module InstanceMethods
22
+ include Helpers::PromptUser
23
+
24
+ end
25
+
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ require 'timeout'
2
+ module Eco
3
+ module API
4
+ module Common
5
+ module Session
6
+ module Helpers
7
+ module PromptUser
8
+
9
+ def prompt_user(question, default:, explanation: "", timeout: nil)
10
+ response = if config.run_mode_remote?
11
+ default
12
+ else
13
+ puts explanation
14
+ print "#{question} "
15
+ if timeout
16
+ begin
17
+ Timeout::timeout(timeout) { STDIN.gets.chop }
18
+ rescue Timeout::Error
19
+ default
20
+ end
21
+ else
22
+ STDIN.gets.chop
23
+ end
24
+ end
25
+ return response unless block_given?
26
+ yield(response)
27
+ end
28
+
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -18,7 +18,7 @@ module Ecoportal
18
18
  entry.to_s(:identify)
19
19
  else
20
20
  str_id = id ? "id: '#{id}'; " : ""
21
- "#{name}' (#{str_id}ext_id: '#{external_id}'; email: '#{email}')"
21
+ "'#{name}' (#{str_id}ext_id: '#{external_id}'; email: '#{email}')"
22
22
  end
23
23
  end
24
24
 
@@ -11,12 +11,15 @@ module Ecoportal
11
11
  original_doc["account"] = JSON.parse(doc["account"])
12
12
  end
13
13
 
14
- def new?
15
- !initial_doc["details"] && !initial_doc["account"]
14
+ def new?(doc = :initial)
15
+ ref_doc = (doc == :original) ? original_doc : initial_doc
16
+ !ref_doc["details"] && !ref_doc["account"]
16
17
  end
17
18
 
18
- def account_added?
19
- account && !initial_doc["account"]
19
+ # @return [Boolean] if the account has been added, compared to `doc`
20
+ def account_added?(doc = :initial)
21
+ ref_doc = (doc == :original) ? original_doc : initial_doc
22
+ account && !ref_doc["account"]
20
23
  end
21
24
 
22
25
  end
@@ -12,16 +12,77 @@ module Eco
12
12
  # @yieldparam person [Ecoportal::API::V1::Person] the found person that matches `entry`, or a new person otherwise.
13
13
  # @return [Eco::API::Organization::People] all the people, including new and existing ones.
14
14
  def with_each(entries, people, options)
15
- entries.map do |entry|
16
- unless person = people.find(entry, strict: micro.strict_search?(options))
17
- person = session.new_person
15
+ @_skip_all_multiple_results = false
16
+ entries.each_with_object([]) do |entry, scoped|
17
+ begin
18
+ unless person = people.find(entry, strict: micro.strict_search?(options))
19
+ person = session.new_person
20
+ end
21
+ rescue Eco::API::Organization::People::MultipleSearchResults => e
22
+ unless @_skip_all_multiple_results
23
+ msg = "\n * When searching this Entry: #{entry.to_s(:identify)}"
24
+ person = _with_each_prompt_to_select_user(e.append_message(msg), entry: entry)
25
+ end
26
+ end
27
+
28
+ if person
29
+ person.entry = entry
30
+ yield(entry, person) if block_given?
31
+ scoped << person
18
32
  end
19
- person.entry = entry
20
- yield(entry, person) if block_given?
21
- person
22
33
  end.yield_self {|all_people| people.newFrom all_people.uniq}
23
34
  end
24
35
 
36
+ private
37
+
38
+ def _with_each_prompt_to_select_user(error, entry: nil, increase_count: true)
39
+ unless error.is_a?(Eco::API::Organization::People::MultipleSearchResults)
40
+ raise "Expecting Eco::API::Organization::People::MultipleSearchResults. Given: #{error.class}"
41
+ end
42
+ @_with_each_prompts = 0 unless instance_variable_defined?(:@_with_each_prompts)
43
+ @_with_each_prompts += 1 if increase_count
44
+
45
+ lines = []
46
+ lines << "\n(#{@_with_each_prompts}) " + error.to_s + "\n"
47
+ lines << " #index - Select the correct person by its number index among the list above."
48
+ lines << " (I) - Just Skip/Ignore this one. I will deal with that input entry in another launch."
49
+ lines << " (A) - Ignore all the rest of input entries with this problem."
50
+ lines << " (C) - Create a new person."
51
+ lines << " (B) - Just break this script. I need to change the input file :/"
52
+
53
+ prompt_user("Type one option (#number/I/A/C/B):", explanation: lines.join("\n"), default: "I") do |res|
54
+ res = res.upcase
55
+ case
56
+ when res.start_with?("I")
57
+ logger.info "Ignoring entry... #{entry.to_s(:identify) if entry}"
58
+ nil
59
+ when res.start_with?("A")
60
+ logger.info "All input entries with this same issue will be ignored for this launch"
61
+ @_skip_all_multiple_results = true
62
+ nil
63
+ when res.start_with?("C")
64
+ logger.info "Creating new person...#{"for entry #{entry.to_s(:identify)}" if entry}"
65
+ session.new_person
66
+ when res.start_with?("B")
67
+ raise error
68
+ when res && !res.empty? && (pos = res.to_i rescue nil) && (pos < error.candidates.length)
69
+ error.candidate(pos).tap do |person|
70
+ logger.info "Thanks!! You selected #{person.identify}"
71
+ sleep(1.5)
72
+ end
73
+ else
74
+ if pos.is_a?(Numeric) && (pos >= error.candidates.length)
75
+ print "#{pos} is not a number in the range. "
76
+ else
77
+ print "#{res} is not an option. "
78
+ end
79
+ puts "Please select one of the offered options..."
80
+ sleep(1)
81
+ _with_each_prompt_to_select_user(error, increase_count: false, entry: entry)
82
+ end
83
+ end
84
+ end
85
+
25
86
  end
26
87
  end
27
88
  end
@@ -15,8 +15,10 @@ module Eco
15
15
  def with_each_present(entries, people, options, log_starter: false)
16
16
  found = []
17
17
  micro.with_each(entries, people, options) do |entry, person|
18
- if person.new? && log_starter
19
- session.logger.error("This person does not exist: #{entry.to_s(:identify)}")
18
+ if person.new?
19
+ if log_starter
20
+ session.logger.error("This person does not exist: #{entry.to_s(:identify)}")
21
+ end
20
22
  next
21
23
  end
22
24
  found << person
@@ -15,8 +15,10 @@ module Eco
15
15
  def with_each_starter(entries, people, options, log_present: false)
16
16
  starters = []
17
17
  micro.with_each(entries, people, options) do |entry, person|
18
- if !person.new? && log_present
19
- session.logger.error("This person (id: '#{person.id}') already exists: #{entry.to_s(:identify)}")
18
+ if !person.new?
19
+ if log_present
20
+ session.logger.error("This person (id: '#{person.id}') already exists: #{entry.to_s(:identify)}")
21
+ end
20
22
  next
21
23
  end
22
24
  starters << person
@@ -9,7 +9,7 @@ require_relative 'organization/tag_tree'
9
9
  require_relative 'organization/presets_factory'
10
10
  require_relative 'organization/preferences'
11
11
  require_relative 'organization/people'
12
- require_relative 'organization/people_analytics'
12
+ require_relative 'organization/people_similarity'
13
13
  require_relative 'organization/person_schemas'
14
14
  require_relative 'organization/policy_groups'
15
15
  require_relative 'organization/login_providers'
@@ -2,6 +2,43 @@ module Eco
2
2
  module API
3
3
  module Organization
4
4
  class People < Eco::Language::Models::Collection
5
+ # Error class that allows to handle cases where multiple people were found for the same criterion.
6
+ # @note its main purpose to prevent the creation of duplicates or override information between different people.
7
+ class MultipleSearchResults < StandardError
8
+ attr_reader :candidates, :property
9
+ # @param msg [String] the basic message error.
10
+ # @param candiates [Array<Person>] the people that match the same search criterion.
11
+ # @param property [String] the property of the person model that triggered the error (base of the search criterion).
12
+ def initialize(msg, candidates: [], property: "email")
13
+ @candidates = candidates
14
+ @property = property
15
+ super(msg + " " + candidates_summary)
16
+ end
17
+
18
+ # @param with_index [Boolean] to add an index to each candidate description.
19
+ # @return [Array<String>] the `candidates` identified
20
+ def identify_candidates(with_index: false)
21
+ candidates.map.each_with_index do |person, i|
22
+ index = with_index ? "#{i}. " : ""
23
+ msg = person.account ? (person.account_added? ? "(new user)" : "(user)") : "(no account)"
24
+ "#{index}#{msg} #{person.identify}"
25
+ end
26
+ end
27
+
28
+ # @return [Person] the `candidate` in the `index` position
29
+ def candidate(index)
30
+ candidates[index]
31
+ end
32
+
33
+ private
34
+
35
+ def candidates_summary
36
+ lines = ["The following people have the same '#{property}':"]
37
+ lines.concat(identify_candidates(with_index: true)).join("\n ")
38
+ end
39
+
40
+ end
41
+
5
42
  # build the shortcuts of Collection
6
43
  attr_presence :account, :details
7
44
  attr_collection :id, :external_id, :email, :name, :supervisor_id
@@ -78,34 +115,36 @@ module Eco
78
115
  # @!group Searchers
79
116
 
80
117
  # It searches a person using the parameters given.
118
+ # @note This is how the search function actually works:
119
+ # 1. if eP `id` is given, returns the person (if found), otherwise...
120
+ # 2. if `external_id` is given, returns the person (if found), otherwise...
121
+ # 3. if `strict` is `false` and `email` is given:
122
+ # - if there is only 1 person with that email, returns that person, otherwise...
123
+ # - if found but, there are many candidates, it raises MultipleSearchResults error
124
+ # - if person `external_id` matches `email`, returns that person
125
+ # @raise MultipleSearchResults if there are multiple people with the same `email`
126
+ # and there's no other criteria to find the person. It only gets to this point if
127
+ # `external_id` was **not** provided and we are **not** in 'strict' search mode.
128
+ # However, it could be we were in `strict` mode and `external_id` was not provided.
81
129
  # @param id [String] the `internal id` of the person
82
130
  # @param external_id [String] the `exernal_id` of the person
83
131
  # @param email [String] the `email` of the person
84
- # @param strict [Boolean] if should perform a `soft` or a `strict` search. `strict` will avoid repeated email addresses.
132
+ # @param strict [Boolean] if should perform a `:soft` or a `:strict` search. `strict` will avoid repeated email addresses.
85
133
  # @return [Person, nil] the person we were searching, or `nil` if not found.
86
134
  def person(id: nil, external_id: nil, email: nil, strict: false)
87
135
  init_caches
88
- pers = @by_id[id]&.first if id
89
- pers = @by_external_id[external_id&.strip]&.first if !pers && !external_id.to_s.strip.empty?
90
-
91
- # strict prevents taking existing user for searched person with same email
92
- # specially useful if the organisation ensures all have external id (no need for email search)
93
- if !pers && (!strict || external_id.to_s.strip.empty?)
94
- # person still not found and either not strict or no external_id provided
95
- pers = @by_users_email[email&.downcase.strip]&.first if !email.to_s.strip.empty?
96
-
97
- if !pers && !strict && !email.to_s.strip.empty?
98
- candidates = @by_non_users_email[email&.downcase.strip] || []
99
- raise "Too many non-user candidates (#{candidates.length}) with email '#{email}'" if candidates.length > 1
100
- pers = candidates.first
101
- end
102
-
103
- pers = @by_external_id[email&.downcase.strip]&.first if !pers && !email.to_s.strip.empty?
104
- end
105
-
136
+ # normalize values
137
+ ext_id = !external_id.to_s.strip.empty? && external_id.strip
138
+ email = !email.to_s.strip.empty? && email.downcase.strip
139
+
140
+ pers = nil
141
+ pers ||= @by_id[id]&.first
142
+ pers ||= @by_external_id[ext_id]&.first
143
+ pers ||= person_by_email(email) unless strict && ext_id
106
144
  pers
107
145
  end
108
146
 
147
+ # @see Eco::API::Organization::People#person
109
148
  def find(object, strict: false)
110
149
  id = attr_value(object, "id")
111
150
  external_id = attr_value(object, "external_id")
@@ -190,16 +229,46 @@ module Eco
190
229
 
191
230
  private
192
231
 
232
+ def person_by_email(email, prevent_duplicates: true)
233
+ return nil unless email
234
+
235
+ candidates = @by_non_users_email[email] || []
236
+ email_users = @by_users_email[email] || []
237
+
238
+ if pers = email_users.first
239
+ return pers if candidates.empty?
240
+ candidates = [pers] + candidates
241
+ elsif candidates.length == 1
242
+ return candidates.first
243
+ end
244
+
245
+ if prevent_duplicates && !candidates.empty?
246
+ msg = "Multiple search results match the criteria."
247
+ raise MultipleSearchResults.new(msg, candidates: candidates, property: "email")
248
+ end
249
+
250
+ @by_external_id[email]&.first
251
+ end
252
+
193
253
  def init_caches
194
254
  return if @caches_init
195
255
  @by_id = to_h
196
- @by_external_id = to_h('external_id')
197
- @by_users_email = users.to_h('email')
198
- @by_non_users_email = non_users.to_h('email')
199
- @by_email = to_h('email')
256
+ @by_external_id = no_nil_key(to_h('external_id'))
257
+ @by_users_email = no_nil_key(existing_users.to_h('email'))
258
+ @by_non_users_email = no_nil_key(non_users.to_h('email'))
259
+ @by_email = no_nil_key(to_h('email'))
200
260
  @caches_init = true
201
261
  end
202
262
 
263
+ def existing_users
264
+ newFrom users.select {|u| !u.account_added?(:original)}
265
+ end
266
+
267
+ def no_nil_key(hash)
268
+ hash.tap {|h| h.delete(nil)}
269
+ end
270
+
271
+
203
272
  end
204
273
  end
205
274
  end