Repository: chaoss/grimoirelab-sortinghat (language: python)

Issue:
Recommendations with extrict critera
The old SortingHat (0.7) had a feature that allowed to unify identities only when the fields used to evaluate a match satisfy some rules.

For example, to use the email field, this has to be a valid email address (example@example.com). For the name field, only those with a first name and one or more last names (e.g. John Smith, John Smith Smith) were selected.

The latest version of SortingHat doesn't have this feature. It should be something that is available by default, but it can be deactivated when needed.


Discussion / hints:

Fix patch:
diff --git a/releases/unreleased/strict-criteria.yml b/releases/unreleased/strict-criteria.yml
new file mode 100644
index 00000000..c6d0fb1f
--- /dev/null
+++ b/releases/unreleased/strict-criteria.yml
@@ -0,0 +1,10 @@
+---
+title: Strict criteria for merge recommendations
+category: added
+author: Eva Millan <evamillan@bitergia.com>
+issue: 812
+notes: >
+  The merge recommendations filter out invalid email adresses
+  and names that don't have at least a first and last name 
+  when looking for matches. To disable this behavior, set the
+  `strict` parameter on `recommendMatches` or `unify` to `false`.
diff --git a/sortinghat/core/jobs.py b/sortinghat/core/jobs.py
index a46db8b7..aeb12136 100644
--- a/sortinghat/core/jobs.py
+++ b/sortinghat/core/jobs.py
@@ -196,7 +196,7 @@ def recommend_affiliations(ctx, uuids=None):
 
 @django_rq.job
 @job_using_tenant
-def recommend_matches(ctx, source_uuids, target_uuids, criteria, exclude=True, verbose=False):
+def recommend_matches(ctx, source_uuids, target_uuids, criteria, exclude=True, verbose=False, strict=True):
     """Generate a list of affiliation recommendations from a set of individuals.
 
     This function generates a list of recommendations which include the
@@ -243,7 +243,7 @@ def recommend_matches(ctx, source_uuids, target_uuids, criteria, exclude=True, v
 
     trxl = TransactionsLog.open('recommend_matches', job_ctx)
 
-    for rec in engine.recommend('matches', source_uuids, target_uuids, criteria, exclude, verbose):
+    for rec in engine.recommend('matches', source_uuids, target_uuids, criteria, exclude, verbose, strict):
         results[rec.key] = list(rec.options)
         # Store matches in the database
         for match in rec.options:
@@ -401,7 +401,7 @@ def affiliate(ctx, uuids=None):
 
 @django_rq.job
 @job_using_tenant
-def unify(ctx, criteria, source_uuids=None, target_uuids=None, exclude=True):
+def unify(ctx, criteria, source_uuids=None, target_uuids=None, exclude=True, strict=True):
     """Unify a set of individuals by merging them using matching recommendations.
 
     This function automates the identities unify process obtaining
@@ -471,7 +471,7 @@ def _group_recommendations(recs):
     trxl = TransactionsLog.open('unify', job_ctx)
 
     match_recs = {}
-    for rec in engine.recommend('matches', source_uuids, target_uuids, criteria, exclude=exclude):
+    for rec in engine.recommend('matches', source_uuids, target_uuids, criteria, exclude=exclude, strict=strict):
         match_recs[rec.key] = list(rec.options)
 
     match_groups = _group_recommendations(match_recs)
diff --git a/sortinghat/core/recommendations/matching.py b/sortinghat/core/recommendations/matching.py
index ba0373ed..8244c39a 100644
--- a/sortinghat/core/recommendations/matching.py
+++ b/sortinghat/core/recommendations/matching.py
@@ -36,8 +36,11 @@
 
 logger = logging.getLogger(__name__)
 
+EMAIL_ADDRESS_REGEX = r"^(?P<email>[^\s@]+@[^\s@.]+\.[^\s@]+)$"
+NAME_REGEX = r"^\w+\s\w+"
 
-def recommend_matches(source_uuids, target_uuids, criteria, exclude=True, verbose=False):
+
+def recommend_matches(source_uuids, target_uuids, criteria, exclude=True, verbose=False, strict=True):
     """Recommend identity matches for a list of individuals.
 
     Returns a generator of identity matches recommendations
@@ -71,6 +74,7 @@ def recommend_matches(source_uuids, target_uuids, criteria, exclude=True, verbos
         RecommenderExclusionTerm table. Otherwise, results will not ignore them.
     :param verbose: if set to `True`, the list of results will include individual
     identities. Otherwise, results will include main keys from individuals
+    :param strict: strict matching with well-formed email addresses and names
 
     :returns: a generator of recommendations
     """
@@ -116,7 +120,7 @@ def _get_identities(uuid):
         identities = Identity.objects.all()
         target_set.update(identities)
 
-    matched = _find_matches(input_set, target_set, criteria, exclude=exclude, verbose=verbose)
+    matched = _find_matches(input_set, target_set, criteria, exclude=exclude, verbose=verbose, strict=strict)
     # Return filtered results
     for uuid in source_uuids:
         result = set()
@@ -136,7 +140,7 @@ def _get_identities(uuid):
     logger.info(f"Matching recommendations generated; criteria='{criteria}'")
 
 
