eco-helpers 2.0.18 → 2.0.19

Sign up to get free protection for your applications and to get access to all the features.
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