From ac7d883c00e3bbd39d0a91d61fcb5c922272b42d Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Mon, 4 Oct 2021 16:52:42 +0200
Subject: [PATCH] api: add timestamp parameter to users synchronization api
 (#57564)

---
 src/authentic2/api_views.py | 35 ++++++++++++---
 tests/test_api.py           | 85 +++++++++++++++++++++++++++++++++++++
 2 files changed, 115 insertions(+), 5 deletions(-)

diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index fa27fc56..856969f3 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -23,6 +23,7 @@ import requests
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.hashers import identify_hasher
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import MultipleObjectsReturned
 from django.db import models
 from django.shortcuts import get_object_or_404
@@ -57,7 +58,8 @@ from authentic2.compat.drf import action
 from . import api_mixins, app_settings, decorators, hooks
 from .a2_rbac.models import OrganizationalUnit, Role
 from .a2_rbac.utils import get_default_ou
-from .custom_user.models import User
+from .apps.journal.models import Event
+from .custom_user.models import DeletedUser, User
 from .journal_event_types import UserLogin, UserRegistration
 from .models import Attribute, PasswordReset, Service
 from .passwords import get_password_checker
@@ -795,11 +797,31 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin
     class SynchronizationSerializer(serializers.Serializer):
         known_uuids = serializers.ListField(child=serializers.CharField())
         full_known_users = serializers.BooleanField(required=False)
+        timestamp = serializers.DateTimeField(required=False)
 
-    def check_uuids(self, uuids):
+    def check_uuids(self, uuids, timestamp=None):
         User = get_user_model()
         known_uuids = User.objects.filter(uuid__in=uuids).values_list('uuid', flat=True)
-        return (known_uuids, set(uuids) - set(known_uuids))
+        unknown_uuids = set(uuids) - set(known_uuids)
+        modified_users_uuids = []
+        if timestamp:
+            unknown_uuids = DeletedUser.objects.filter(
+                old_uuid__in=unknown_uuids, deleted__gt=timestamp
+            ).values_list('old_uuid', flat=True)
+            user_ct = ContentType.objects.get_for_model(get_user_model())
+            user_events = Event.objects.filter(
+                reference_ct_ids__contains=[user_ct.id], timestamp__gt=timestamp
+            )
+            for user_event in user_events:
+                for reference in user_event.references:
+                    if (
+                        reference is not None  # xxx None references in journal?!
+                        and ContentType.objects.get_for_model(reference) == user_ct
+                        and reference.uuid not in modified_users_uuids
+                        and reference.uuid not in unknown_uuids
+                    ):
+                        modified_users_uuids.append(reference.uuid)
+        return (known_uuids, unknown_uuids, modified_users_uuids)
 
     def get_users_from_uuids(self, known_uuids):
         User = get_user_model()
@@ -813,12 +835,15 @@ class UsersAPI(api_mixins.GetOrCreateMixinView, HookMixin, ExceptionHandlerMixin
             response = {'result': 0, 'errors': serializer.errors}
             return Response(response, status.HTTP_400_BAD_REQUEST)
         hooks.call_hooks('api_modify_serializer_after_validation', self, serializer)
-        known_uuids, unknown_uuids = self.check_uuids(serializer.validated_data.get('known_uuids', []))
-        full_known_users = serializer.validated_data.get('full_known_users', None)
+        known_uuids = serializer.validated_data.get('known_uuids', [])
+        timestamp = serializer.validated_data.get('timestamp', None)
+        known_uuids, unknown_uuids, modified_users_uuids = self.check_uuids(known_uuids, timestamp)
         data = {
             'result': 1,
             'unknown_uuids': unknown_uuids,
+            'modified_users_uuids': modified_users_uuids,
         }
+        full_known_users = serializer.validated_data.get('full_known_users', None)
         if full_known_users:
             if len(known_uuids) > 1000:
                 known_uuids = known_uuids[:1000]
diff --git a/tests/test_api.py b/tests/test_api.py
index e84da1cb..a7a28297 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1183,6 +1183,91 @@ def test_user_synchronization_full(app, admin):
         }.issubset(set(user_dict.keys()))
 
 
+def test_user_synchronization_timestamp(app, admin):
+    headers = basic_authorization_header(admin)
+    url = reverse('a2-api-users-synchronization')
+    now = datetime.datetime.now()
+
+    ou = get_default_ou()
+    User = get_user_model()
+    users = []
+
+    for i in range(6):
+        users.append(
+            User.objects.create(
+                first_name='john%s' % i,
+                last_name='doe',
+                username='user%s' % i,
+                email='user%s' % i,
+                ou=ou,
+            )
+        )
+
+    for i, event_name in enumerate(
+        [
+            'manager.user.creation',
+            'manager.user.profile.edit',
+            'manager.user.activation',
+            'manager.user.deactivation',
+            'manager.user.password.change.force',
+            'manager.user.password.change.unforce',
+        ]
+    ):
+        event_type = EventType.objects.get_for_name(event_name)
+        Event.objects.create(
+            type=event_type,
+            timestamp=now - datetime.timedelta(days=i, hours=1),
+            references=[users[i]],
+        )
+
+    content = {
+        'known_uuids': [user.uuid for user in users],
+        'timestamp': (now - datetime.timedelta(days=3)).isoformat(),
+    }
+
+    response = app.post(url, params=content, headers=headers)
+
+    for user in users[:3]:
+        assert user.uuid in response.json['modified_users_uuids']
+        assert user.uuid not in response.json['unknown_uuids']
+    for user in users[3:]:
+        assert user.uuid not in response.json['modified_users_uuids']
+        assert user.uuid not in response.json['unknown_uuids']
+
+    for user in users[:3]:
+        user.delete()
+
+    content['timestamp'] = (now - datetime.timedelta(days=7)).isoformat()
+
+    response = app.post(url, params=content, headers=headers)
+
+    for user in users[:3]:
+        assert user.uuid not in response.json['modified_users_uuids']
+        assert user.uuid in response.json['unknown_uuids']
+    for user in users[3:]:
+        assert user.uuid in response.json['modified_users_uuids']
+        assert user.uuid not in response.json['unknown_uuids']
+
+    for user in users[3:]:
+        user.delete()
+
+    response = app.post(url, params=content, headers=headers)
+
+    assert not response.json['modified_users_uuids']
+    for user in users:
+        assert user.uuid in response.json['unknown_uuids']
+
+    for user in users[:3]:
+        content['known_uuids'].remove(user.uuid)
+
+    response = app.post(url, params=content, headers=headers)
+
+    assert not response.json['modified_users_uuids']
+    assert len(response.json['unknown_uuids']) == 3
+    for user in users[3:]:
+        assert user.uuid in response.json['unknown_uuids']
+
+
 def test_api_drf_authentication_class(app, admin, user_ou1, oidc_client):
     url = '/api/users/%s/' % user_ou1.uuid
     # test invalid client
-- 
2.34.1