-def _find_matches(set_x, set_y, criteria, exclude, verbose):
+def _find_matches(set_x, set_y, criteria, exclude, verbose, strict):
     """Find identities matches between two sets using Pandas' library.
 
     This method find matches for the identities in `set_x` looking at
@@ -157,6 +161,7 @@ def _find_matches(set_x, set_y, criteria, exclude, verbose):
         RecommenderExclusionTerm table. Otherwise, results will not ignore them.
     :param verbose: if set to `True`, the list of results will include individual
         identities. Otherwise, results will include main keys from individuals.
+    :param strict: strict matching with well-formed email addresses and names
 
     :returns: a dictionary including the set of matches found for each
         identity from `set_x`.
@@ -174,11 +179,18 @@ def _apply_recommender_exclusion_list(df):
         df_excluded = df[~df['username'].isin(excluded) & ~df['email'].isin(excluded) & ~df['name'].isin(excluded)]
         return df_excluded
 
-    def _filter_criteria(df, c):
+    def _filter_criteria(df, c, strict=True):
         """Filter dataframe creating a basic subset including a given column"""
         cols = ['uuid', 'individual', c]
         cdf = df[cols]
-        return cdf.dropna(subset=[c])
+        cdf = cdf.dropna(subset=[c])
+
+        if strict and c == 'email':
+            cdf = cdf[cdf['email'].str.fullmatch(EMAIL_ADDRESS_REGEX)]
+        elif strict and c == 'name':
+            cdf = cdf[cdf['name'].str.match(NAME_REGEX)]
+
+        return cdf
 
     def _calculate_matches_groups(grouped_uids, verbose=False):
         """Calculate groups of matching identities from identity groups.
@@ -242,8 +254,8 @@ def _calculate_matches_groups(grouped_uids, verbose=False):
     cdfs = []
 
     for c in criteria:
-        cdf_x = _filter_criteria(df_x, c)
-        cdf_y = _filter_criteria(df_y, c)
+        cdf_x = _filter_criteria(df_x, c, strict)
+        cdf_y = _filter_criteria(df_y, c, strict)
         cdf = pandas.merge(cdf_x, cdf_y, on=c, how='inner')
         cdf = cdf[['individual_y', 'uuid_x', 'uuid_y']]
         cdfs.append(cdf)
diff --git a/sortinghat/core/schema.py b/sortinghat/core/schema.py
index 8f0bd9cc..b22e9c18 100644
--- a/sortinghat/core/schema.py
+++ b/sortinghat/core/schema.py
@@ -1073,17 +1073,18 @@ class Arguments:
         criteria = graphene.List(graphene.String)
         verbose = graphene.Boolean(required=False)
         exclude = graphene.Boolean(required=False)
+        strict = graphene.Boolean(required=False)
 
     job_id = graphene.Field(lambda: graphene.String)
 
     @check_permissions('core.execute_job')
     @check_auth
-    def mutate(self, info, criteria, source_uuids=None, target_uuids=None, exclude=True, verbose=False):
+    def mutate(self, info, criteria, source_uuids=None, target_uuids=None, exclude=True, verbose=False, strict=True):
         user = info.context.user
         tenant = get_db_tenant()
         ctx = SortingHatContext(user=user, tenant=tenant)
 
-        job = enqueue(recommend_matches, ctx, source_uuids, target_uuids, criteria, exclude, verbose, job_timeout=-1)
+        job = enqueue(recommend_matches, ctx, source_uuids, target_uuids, criteria, exclude, verbose, strict, job_timeout=-1)
 
         return RecommendMatches(
             job_id=job.id
@@ -1141,17 +1142,18 @@ class Arguments:
                                      required=False)
         criteria = graphene.List(graphene.String)
         exclude = graphene.Boolean(required=False)
+        strict = graphene.Boolean(required=False)
 
     job_id = graphene.Field(lambda: graphene.String)
 
     @check_permissions('core.execute_job')
     @check_auth
-    def mutate(self, info, criteria, source_uuids=None, target_uuids=None, exclude=True):
+    def mutate(self, info, criteria, source_uuids=None, target_uuids=None, exclude=True, strict=True):
         user = info.context.user
         tenant = get_db_tenant()
         ctx = SortingHatContext(user=user, tenant=tenant)
 
-        job = enqueue(unify, ctx, criteria, source_uuids, target_uuids, exclude, job_timeout=-1)
+        job = enqueue(unify, ctx, criteria, source_uuids, target_uuids, exclude, strict, job_timeout=-1)
 
         return Unify(
             job_id=job.id
diff --git a/ui/src/apollo/mutations.js b/ui/src/apollo/mutations.js
index 511e90e6..9ab48ca9 100644
--- a/ui/src/apollo/mutations.js
+++ b/ui/src/apollo/mutations.js
@@ -286,8 +286,8 @@ const GENDERIZE = gql`
 `;
 
 const UNIFY = gql`
-  mutation unify($criteria: [String], $exclude: Boolean) {
-    unify(criteria: $criteria, exclude: $exclude) {
+  mutation unify($criteria: [String], $exclude: Boolean, $strict: Boolean) {
+    unify(criteria: $criteria, exclude: $exclude, strict: $strict) {
       jobId
     }
   }
@@ -309,11 +309,13 @@ const RECOMMEND_MATCHES = gql`
     $criteria: [String]
     $exclude: Boolean
     $sourceUuids: [String]
+    $strict: Boolean
   ) {
     recommendMatches(
       criteria: $criteria
       exclude: $exclude
       sourceUuids: $sourceUuids
+      strict: $strict
     ) {
       jobId
     }
@@ -614,12 +616,13 @@ const genderize = (apollo, exclude, noStrictMatching, uuids) => {
   });
 };
 
