diff --git a/releases/unreleased/recommendations-last-modified.yml b/releases/unreleased/recommendations-last-modified.yml new file mode 100644 index 00000000..780173be --- /dev/null +++ b/releases/unreleased/recommendations-last-modified.yml @@ -0,0 +1,9 @@ +--- +title: Recommendations for individuals modified after a given date +category: added +author: Eva Millan +issue: 813 +notes: > + Users can generate merge and affiliation recommendations for + individuals that have been created or modified after a date + specified with the `last_modified` parameter. diff --git a/sortinghat/core/jobs.py b/sortinghat/core/jobs.py index aeb12136..5952680a 100644 --- a/sortinghat/core/jobs.py +++ b/sortinghat/core/jobs.py @@ -41,7 +41,8 @@ AffiliationRecommendation, MergeRecommendation, GenderRecommendation, - ScheduledTask) + ScheduledTask, + MIN_PERIOD_DATE) from .recommendations.engine import RecommendationEngine @@ -126,7 +127,7 @@ def job_in_tenant(job, tenant): @django_rq.job @job_using_tenant -def recommend_affiliations(ctx, uuids=None): +def recommend_affiliations(ctx, uuids=None, last_modified=MIN_PERIOD_DATE): """Generate a list of affiliation recommendations from a set of individuals. This function generates a list of recommendations which include the @@ -140,6 +141,8 @@ def recommend_affiliations(ctx, uuids=None): :param ctx: context where this job is run :param uuids: list of individuals identifiers + :param last_modified: generate recommendations only for individuals modified after + this date :returns: a dictionary with which individuals are recommended to be affiliated to which organization. @@ -148,7 +151,7 @@ def recommend_affiliations(ctx, uuids=None): if not uuids: logger.info(f"Running job {job.id} 'recommend affiliations'; uuids='all'; ...") - uuids = Individual.objects.values_list('mk', flat=True).iterator() + uuids = Individual.objects.filter(last_modified__gte=last_modified).values_list('mk', flat=True).iterator() else: logger.info(f"Running job {job.id} 'recommend affiliations'; uuids={uuids}; ...") uuids = iter(uuids) @@ -196,7 +199,10 @@ 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, strict=True): +def recommend_matches(ctx, source_uuids, + target_uuids, criteria, + exclude=True, verbose=False, + strict=True, last_modified=MIN_PERIOD_DATE): """Generate a list of affiliation recommendations from a set of individuals. This function generates a list of recommendations which include the @@ -222,6 +228,8 @@ def recommend_matches(ctx, source_uuids, target_uuids, criteria, exclude=True, v RecommenderExclusionTerm table. Otherwise, results will not ignore them. :param verbose: if set to `True`, the match results will be composed by individual identities (even belonging to the same individual). + :param last_modified: generate recommendations only for individuals modified after + this date :returns: a dictionary with which individuals are recommended to be merged to which individual or which identities. @@ -243,7 +251,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, strict): + for rec in engine.recommend('matches', source_uuids, target_uuids, criteria, exclude, verbose, strict, last_modified): results[rec.key] = list(rec.options) # Store matches in the database for match in rec.options: @@ -333,7 +341,7 @@ def recommend_gender(ctx, uuids, exclude=True, no_strict_matching=False): @django_rq.job @job_using_tenant -def affiliate(ctx, uuids=None): +def affiliate(ctx, uuids=None, last_modified=MIN_PERIOD_DATE): """Affiliate a set of individuals using recommendations. This function automates the affiliation process obtaining @@ -348,6 +356,8 @@ def affiliate(ctx, uuids=None): :param ctx: context where this job is run :param uuids: list of individuals identifiers + :param last_modified: only affiliate individuals that have been + modified after this date :returns: a dictionary with which individuals were enrolled and the errors found running the job @@ -356,7 +366,7 @@ def affiliate(ctx, uuids=None): if not uuids: logger.info(f"Running job {job.id} 'affiliate'; uuids='all'; ...") - uuids = Individual.objects.values_list('mk', flat=True).iterator() + uuids = Individual.objects.filter(last_modified__gte=last_modified).values_list('mk', flat=True).iterator() else: logger.info(f"Running job {job.id} 'affiliate'; uuids={uuids}; ...") uuids = iter(uuids) @@ -401,7 +411,7 @@ def affiliate(ctx, uuids=None): @django_rq.job @job_using_tenant -def unify(ctx, criteria, source_uuids=None, target_uuids=None, exclude=True, strict=True): +def unify(ctx, criteria, source_uuids=None, target_uuids=None, exclude=True, strict=True, last_modified=MIN_PERIOD_DATE): """Unify a set of individuals by merging them using matching recommendations. This function automates the identities unify process obtaining @@ -425,6 +435,7 @@ def unify(ctx, criteria, source_uuids=None, target_uuids=None, exclude=True, str :param exclude: if set to `True`, the results list will ignore individual identities if any value from the `email`, `name`, or `username` fields are found in the RecommenderExclusionTerm table. Otherwise, results will not ignore them. + :param last_modified: only unify individuals that have been modified after this date :returns: a list with the individuals resulting from merge operations and the errors found running the job @@ -471,7 +482,13 @@ 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, strict=strict): + for rec in engine.recommend('matches', + source_uuids, + target_uuids, + criteria, + exclude=exclude, + strict=strict, + last_modified=last_modified): 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 8244c39a..7f97fc32 100644 --- a/sortinghat/core/recommendations/matching.py +++ b/sortinghat/core/recommendations/matching.py @@ -30,7 +30,7 @@ from ..db import (find_individual_by_uuid) from ..errors import NotFoundError -from ..models import Identity +from ..models import Identity, MIN_PERIOD_DATE from .exclusion import fetch_recommender_exclusion_list @@ -40,7 +40,10 @@ NAME_REGEX = r"^\w+\s\w+" -def recommend_matches(source_uuids, target_uuids, criteria, exclude=True, verbose=False, strict=True): +def recommend_matches(source_uuids, target_uuids, + criteria, exclude=True, + verbose=False, strict=True, + last_modified=MIN_PERIOD_DATE): """Recommend identity matches for a list of individuals. Returns a generator of identity matches recommendations @@ -75,6 +78,8 @@ def recommend_matches(source_uuids, target_uuids, criteria, exclude=True, verbos :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 + :param last_modified: generate recommendations only for individuals modified after + this date :returns: a generator of recommendations """ @@ -106,7 +111,7 @@ def _get_identities(uuid): aliases[uuid] = [identity.uuid for identity in identities] input_set.update(identities) else: - identities = Identity.objects.select_related('individual').all() + identities = Identity.objects.select_related('individual').filter(individual__last_modified__gte=last_modified) input_set.update(identities) for identity in identities: aliases[identity.individual.mk].append(identity.uuid) diff --git a/sortinghat/core/schema.py b/sortinghat/core/schema.py index b22e9c18..b425d97e 100644 --- a/sortinghat/core/schema.py +++ b/sortinghat/core/schema.py @@ -95,7 +95,8 @@ AffiliationRecommendation, MergeRecommendation, GenderRecommendation, - ScheduledTask) + ScheduledTask, + MIN_PERIOD_DATE) from .recommendations.exclusion import delete_recommend_exclusion_term, add_recommender_exclusion_term @@ -1047,17 +1048,18 @@ class RecommendAffiliations(graphene.Mutation): class Arguments: uuids = graphene.List(graphene.String, required=False) + last_modified = graphene.DateTime(required=False) job_id = graphene.Field(lambda: graphene.String) @check_permissions('core.execute_job') @check_auth - def mutate(self, info, uuids=None): + def mutate(self, info, uuids=None, last_modified=MIN_PERIOD_DATE): user = info.context.user tenant = get_db_tenant() ctx = SortingHatContext(user=user, tenant=tenant) - job = enqueue(recommend_affiliations, ctx, uuids, job_timeout=-1) + job = enqueue(recommend_affiliations, ctx, uuids, last_modified, job_timeout=-1) return RecommendAffiliations( job_id=job.id @@ -1074,17 +1076,30 @@ class Arguments: verbose = graphene.Boolean(required=False) exclude = graphene.Boolean(required=False) strict = graphene.Boolean(required=False) + last_modified = graphene.DateTime(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, strict=True): + def mutate(self, info, criteria, + source_uuids=None, target_uuids=None, + exclude=True, verbose=False, strict=True, + last_modified=MIN_PERIOD_DATE): 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, strict, job_timeout=-1) + job = enqueue(recommend_matches, + ctx, + source_uuids, + target_uuids, + criteria, + exclude, + verbose, + strict, + last_modified, + job_timeout=-1) return RecommendMatches( job_id=job.id @@ -1117,17 +1132,18 @@ class Affiliate(graphene.Mutation): class Arguments: uuids = graphene.List(graphene.String, required=False) + last_modified = graphene.DateTime(required=False) job_id = graphene.Field(lambda: graphene.String) @check_permissions('core.execute_job') @check_auth - def mutate(self, info, uuids=None): + def mutate(self, info, uuids=None, last_modified=MIN_PERIOD_DATE): user = info.context.user tenant = get_db_tenant() ctx = SortingHatContext(user=user, tenant=tenant) - job = enqueue(affiliate, ctx, uuids, job_timeout=-1) + job = enqueue(affiliate, ctx, uuids, last_modified, job_timeout=-1) return Affiliate( job_id=job.id @@ -1143,17 +1159,21 @@ class Arguments: criteria = graphene.List(graphene.String) exclude = graphene.Boolean(required=False) strict = graphene.Boolean(required=False) + last_modified = graphene.DateTime(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, strict=True): + def mutate(self, info, criteria, + source_uuids=None, target_uuids=None, + exclude=True, strict=True, + last_modified=MIN_PERIOD_DATE): user = info.context.user tenant = get_db_tenant() ctx = SortingHatContext(user=user, tenant=tenant) - job = enqueue(unify, ctx, criteria, source_uuids, target_uuids, exclude, strict, job_timeout=-1) + job = enqueue(unify, ctx, criteria, source_uuids, target_uuids, exclude, strict, last_modified, job_timeout=-1) return Unify( job_id=job.id diff --git a/tests/rec/test_matches.py b/tests/rec/test_matches.py index 8c23960e..7536d60d 100644 --- a/tests/rec/test_matches.py +++ b/tests/rec/test_matches.py @@ -23,6 +23,8 @@ from django.contrib.auth import get_user_model from django.test import TestCase +from grimoirelab_toolkit.datetime import datetime_utcnow + from sortinghat.core import api from sortinghat.core.context import SortingHatContext from sortinghat.core.recommendations.matching import recommend_matches @@ -239,6 +241,36 @@ def test_recommend_matches_exclude(self): self.assertEqual(len(result), 3) self.assertDictEqual(result, expected) + def test_recommend_matches_last_modified(self): + """Check if recommendations are obtained for individuals modified after a date""" + + timestamp = datetime_utcnow() + + api.add_identity(self.ctx, + username='john_smith', + source='mls', + uuid=self.js_alt.uuid) + # Test + expected = { + self.js_alt.uuid: sorted([self.jsmith.uuid]) + } + + criteria = ['email', 'name', 'username'] + + # Identities which don't have the fields in `criteria` won't be returned + recs = dict(recommend_matches(None, + None, + criteria, + last_modified=timestamp)) + + # Preserve results order for the comparison against the expected results + result = {} + for key in recs: + result[key] = sorted(recs[key]) + + self.assertEqual(len(result), 1) + self.assertDictEqual(result, expected) + def test_recommend_matches_verbose(self): """Check if recommendations are obtained for the specified individuals, at identity level""" diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 80c8e327..3fe9d3ca 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -226,6 +226,35 @@ def test_recommend_affiliations_new_identity(self): self.assertEqual(rec.individual.mk, uuids[0]) self.assertIn(rec.organization.name, ['Bitergia', 'Example']) + def test_recommend_affiliations_last_modified(self): + """Check if recommendations are obtained only for individuals modified after a date""" + ctx = SortingHatContext(self.user) + + # Test + expected = { + 'results': { + 'dc31d2afbee88a6d1dbc1ef05ec827b878067744': ['Bitergia', 'Example'] + } + } + + timestamp = datetime_utcnow() + + api.add_identity(ctx, + source='unknown', + email='jsmith@bitergia.com', + uuid=self.jsmith.uuid) + + job = recommend_affiliations.delay(ctx, uuids=None, last_modified=timestamp) + result = job.result + + self.assertDictEqual(result, expected) + + recommendations = AffiliationRecommendation.objects.filter(individual__mk=self.jsmith.uuid) + self.assertEqual(len(recommendations), 2) + for rec in recommendations: + self.assertEqual(rec.individual.mk, self.jsmith.uuid) + self.assertIn(rec.organization.name, ['Bitergia', 'Example']) + @unittest.mock.patch('sortinghat.core.api.find_individual_by_uuid') def test_not_found_uuid_error(self, mock_find_indv): """Check if the recommendation process returns no results when an individual is not found""" @@ -376,6 +405,57 @@ def test_affiliate(self): enrollments_db = individual_db.enrollments.all() self.assertEqual(len(enrollments_db), 0) + def test_affiliate_last_modified(self): + """Check if only the individuals modified after a date are affiliated""" + + ctx = SortingHatContext(self.user) + + # Test + expected = { + 'results': { + 'dc31d2afbee88a6d1dbc1ef05ec827b878067744': ['Bitergia', 'Example'] + }, + 'errors': [] + } + + timestamp = datetime_utcnow() + + api.add_identity(ctx, + source='unknown', + email='jsmith@bitergia.com', + uuid=self.jsmith.uuid) + + job = affiliate.delay(ctx, uuids=None, last_modified=timestamp) + result = job.result + + self.assertDictEqual(result, expected) + + # Check database objects + + # Only John Smith was affiliated + individual_db = Individual.objects.get(mk=self.jsmith.uuid) + enrollments_db = individual_db.enrollments.all() + self.assertEqual(len(enrollments_db), 2) + + enrollment_db = enrollments_db[0] + self.assertEqual(enrollment_db.group.name, 'Example') + self.assertEqual(enrollment_db.start, datetime.datetime(1900, 1, 1, tzinfo=UTC)) + self.assertEqual(enrollment_db.end, datetime.datetime(2100, 1, 1, tzinfo=UTC)) + + enrollment_db = enrollments_db[1] + self.assertEqual(enrollment_db.group.name, 'Bitergia') + self.assertEqual(enrollment_db.start, datetime.datetime(1900, 1, 1, tzinfo=UTC)) + self.assertEqual(enrollment_db.end, datetime.datetime(2100, 1, 1, tzinfo=UTC)) + + # Jane Roe and John Doe were not affiliated + individual_db = Individual.objects.get(mk=self.jdoe.uuid) + enrollments_db = individual_db.enrollments.all() + self.assertEqual(len(enrollments_db), 0) + + individual_db = Individual.objects.get(mk=self.jroe.uuid) + enrollments_db = individual_db.enrollments.all() + self.assertEqual(len(enrollments_db), 0) + def test_affiliate_uuid(self): """Check if only the given individuals are affiliated""" @@ -1249,6 +1329,42 @@ def test_unify_all_individuals(self): identities = individual_2.identities.all() self.assertEqual(len(identities), 5) + def test_unify_last_modified(self): + """Check if unify is applied only for individuals updated after a given date""" + + ctx = SortingHatContext(self.user) + + # Test + expected = { + 'results': [self.js_alt.uuid], + 'errors': [] + } + + criteria = ['email', 'name', 'username'] + + timestamp = datetime_utcnow() + + api.add_identity(self.ctx, + username='john_smith', + source='mls', + uuid=self.js_alt.uuid) + + # Identities which don't have the fields in `criteria` or no matches won't be returned + job = unify.delay(ctx, + criteria, + None, + None, + exclude=False, + last_modified=timestamp) + + result = job.result + self.assertDictEqual(result, expected) + + # Checking if the identities have been merged + individual_1 = Individual.objects.get(mk=self.js_alt.uuid) + identities = individual_1.identities.all() + self.assertEqual(len(identities), 8) + def test_unify_source_not_mk(self): """Check if unify works when the provided uuid is not an Individual's main key""" diff --git a/tests/test_schema.py b/tests/test_schema.py index 2ca4b074..56768e01 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -9483,8 +9483,8 @@ class TestAffiliateMutation(django.test.TestCase): """Unit tests for mutation to affiliate individuals""" SH_AFFILIATE = """ - mutation affiliate($uuids: [String]) { - affiliate(uuids: $uuids) { + mutation affiliate($uuids: [String], $lastModified: DateTime) { + affiliate(uuids: $uuids, lastModified: $lastModified) { jobId } } @@ -9501,54 +9501,54 @@ def setUp(self): self.context_value = RequestFactory().get(GRAPHQL_ENDPOINT) self.context_value.user = self.user - ctx = SortingHatContext(self.user) + self.ctx = SortingHatContext(self.user) # Organizations and domains - api.add_organization(ctx, 'Example') - api.add_domain(ctx, 'Example', 'example.com', is_top_domain=True) + api.add_organization(self.ctx, 'Example') + api.add_domain(self.ctx, 'Example', 'example.com', is_top_domain=True) - api.add_organization(ctx, 'Example Int.') - api.add_domain(ctx, 'Example Int.', 'u.example.com', + api.add_organization(self.ctx, 'Example Int.') + api.add_domain(self.ctx, 'Example Int.', 'u.example.com', is_top_domain=True) - api.add_domain(ctx, 'Example Int.', 'es.u.example.com') - api.add_domain(ctx, 'Example Int.', 'en.u.example.com') + api.add_domain(self.ctx, 'Example Int.', 'es.u.example.com') + api.add_domain(self.ctx, 'Example Int.', 'en.u.example.com') - api.add_organization(ctx, 'Bitergia') - api.add_domain(ctx, 'Bitergia', 'bitergia.com') - api.add_domain(ctx, 'Bitergia', 'bitergia.org') + api.add_organization(self.ctx, 'Bitergia') + api.add_domain(self.ctx, 'Bitergia', 'bitergia.com') + api.add_domain(self.ctx, 'Bitergia', 'bitergia.org') - api.add_organization(ctx, 'LibreSoft') + api.add_organization(self.ctx, 'LibreSoft') # John Smith identity - self.jsmith = api.add_identity(ctx, + self.jsmith = api.add_identity(self.ctx, source='scm', email='jsmith@us.example.com', name='John Smith', username='jsmith') - api.add_identity(ctx, + api.add_identity(self.ctx, source='scm', email='jsmith@example.net', name='John Smith', uuid=self.jsmith.uuid) # Add John Doe identity - self.jdoe = api.add_identity(ctx, + self.jdoe = api.add_identity(self.ctx, source='unknown', email=None, name='John Doe', username='jdoe') # Jane Roe identity - self.jroe = api.add_identity(ctx, + self.jroe = api.add_identity(self.ctx, source='scm', email='jroe@example.com', name='Jane Roe', username='jroe') - api.add_identity(ctx, + api.add_identity(self.ctx, source='scm', email='jroe@example.com', uuid=self.jroe.uuid) - api.add_identity(ctx, + api.add_identity(self.ctx, source='unknown', email='jroe@bitergia.com', uuid=self.jroe.uuid) @@ -9638,6 +9638,55 @@ def test_affiliate_uuid(self, mock_job_id_gen): enrollments_db = individual_db.enrollments.all() self.assertEqual(len(enrollments_db), 0) + @unittest.mock.patch('sortinghat.core.jobs.rq.job.uuid4') + def test_affiliate_last_modified(self, mock_job_id_gen): + """Check if only the individuals modified after a given date are affiliated""" + + mock_job_id_gen.return_value = "1234-5678-90AB-CDEF" + + client = graphene.test.Client(schema) + + timestamp = datetime_utcnow() + + api.add_identity(self.ctx, + source='alt', + email='jsmith@example.net', + name='John Smith', + uuid=self.jsmith.uuid) + + params = { + 'lastModified': timestamp + } + + executed = client.execute(self.SH_AFFILIATE, + context_value=self.context_value, + variables=params) + + # Check if the job was run and individuals were affiliated + job_id = executed['data']['affiliate']['jobId'] + self.assertEqual(job_id, "1234-5678-90AB-CDEF") + + # Check database objects + + # Only John Smith was affiliated + individual_db = Individual.objects.get(mk=self.jsmith.uuid) + enrollments_db = individual_db.enrollments.all() + self.assertEqual(len(enrollments_db), 1) + + enrollment_db = enrollments_db[0] + self.assertEqual(enrollment_db.group.name, 'Example') + self.assertEqual(enrollment_db.start, datetime.datetime(1900, 1, 1, tzinfo=UTC)) + self.assertEqual(enrollment_db.end, datetime.datetime(2100, 1, 1, tzinfo=UTC)) + + # Jane Roe and John Doe were not affiliated + individual_db = Individual.objects.get(mk=self.jroe.uuid) + enrollments_db = individual_db.enrollments.all() + self.assertEqual(len(enrollments_db), 0) + + individual_db = Individual.objects.get(mk=self.jdoe.uuid) + enrollments_db = individual_db.enrollments.all() + self.assertEqual(len(enrollments_db), 0) + def test_authentication(self): """Check if it fails when a non-authenticated user executes the query""" @@ -9678,11 +9727,13 @@ class TestUnifyMutation(django.test.TestCase): mutation unify($sourceUuids: [String], $targetUuids: [String], $criteria: [String], - $strict: Boolean) { + $strict: Boolean, + $lastModified: DateTime) { unify(sourceUuids: $sourceUuids, targetUuids: $targetUuids, criteria: $criteria, - strict: $strict) { + strict: $strict, + lastModified: $lastModified) { jobId } } @@ -9698,77 +9749,77 @@ def setUp(self): self.context_value = RequestFactory().get(GRAPHQL_ENDPOINT) self.context_value.user = self.user - ctx = SortingHatContext(self.user) + self.ctx = SortingHatContext(self.user) # Individual 1 - self.john_smith = api.add_identity(ctx, + self.john_smith = api.add_identity(self.ctx, email='jsmith@example.com', name='John Smith', source='scm') - self.js2 = api.add_identity(ctx, + self.js2 = api.add_identity(self.ctx, name='John Smith', source='scm', uuid=self.john_smith.uuid) - self.js3 = api.add_identity(ctx, + self.js3 = api.add_identity(self.ctx, username='jsmith', source='scm', uuid=self.john_smith.uuid) # Individual 2 - self.jsmith = api.add_identity(ctx, + self.jsmith = api.add_identity(self.ctx, name='J. Smith', username='john_smith', source='alt') - self.jsm2 = api.add_identity(ctx, + self.jsm2 = api.add_identity(self.ctx, name='John Smith', username='jsmith', source='alt', uuid=self.jsmith.uuid) - self.jsm3 = api.add_identity(ctx, + self.jsm3 = api.add_identity(self.ctx, email='jsmith@example.com', source='alt', uuid=self.jsmith.uuid) # Individual 3 - self.jane_rae = api.add_identity(ctx, + self.jane_rae = api.add_identity(self.ctx, name='Janer Rae', source='mls') - self.jr2 = api.add_identity(ctx, + self.jr2 = api.add_identity(self.ctx, email='jane.rae@example.net', name='Jane Rae Doe', source='mls', uuid=self.jane_rae.uuid) # Individual 4 - self.js_alt = api.add_identity(ctx, + self.js_alt = api.add_identity(self.ctx, name='J. Smith', username='john_smith', source='scm') - self.js_alt2 = api.add_identity(ctx, + self.js_alt2 = api.add_identity(self.ctx, email='JSmith@example.com', username='john_smith', source='mls', uuid=self.js_alt.uuid) - self.js_alt3 = api.add_identity(ctx, + self.js_alt3 = api.add_identity(self.ctx, username='Smith. J', source='mls', uuid=self.js_alt.uuid) - self.js_alt4 = api.add_identity(ctx, + self.js_alt4 = api.add_identity(self.ctx, email='JSmith@example.com', name='Smith. J', source='mls', uuid=self.js_alt.uuid) # Individual 5 - self.jrae = api.add_identity(ctx, + self.jrae = api.add_identity(self.ctx, email='jrae@example.net', name='Jane Rae Doe', source='mls') - self.jrae2 = api.add_identity(ctx, + self.jrae2 = api.add_identity(self.ctx, name='jrae', source='mls', uuid=self.jrae.uuid) - self.jrae3 = api.add_identity(ctx, + self.jrae3 = api.add_identity(self.ctx, name='jrae', source='scm', uuid=self.jrae.uuid) @@ -9845,6 +9896,103 @@ def test_unify(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_last_modified(self, mock_job_id_gen): + """Check if unify is applied only for the individuals modified after a date""" + + mock_job_id_gen.return_value = "1234-5678-90AB-CDEF" + + client = graphene.test.Client(schema) + + timestamp = datetime_utcnow() + + new_identity = api.add_identity(self.ctx, + email='jsmith.alt@example.com', + source='alt', + uuid=self.js_alt.uuid) + + params = { + 'lastModified': timestamp, + 'criteria': ['email', 'name', 'username'] + } + + executed = client.execute(self.SH_UNIFY, + context_value=self.context_value, + variables=params) + + # Check if the job was run and individuals were merged + job_id = executed['data']['unify']['jobId'] + self.assertEqual(job_id, "1234-5678-90AB-CDEF") + + # Checking if the identities have been merged + # Individual 1 + individual_db_1 = Individual.objects.get(mk=self.js_alt.uuid) + identities = individual_db_1.identities.all() + self.assertEqual(len(identities), 8) + + id1 = identities[0] + self.assertEqual(id1, self.jsm2) + + id2 = identities[1] + self.assertEqual(id2, self.js_alt) + + id3 = identities[2] + self.assertEqual(id3, self.js_alt4) + + id4 = identities[3] + self.assertEqual(id4, self.js_alt3) + + id5 = identities[4] + self.assertEqual(id5, self.jsmith) + + id6 = identities[5] + self.assertEqual(id6, self.jsm3) + + id7 = identities[6] + self.assertEqual(id7, new_identity) + + id8 = identities[7] + self.assertEqual(id8, self.js_alt2) + + # Individual 2 + individual_db_2 = Individual.objects.get(mk=self.john_smith.uuid) + identities = individual_db_2.identities.all() + self.assertEqual(len(identities), 3) + + id1 = identities[0] + self.assertEqual(id1, self.john_smith) + + id2 = identities[1] + self.assertEqual(id2, self.js2) + + id3 = identities[2] + self.assertEqual(id3, self.js3) + + # Individual 3 + individual_db_3 = Individual.objects.get(mk=self.jrae.uuid) + identities = individual_db_3.identities.all() + self.assertEqual(len(identities), 3) + + id1 = identities[0] + self.assertEqual(id1, self.jrae2) + + id2 = identities[1] + self.assertEqual(id2, self.jrae3) + + id3 = identities[2] + self.assertEqual(id3, self.jrae) + + # Individual 4 + individual_db_4 = Individual.objects.get(mk=self.jane_rae.uuid) + identities = individual_db_4.identities.all() + self.assertEqual(len(identities), 2) + + id1 = identities[0] + self.assertEqual(id1, self.jane_rae) + + id2 = identities[1] + self.assertEqual(id2, self.jr2) + @unittest.mock.patch('sortinghat.core.jobs.rq.job.uuid4') def test_unify_exclude(self, mock_job_id_gen): """Check if unify is applied for the specified individuals"""