-const unify = (apollo, criteria, exclude) => {
+const unify = (apollo, criteria, exclude, strict) => {
   return apollo.mutate({
     mutation: UNIFY,
     variables: {
       criteria: criteria,
       exclude: exclude,
+      strict: strict,
     },
   });
 };
@@ -634,13 +637,14 @@ const manageMergeRecommendation = (apollo, id, apply) => {
   });
 };
 
-const recommendMatches = (apollo, criteria, exclude, sourceUuids) => {
+const recommendMatches = (apollo, criteria, exclude, strict, sourceUuids) => {
   return apollo.mutate({
     mutation: RECOMMEND_MATCHES,
     variables: {
       criteria: criteria,
       exclude: exclude,
       sourceUuids: sourceUuids,
+      strict: strict,
     },
   });
 };
diff --git a/ui/src/components/JobModal.vue b/ui/src/components/JobModal.vue
index 3632027f..6adfaf8c 100644
--- a/ui/src/components/JobModal.vue
+++ b/ui/src/components/JobModal.vue
@@ -55,6 +55,12 @@
               multiple
               outlined
             />
+            <v-checkbox
+              v-model="forms.unify.strict"
+              label="Exclude individuals with invalid email adresses and names"
+              id="unify_strict"
+              hide-details
+            />
             <v-checkbox
               v-model="forms.unify.exclude"
               label="Exclude individuals in RecommenderExclusionTerm list"
@@ -74,6 +80,12 @@
               multiple
               outlined
             />
+            <v-checkbox
+              v-model="forms.recommendMatches.strict"
+              label="Exclude individuals with invalid email adresses and names"
+              id="recommend_strict"
+              hide-details
+            />
             <v-checkbox
               v-model="forms.recommendMatches.exclude"
               label="Exclude individuals in RecommenderExclusionTerm list"
@@ -132,10 +144,12 @@ const defaultForms = {
   unify: {
     criteria: ["name", "email", "username"],
     exclude: true,
+    strict: true,
   },
   recommendMatches: {
     criteria: ["name", "email", "username"],
     exclude: true,
+    strict: true,
   },
 };
 
diff --git a/ui/src/components/MatchesModal.vue b/ui/src/components/MatchesModal.vue
index ee8ff4a0..a07f9af2 100644
--- a/ui/src/components/MatchesModal.vue
+++ b/ui/src/components/MatchesModal.vue
@@ -40,6 +40,12 @@
         </div>
         <v-divider></v-divider>
         <div class="my-4">
+          <v-checkbox
+            v-model="form.strict"
+            label="Strict matching criteria"
+            dense
+            hide-details
+          />
           <v-checkbox
             v-model="form.exclude"
             label="Exclude individuals in RecommenderExclusionTerm list"
@@ -87,6 +93,7 @@ export default {
       form: {
         criteria: ["name", "email", "username"],
         exclude: true,
+        strict: true,
       },
       jobId: null,
       error: null,
@@ -98,6 +105,7 @@ export default {
       this.form = {
         criteria: ["name", "email", "username"],
         exclude: true,
+        strict: true,
       };
       this.error = null;
       this.jobId = null;
@@ -107,6 +115,7 @@ export default {
         const response = await this.recommendMatches(
           this.form.criteria,
           this.form.exclude,
+          this.form.strict,
           [this.uuid]
         );
         this.jobId = response.data.recommendMatches.jobId;
diff --git a/ui/src/views/Individual.vue b/ui/src/views/Individual.vue
index 3874ae3f..89c08c02 100644
--- a/ui/src/views/Individual.vue
+++ b/ui/src/views/Individual.vue
@@ -674,11 +674,12 @@ export default {
         this.$logger.error(`Error applying recommendation ${id}: ${error}`);
       }
     },
-    async recommendMatches(criteria, exclude, uuid) {
+    async recommendMatches(criteria, exclude, strict, uuid) {
       const response = await recommendMatches(
         this.$apollo,
         criteria,
         exclude,
+        strict,
         uuid
       );
       return response;
diff --git a/ui/src/views/Jobs.vue b/ui/src/views/Jobs.vue
index ed772618..656a7e84 100644
--- a/ui/src/views/Jobs.vue
+++ b/ui/src/views/Jobs.vue
@@ -62,9 +62,9 @@ export default {
         });
       }
     },
-    async unify({ criteria, exclude }) {
+    async unify({ criteria, exclude, strict }) {
       try {
-        await unify(this.$apollo, criteria, exclude);
+        await unify(this.$apollo, criteria, exclude, strict);
         this.$refs.table.getPaginatedJobs();
       } catch (error) {
         this.snackbar = Object.assign(this.snackbar, {
@@ -73,9 +73,9 @@ export default {
         });
       }
     },
-    async recommendMatches({ criteria, exclude }) {
+    async recommendMatches({ criteria, exclude, strict }) {
       try {
-        await recommendMatches(this.$apollo, criteria, exclude);
+        await recommendMatches(this.$apollo, criteria, exclude, strict);
         this.$refs.table.getPaginatedJobs();
       } catch (error) {
         this.snackbar = Object.assign(this.snackbar, {
diff --git a/ui/src/views/SettingsGeneral.vue b/ui/src/views/SettingsGeneral.vue
index a46d2219..ee45e3e0 100644
--- a/ui/src/views/SettingsGeneral.vue
+++ b/ui/src/views/SettingsGeneral.vue
@@ -139,6 +139,12 @@
                   dense
                   hide-details
                 />
+                <v-checkbox
+                  v-model="tasks.unify.params.strict"
+                  label="Exclude individuals with invalid email adresses and names"
+                  dense
+                  hide-details
+                />
               </v-col>
             </v-row>
             <v-row class="ma-0 pl-2">
@@ -251,6 +257,7 @@ export default {
           params: {
             criteria: ["name", "email", "username"],
             exclude: true,
+            strict: true,
           },
         },
       },


Test patch:
diff --git a/tests/rec/test_matches.py b/tests/rec/test_matches.py
index 42d70157..8c23960e 100644
--- a/tests/rec/test_matches.py
+++ b/tests/rec/test_matches.py
@@ -51,6 +51,10 @@ def setUp(self):
                                     username='jsmith',
                                     source='scm',
                                     uuid=self.john_smith.uuid)
+        self.js4 = api.add_identity(self.ctx,
+                                    email='jsmith',
+                                    source='scm',
+                                    uuid=self.john_smith.uuid)
 
         # Individual 2
         self.jsmith = api.add_identity(self.ctx,
@@ -106,10 +110,16 @@ def setUp(self):
                                       name='jrae',
                                       source='mls',
                                       uuid=self.jrae.uuid)
-        self.jrae3 = api.add_identity(self.ctx,
-                                      name='jrae',
-                                      source='scm',
-                                      uuid=self.jrae.uuid)
+
+        # Individual 6
+        self.jrae_no_name = api.add_identity(self.ctx,
+                                             name='jrae',
+                                             source='scm')
+
+        # Individual 7
+        self.jsmith_no_email = api.add_identity(self.ctx,
+                                                email='jsmith',
+                                                source='mls')
 
     def test_recommend_matches(self):
         """Check if recommendations are obtained for the specified individuals"""
@@ -117,19 +127,20 @@ def test_recommend_matches(self):
         # Test
         expected = {
             self.john_smith.uuid: sorted([self.jsmith.uuid]),
-            self.jrae3.uuid: sorted([self.jrae.uuid,
-                                     self.jane_rae.uuid]),
+            self.jrae_no_name.uuid: [],
             self.jr2.uuid: sorted([self.jrae.uuid,
                                    self.jane_rae.uuid])
         }
 
-        source_uuids = [self.john_smith.uuid, self.jrae3.uuid, self.jr2.uuid]
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
         target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
                         self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
                         self.jane_rae.uuid, self.jr2.uuid,
                         self.js_alt.uuid, self.js_alt2.uuid,
                         self.js_alt3.uuid, self.js_alt4.uuid,
-                        self.jrae.uuid, self.jrae2.uuid, self.jrae3.uuid]
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
 
         criteria = ['email', 'name', 'username']
 
@@ -146,6 +157,46 @@ def test_recommend_matches(self):
         self.assertEqual(len(result), 3)
         self.assertDictEqual(result, expected)
 
+    def test_recommend_matches_no_strict(self):
+        """Check if recommendations are obtained for the specified individuals when
+        'strict' is disabled"""
+
+        # Test
+        expected = {
+            self.john_smith.uuid: sorted([self.jsmith.uuid,
+                                          self.jsmith_no_email.uuid]),
+            self.jrae_no_name.uuid: sorted([self.jrae.uuid,
+                                            self.jane_rae.uuid]),
+            self.jr2.uuid: sorted([self.jrae.uuid,
+                                   self.jane_rae.uuid,
+                                   self.jrae_no_name.uuid])
+        }
+
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
+        target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
+                        self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
+                        self.jane_rae.uuid, self.jr2.uuid,
+                        self.js_alt.uuid, self.js_alt2.uuid,
+                        self.js_alt3.uuid, self.js_alt4.uuid,
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
+
+        criteria = ['email', 'name', 'username']
+
+        recs = dict(recommend_matches(source_uuids,
+                                      target_uuids,
+                                      criteria,
+                                      strict=False))
+
+        # Preserve results order for the comparison against the expected results
+        result = {}
+        for key in recs:
+            result[key] = sorted(recs[key])
+
+        self.assertEqual(len(result), 3)
+        self.assertDictEqual(result, expected)
+
     def test_recommend_matches_exclude(self):
         """Check if recommendations are obtained for the specified individuals
         activating exclude"""
@@ -153,19 +204,20 @@ def test_recommend_matches_exclude(self):
         # Test
         expected = {
             self.john_smith.uuid: [],
-            self.jrae3.uuid: sorted([self.jrae.uuid,
-                                     self.jane_rae.uuid]),
+            self.jrae_no_name.uuid: [],
             self.jr2.uuid: sorted([self.jrae.uuid,
                                    self.jane_rae.uuid])
         }
 
-        source_uuids = [self.john_smith.uuid, self.jrae3.uuid, self.jr2.uuid]
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
         target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
                         self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
                         self.jane_rae.uuid, self.jr2.uuid,
                         self.js_alt.uuid, self.js_alt2.uuid,
                         self.js_alt3.uuid, self.js_alt4.uuid,
-                        self.jrae.uuid, self.jrae2.uuid, self.jrae3.uuid]
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
 
         criteria = ['email', 'name', 'username']
 
@@ -196,16 +248,19 @@ def test_recommend_matches_verbose(self):
                                           self.jsm3.uuid,
                                           self.js2.uuid,
                                           self.js3.uuid]),
-            self.jrae3.uuid: sorted([self.jrae2.uuid]),
+            self.jrae_no_name.uuid: [],
             self.jr2.uuid: sorted([self.jrae.uuid])
         }
 
-        source_uuids = [self.john_smith.uuid, self.jrae3.uuid, self.jr2.uuid]
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
         target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
                         self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
                         self.jane_rae.uuid, self.jr2.uuid,
-                        self.js_alt.uuid, self.js_alt2.uuid, self.js_alt3.uuid, self.js_alt4.uuid,
-                        self.jrae.uuid, self.jrae2.uuid, self.jrae3.uuid]
+                        self.js_alt.uuid, self.js_alt2.uuid,
+                        self.js_alt3.uuid, self.js_alt4.uuid,
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
 
         criteria = ['email', 'name', 'username']
 
@@ -222,6 +277,46 @@ def test_recommend_matches_verbose(self):
         self.assertEqual(len(result), 3)
         self.assertDictEqual(result, expected)
 
+    def test_recommend_matches_verbose_no_strict(self):
+        """Check if recommendations are obtained for the specified individuals,
+        at identity level, when 'strict' is disabled"""
+
+        # Test
+        expected = {
+            self.john_smith.uuid: sorted([self.jsm2.uuid,
+                                          self.jsm3.uuid,
+                                          self.js2.uuid,
+                                          self.js3.uuid]),
+            self.jrae_no_name.uuid: sorted([self.jrae2.uuid]),
+            self.jr2.uuid: sorted([self.jrae.uuid])
+        }
+
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
+        target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
+                        self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
+                        self.jane_rae.uuid, self.jr2.uuid,
+                        self.js_alt.uuid, self.js_alt2.uuid,
+                        self.js_alt3.uuid, self.js_alt4.uuid,
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
+
+        criteria = ['email', 'name', 'username']
+
+        # Identities which don't have the fields in `criteria` won't be returned
+        recs = dict(recommend_matches(source_uuids,
+                                      target_uuids,
+                                      criteria,
+                                      verbose=True,
+                                      strict=False))
+        # Preserve results order for the comparison against the expected results
+        result = {}
+        for key in recs:
+            result[key] = sorted(recs[key])
+
+        self.assertEqual(len(result), 3)
+        self.assertDictEqual(result, expected)
+
     def test_recommend_source_not_mk(self):
         """Check if recommendations work when the provided uuid is not an Individual's main key"""
 
diff --git a/tests/test_jobs.py b/tests/test_jobs.py
index 11a15b83..80c8e327 100644
--- a/tests/test_jobs.py
+++ b/tests/test_jobs.py
@@ -517,6 +517,10 @@ def setUp(self):
                                     username='jsmith',
                                     source='scm',
                                     uuid=self.john_smith.uuid)
+        self.js4 = api.add_identity(self.ctx,
+                                    email='jsmith',
+                                    source='scm',
+                                    uuid=self.john_smith.uuid)
 
         # Individual 2
         self.jsmith = api.add_identity(self.ctx,
@@ -572,10 +576,16 @@ def setUp(self):
                                       name='jrae',
                                       source='mls',
                                       uuid=self.jrae.uuid)
-        self.jrae3 = api.add_identity(self.ctx,
-                                      name='jrae',
-                                      source='scm',
-                                      uuid=self.jrae.uuid)
+
+        # Individual 6
+        self.jrae_no_name = api.add_identity(self.ctx,
+                                             name='jrae',
+                                             source='scm')
+
+        # Individual 7
+        self.jsmith_no_email = api.add_identity(self.ctx,
+                                                email='jsmith',
+                                                source='mls')
 
     def test_recommend_matches_all_individuals(self):
         """Check if recommendations are obtained for all individuals"""
@@ -585,11 +595,13 @@ def test_recommend_matches_all_individuals(self):
         # Test
         expected = {
             'results': {
-                self.john_smith.uuid: sorted([self.jsmith.uuid, self.js_alt.uuid]),
+                self.js_alt.uuid: sorted([self.jsmith.uuid, self.john_smith.uuid]),
                 self.jsmith.uuid: sorted([self.john_smith.uuid, self.js_alt.uuid]),
+                self.jrae_no_name.uuid: [],
+                self.jsmith_no_email.uuid: [],
+                self.john_smith.uuid: sorted([self.jsmith.uuid, self.js_alt.uuid]),
+                self.jrae.uuid: sorted([self.jane_rae.uuid]),
                 self.jane_rae.uuid: sorted([self.jrae.uuid]),
-                self.js_alt.uuid: sorted([self.jsmith.uuid, self.john_smith.uuid]),
-                self.jrae.uuid: sorted([self.jane_rae.uuid])
             }
         }
 
@@ -618,7 +630,8 @@ def test_recommend_matches_all_individuals(self):
 
         # Should have the same result as passing all the uuids
         all_source_uuids = [self.john_smith.uuid, self.jsmith.uuid,
-                            self.jane_rae.uuid, self.js_alt.uuid, self.jrae.uuid]
+                            self.jane_rae.uuid, self.js_alt.uuid, self.jrae.uuid,
+                            self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
 
         job_uuids = recommend_matches.delay(ctx,
                                             all_source_uuids,
@@ -630,6 +643,70 @@ def test_recommend_matches_all_individuals(self):
 
         self.assertDictEqual(result, result_all_uuids)
 
+    def test_recommend_matches_all_individuals_no_strict(self):
+        """Check if recommendations are obtained for all individuals with strict mode disabled"""
+
+        ctx = SortingHatContext(self.user)
+
+        # Test
+        expected = {
+            'results': {
+                self.js_alt.uuid: sorted([self.jsmith.uuid, self.john_smith.uuid, self.jsmith_no_email.uuid]),
+                self.jsmith.uuid: sorted([self.john_smith.uuid, self.js_alt.uuid, self.jsmith_no_email.uuid]),
+                self.jrae_no_name.uuid: sorted([self.jrae.uuid, self.jane_rae.uuid]),
+                self.jsmith_no_email.uuid: sorted([self.jsmith.uuid, self.john_smith.uuid, self.js_alt.uuid]),
+                self.john_smith.uuid: sorted([self.jsmith.uuid, self.js_alt.uuid, self.jsmith_no_email.uuid]),
+                self.jrae.uuid: sorted([self.jane_rae.uuid, self.jrae_no_name.uuid]),
+                self.jane_rae.uuid: sorted([self.jrae.uuid, self.jrae_no_name.uuid]),
+            }
+        }
+
+        recommendations_expected = {
+            self.js_alt.individual.mk: [self.jsmith.individual.mk,
+                                        self.john_smith.individual.mk,
+                                        self.jsmith_no_email.individual.mk],
+            self.jsmith.individual.mk: [self.john_smith.individual.mk, self.jsmith_no_email.individual.mk],
+            self.jrae_no_name.individual.mk: [self.jrae.individual.mk, self.jane_rae.individual.mk],
+            self.jsmith_no_email.individual.mk: [self.jsmith.individual.mk,
+                                                 self.john_smith.individual.mk,
+                                                 self.js_alt.individual.mk],
+            self.jane_rae.individual.mk: [self.jrae.individual.mk, self.jrae_no_name.individual.mk],
+            self.jrae.individual.mk: [self.jane_rae.individual.mk, self.jrae_no_name.individual.mk]
+        }
+
+        criteria = ['email', 'name', 'username']
+
+        # Identities which don't have the fields in `criteria` or no matches won't be returned
+        job = recommend_matches.delay(ctx,
+                                      None,
+                                      None,
+                                      criteria,
+                                      strict=False)
+        # Preserve job results order for the comparison against the expected results
+        result = job.result
+        for key in result['results']:
+            result['results'][key] = sorted(result['results'][key])
+
+        self.assertDictEqual(result, expected)
+        for mr in MergeRecommendation.objects.all():
+            self.assertIn(mr.individual2.mk, recommendations_expected[mr.individual1.mk])
+
+        # Should have the same result as passing all the uuids
+        all_source_uuids = [self.john_smith.uuid, self.jsmith.uuid,
+                            self.jane_rae.uuid, self.js_alt.uuid, self.jrae.uuid,
+                            self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
+
+        job_uuids = recommend_matches.delay(ctx,
+                                            all_source_uuids,
+                                            None,
+                                            criteria,
+                                            strict=False)
+        result_all_uuids = job_uuids.result
+        for key in result_all_uuids['results']:
+            result_all_uuids['results'][key] = sorted(result_all_uuids['results'][key])
+
+        self.assertDictEqual(result, result_all_uuids)
+
     def test_recommend_matches(self):
         """Check if recommendations are obtained for the specified individuals"""
 
@@ -639,25 +716,27 @@ def test_recommend_matches(self):
         expected = {
             'results': {
                 self.john_smith.uuid: sorted([self.jsmith.uuid]),
-                self.jrae3.uuid: sorted([self.jrae.uuid,
-                                         self.jane_rae.uuid]),
+                self.jrae_no_name.uuid: [],
                 self.jr2.uuid: sorted([self.jrae.uuid,
                                        self.jane_rae.uuid])
             }
         }
+
         individuals_expected = {
             self.john_smith.individual.mk: [self.jsmith.individual.mk],
-            self.jrae3.individual.mk: [self.jrae.individual.mk, self.jane_rae.individual.mk],
+            self.jrae_no_name.individual.mk: [],
             self.jr2.individual.mk: [self.jrae.individual.mk, self.jane_rae.individual.mk]
         }
 
-        source_uuids = [self.john_smith.uuid, self.jrae3.uuid, self.jr2.uuid]
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
         target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
                         self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
                         self.jane_rae.uuid, self.jr2.uuid,
                         self.js_alt.uuid, self.js_alt2.uuid,
                         self.js_alt3.uuid, self.js_alt4.uuid,
-                        self.jrae.uuid, self.jrae2.uuid, self.jrae3.uuid]
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
 
         criteria = ['email', 'name', 'username']
 
@@ -675,6 +754,58 @@ def test_recommend_matches(self):
         for mr in MergeRecommendation.objects.all():
             self.assertIn(mr.individual2.mk, individuals_expected[mr.individual1.mk])
 
+    def test_recommend_matches_no_strict(self):
+        """Check if recommendations are obtained for the specified individuals without strict mode"""
+
+        ctx = SortingHatContext(self.user)
+
+        # Test
+        expected = {
+            'results': {
+                self.john_smith.uuid: sorted([self.jsmith.uuid]),
+                self.jrae_no_name.uuid: sorted([self.jrae.uuid,
+                                                self.jane_rae.uuid]),
+                self.jr2.uuid: sorted([self.jrae.uuid,
+                                       self.jane_rae.uuid,
+                                       self.jrae_no_name.uuid])
+            }
+        }
+
+        individuals_expected = {
+            self.john_smith.individual.mk: [self.jsmith.individual.mk,
+                                            self.jsmith_no_email.individual.mk],
+            self.jrae_no_name.individual.mk: [self.jrae.individual.mk,
+                                              self.jane_rae.individual.mk],
+            self.jr2.individual.mk: [self.jrae.individual.mk,
+                                     self.jane_rae.individual.mk,
+                                     self.jrae_no_name.individual.mk]
+        }
+
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
+        target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
+                        self.jane_rae.uuid, self.jr2.uuid,
+                        self.js_alt.uuid, self.js_alt2.uuid,
+                        self.js_alt3.uuid, self.js_alt4.uuid,
+                        self.jrae.uuid, self.jrae2.uuid, self.jrae_no_name.uuid]
+
+        criteria = ['email', 'name', 'username']
+
+        # Identities which don't have the fields in `criteria` or no matches won't be returned
+        job = recommend_matches.delay(ctx,
+                                      source_uuids,
+                                      target_uuids,
+                                      criteria,
+                                      strict=False)
+        # Preserve job results order for the comparison against the expected results
+        result = job.result
+        for key in result['results']:
+            result['results'][key] = sorted(result['results'][key])
+
+        self.assertDictEqual(result, expected)
+        for mr in MergeRecommendation.objects.all():
+            self.assertIn(mr.individual2.mk, individuals_expected[mr.individual1.mk])
+
     def test_recommend_matches_verbose(self):
         """Check if recommendations are obtained for the specified individuals, at identity level"""
 
@@ -687,7 +818,7 @@ def test_recommend_matches_verbose(self):
                                               self.jsm3.uuid,
                                               self.js2.uuid,
                                               self.js3.uuid]),
-                self.jrae3.uuid: sorted([self.jrae2.uuid]),
+                self.jrae_no_name.uuid: [],
                 self.jr2.uuid: sorted([self.jrae.uuid])
             }
         }
@@ -696,16 +827,19 @@ def test_recommend_matches_verbose(self):
                                             self.jsm3.individual.mk,
                                             self.js2.individual.mk,
                                             self.js3.individual.mk],
-            self.jrae3.individual.mk: [self.jrae2.individual.mk],
+            self.jrae_no_name.individual.mk: [],
             self.jr2.individual.mk: [self.jrae.individual.mk]
         }
 
-        source_uuids = [self.john_smith.uuid, self.jrae3.uuid, self.jr2.uuid]
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
         target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
                         self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
                         self.jane_rae.uuid, self.jr2.uuid,
-                        self.js_alt.uuid, self.js_alt2.uuid, self.js_alt3.uuid, self.js_alt4.uuid,
-                        self.jrae.uuid, self.jrae2.uuid, self.jrae3.uuid]
+                        self.js_alt.uuid, self.js_alt2.uuid,
+                        self.js_alt3.uuid, self.js_alt4.uuid,
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
 
         criteria = ['email', 'name', 'username']
 
@@ -725,6 +859,62 @@ def test_recommend_matches_verbose(self):
         for mr in MergeRecommendation.objects.all():
             self.assertIn(mr.individual2.mk, individuals_expected[mr.individual1.mk])
 
+    def test_recommend_matches_verbose_no_strict(self):
+        """Check if recommendations are obtained for the specified individuals, at identity level,
+        with strict mode disabled"""
+
+        ctx = SortingHatContext(self.user)
+
+        # Test
+        expected = {
+            'results': {
+                self.john_smith.uuid: sorted([self.jsm2.uuid,
+                                              self.jsm3.uuid,
+                                              self.js2.uuid,
+                                              self.js3.uuid]),
+                self.jrae_no_name.uuid: sorted([self.jrae2.uuid]),
+                self.jr2.uuid: sorted([self.jrae.uuid])
+            }
+        }
+        individuals_expected = {
+            self.john_smith.individual.mk: [self.jsm2.individual.mk,
+                                            self.jsm3.individual.mk,
+                                            self.js2.individual.mk,
+                                            self.js3.individual.mk],
+            self.jrae_no_name.individual.mk: [self.jrae.individual.mk,
+                                              self.jane_rae.individual.mk],
+            self.jr2.individual.mk: [self.jrae.individual.mk]
+        }
+
+        source_uuids = [self.john_smith.uuid, self.jrae_no_name.uuid, self.jr2.uuid]
+        target_uuids = [self.john_smith.uuid, self.js2.uuid, self.js3.uuid,
+                        self.js4.uuid,
+                        self.jsmith.uuid, self.jsm2.uuid, self.jsm3.uuid,
+                        self.jane_rae.uuid, self.jr2.uuid,
+                        self.js_alt.uuid, self.js_alt2.uuid,
+                        self.js_alt3.uuid, self.js_alt4.uuid,
+                        self.jrae.uuid, self.jrae2.uuid,
+                        self.jrae_no_name.uuid, self.jsmith_no_email.uuid]
+
+        criteria = ['email', 'name', 'username']
+
+        # Identities which don't have the fields in `criteria` or no matches won't be returned
+        job = recommend_matches.delay(ctx,
+                                      source_uuids,
+                                      target_uuids,
+                                      criteria,
+                                      verbose=True,
+                                      strict=False)
+        # Preserve job results order for the comparison against the expected results
+        result = job.result
+        for key in result['results']:
+            result['results'][key] = sorted(result['results'][key])
+
+        self.assertDictEqual(result, expected)
+
+        for mr in MergeRecommendation.objects.all():
+            self.assertIn(mr.individual2.mk, individuals_expected[mr.individual1.mk])
+
     def test_recommend_source_not_mk(self):
         """Check if recommendations work when the provided uuid is not an Individual's main key"""
 
diff --git a/tests/test_schema.py b/tests/test_schema.py
index c7b8749b..2ca4b074 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -9677,10 +9677,12 @@ class TestUnifyMutation(django.test.TestCase):
     SH_UNIFY = """
         mutation unify($sourceUuids: [String],
                        $targetUuids: [String],
-                       $criteria: [String]) {
+                       $criteria: [String],
+                       $strict: Boolean) {
             unify(sourceUuids: $sourceUuids,
                   targetUuids: $targetUuids,
-                  criteria: $criteria) {
+                  criteria: $criteria,
+                  strict: $strict) {
                 jobId
             }
         }
@@ -9787,7 +9789,8 @@ def test_unify(self, mock_job_id_gen):
                             self.js_alt.uuid, self.js_alt2.uuid,
                             self.js_alt3.uuid, self.js_alt4.uuid,
                             self.jrae.uuid, self.jrae2.uuid, self.jrae3.uuid],
-            'criteria': ['email', 'name', 'username']
+            'criteria': ['email', 'name', 'username'],
+            'strict': False
         }
 
         executed = client.execute(self.SH_UNIFY,
@@ -9868,7 +9871,8 @@ def test_unify_exclude(self, mock_job_id_gen):
                             self.js_alt.uuid, self.js_alt2.uuid,
                             self.js_alt3.uuid, self.js_alt4.uuid,
                             self.jrae.uuid, self.jrae2.uuid, self.jrae3.uuid],
-            'criteria': ['email', 'name', 'username']
+            'criteria': ['email', 'name', 'username'],
+            'strict': False
         }
 
         # Add jsmith@example.com to RecommenderExclusionTerm
@@ -9940,6 +9944,78 @@ def test_unify_exclude(self, mock_job_id_gen):
         id5 = identities[4]
         self.assertEqual(id5, self.jr2)
 
+    @unittest.mock.patch('sortinghat.core.jobs.rq.job.uuid4')
+    def test_unify_strict(self, mock_job_id_gen):
+        """Check if unify is applied for the specified individuals with strict criteria"""
+        mock_job_id_gen.return_value = "1234-5678-90AB-CDEF"
+
+        client = graphene.test.Client(schema)
+
+        params = {
+            'sourceUuids': [self.js_alt.uuid],
+            'targetUuids': [self.jsmith.uuid],
+            'criteria': ['name'],
+            'strict': True
+        }
+
+        executed = client.execute(self.SH_UNIFY,
+                                  context_value=self.context_value,
+                                  variables=params)
+
+        # Check if the job was run
+        job_id = executed['data']['unify']['jobId']
+        self.assertEqual(job_id, "1234-5678-90AB-CDEF")
+
+        # Checking if the identities have not been merged
+        # Individual 1
+        individual_db_1 = Individual.objects.get(mk=self.jsmith.uuid)
+        identities = individual_db_1.identities.all()
+        self.assertEqual(len(identities), 3)
+
+        id1 = identities[0]
+        self.assertEqual(id1, self.jsm2)
+
+        id2 = identities[1]
+        self.assertEqual(id2, self.jsmith)
+
+        id3 = identities[2]
+        self.assertEqual(id3, self.jsm3)
+
+        # Individual 4
+        individual_db_4 = Individual.objects.get(mk=self.js_alt.uuid)
+        identities = individual_db_4.identities.all()
+
+        self.assertEqual(len(identities), 4)
+
+        id1 = identities[0]
+        self.assertEqual(id1, self.js_alt)
+
+        id2 = identities[1]
+        self.assertEqual(id2, self.js_alt4)
+
+        id3 = identities[2]
+        self.assertEqual(id3, self.js_alt3)
+
+        id4 = identities[3]
+        self.assertEqual(id4, self.js_alt2)
+
+        # Check that they are merged if 'strict' is disabled
+        params = {
+            'sourceUuids': [self.js_alt.uuid],
+            'targetUuids': [self.jsmith.uuid],
+            'criteria': ['name'],
+            'strict': False
+        }
+
+        executed = client.execute(self.SH_UNIFY,
+                                  context_value=self.context_value,
+                                  variables=params)
+
+        individual_db_1 = Individual.objects.get(mk=self.js_alt.uuid)
+        identities = individual_db_1.identities.all()
+
+        self.assertEqual(len(identities), 7)
+
     @unittest.mock.patch('sortinghat.core.jobs.rq.job.uuid4')
     def test_unify_source_not_mk(self, mock_job_id_gen):
         """Check if unify works when the provided uuid is not an Individual's main key"""