From 99cdcd96963828afe117932ff8a97a8db87709a9 Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Tue, 24 May 2022 11:58:10 +0200
Subject: [PATCH 1/3] manage custom attribute's verification sources (#65612)

---
 src/authentic2/custom_user/models.py          | 16 +++-
 .../migrations/0042_auto_20220530_1426.py     | 29 +++++++
 src/authentic2/models.py                      | 82 +++++++++++++++++--
 tests/test_all.py                             |  2 +
 tests/test_user_manager.py                    | 68 +++++++++++++++
 5 files changed, 190 insertions(+), 7 deletions(-)
 create mode 100644 src/authentic2/migrations/0042_auto_20220530_1426.py

diff --git a/src/authentic2/custom_user/models.py b/src/authentic2/custom_user/models.py
index baeade85a..aac78a30d 100644
--- a/src/authentic2/custom_user/models.py
+++ b/src/authentic2/custom_user/models.py
@@ -80,17 +80,29 @@ class Attributes:
         self.__dict__['values'] = owner._a2_attributes_cache
 
     def __setattr__(self, name, value):
+        self._set_sourced_attr(name, value, None)
+
+    def _set_sourced_attr(self, name, value, verification_source=None):
         attribute = get_attributes_map().get(name)
         if not attribute:
             raise AttributeError(name)
 
         with transaction.atomic():
             if attribute.multiple:
-                attribute.set_value(self.owner, value, verified=bool(self.verified))
+                attribute.set_value(
+                    self.owner,
+                    value,
+                    verified=bool(self.verified),
+                    verification_source=verification_source,
+                )
             else:
                 atv = self.values.get(name)
                 self.values[name] = attribute.set_value(
-                    self.owner, value, verified=bool(self.verified), attribute_value=atv
+                    self.owner,
+                    value,
+                    verified=bool(self.verified),
+                    attribute_value=atv,
+                    verification_source=verification_source,
                 )
 
             update_fields = ['modified']
diff --git a/src/authentic2/migrations/0042_auto_20220530_1426.py b/src/authentic2/migrations/0042_auto_20220530_1426.py
new file mode 100644
index 000000000..30eca4cc9
--- /dev/null
+++ b/src/authentic2/migrations/0042_auto_20220530_1426.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2.26 on 2022-05-30 12:26
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('authentic2', '0041_lock'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='attributevalue',
+            name='last_verified_on',
+            field=models.DateTimeField(null=True, verbose_name='last verification timestamp'),
+        ),
+        migrations.AddField(
+            model_name='attributevalue',
+            name='verification_sources',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.CharField(max_length=63),
+                null=True,
+                size=None,
+                verbose_name='verification sources',
+            ),
+        ),
+    ]
diff --git a/src/authentic2/models.py b/src/authentic2/models.py
index 41487c26f..1f4b77ae2 100644
--- a/src/authentic2/models.py
+++ b/src/authentic2/models.py
@@ -24,7 +24,7 @@ import django
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.fields import jsonb
+from django.contrib.postgres.fields import ArrayField, jsonb
 from django.contrib.postgres.indexes import GinIndex
 from django.contrib.postgres.search import SearchVectorField
 from django.core.exceptions import ValidationError
@@ -243,7 +243,37 @@ class Attribute(models.Model):
             except AttributeValue.DoesNotExist:
                 return kind['default']
 
-    def set_value(self, owner, value, verified=False, attribute_value=None):
+    @classmethod
+    def add_verification_source(cls, owner, attribute_name, value, source):
+        '''
+        Add verification source for an existing unitary attribute value,
+        typically the user's first_name and last_name extended attributes.
+        '''
+        attribute = None
+        try:
+            attribute = Attribute.objects.get(name=attribute_name)
+        except Attribute.DoesNotExist:
+            pass
+        if not attribute:
+            # caller should have checked that the attribute object has been created, silently failing.
+            return
+
+        with transaction.atomic():
+            try:
+                av = AttributeValue.objects.with_owner(owner).get(attribute=attribute, content=value)
+            except AttributeValue.DoesNotExist:
+                # caller should have checked that the attribute value is defined, silently failing
+                # to add source.
+                pass
+            else:
+                sources = av.verification_sources or []
+                if source not in sources:
+                    sources.append(source)
+                    av.verification_sources = sources
+                    av.last_verified_on = timezone.now()
+                    av.save()
+
+    def set_value(self, owner, value, verified=False, attribute_value=None, verification_source=None):
         serialize = self.get_kind()['serialize']
         # setting to None is to delete
         if value is None:
@@ -267,10 +297,26 @@ class Attribute(models.Model):
                         attribute=self,
                         multiple=True,
                         content=content,
-                        defaults={'verified': verified},
+                        defaults={
+                            'verified': verified,
+                            'last_verified_on': timezone.now() if verified else None,
+                            'verification_sources': [verification_source]
+                            if verification_source and verified
+                            else [],
+                        },
                     )
                     if not created:
                         av.verified = verified
+                        sources = av.verification_sources or []
+                        if verified:
+                            av.last_verified_on = timezone.now()
+                            if not sources and verification_source:
+                                sources.append(verification_source)
+                                av.verification_sources = sources
+                        elif verification_source in sources:
+                            av.verification_sources.remove(verification_source)
+                            if not av.verification_sources:
+                                av.verified = False
                         av.save()
                     avs.append(av)
                     content_list.append(content)
@@ -292,11 +338,28 @@ class Attribute(models.Model):
                         object_id=owner.pk,
                         attribute=self,
                         multiple=False,
-                        defaults={'content': content, 'verified': verified},
+                        defaults={
+                            'content': content,
+                            'verified': verified,
+                            'last_verified_on': timezone.now() if verified else None,
+                            'verification_sources': [verification_source]
+                            if verification_source and verified
+                            else [],
+                        },
                     )
-                if not created and (av.content != content or av.verified != verified):
+                if not created:
                     av.content = content
                     av.verified = verified
+                    sources = av.verification_sources or []
+                    if verified:
+                        av.last_verified_on = timezone.now()
+                        if not sources and verification_source:
+                            sources.append(verification_source)
+                            av.verification_sources = sources
+                    elif verification_source in sources:
+                        av.verification_sources.remove(verification_source)
+                        if not av.verification_sources:
+                            av.verified = False
                     av.save()
                 return av
 
@@ -332,6 +395,15 @@ class AttributeValue(models.Model):
     content = models.TextField(verbose_name=_('content'), db_index=True)
     search_vector = SearchVectorField(null=True, editable=False)
     verified = models.BooleanField(default=False)
+    verification_sources = ArrayField(
+        verbose_name=_('verification sources'),
+        base_field=models.CharField(max_length=63),
+        null=True,
+    )
+    last_verified_on = models.DateTimeField(
+        verbose_name=_('last verification timestamp'),
+        null=True,
+    )
 
     all_objects = managers.AttributeValueManager()
     objects = managers.AttributeValueManager(attribute__disabled=False)
diff --git a/tests/test_all.py b/tests/test_all.py
index 0fe41bd6d..3c73fffad 100644
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -117,6 +117,8 @@ class SerializerTests(TestCase):
                     'content': '0101010101',
                     'multiple': False,
                     'verified': False,
+                    'verification_sources': None,
+                    'last_verified_on': None,
                     'search_vector': None,
                 },
             },
diff --git a/tests/test_user_manager.py b/tests/test_user_manager.py
index 307284ab8..d5b348550 100644
--- a/tests/test_user_manager.py
+++ b/tests/test_user_manager.py
@@ -1276,3 +1276,71 @@ def test_delete_user(app, superuser, simple_user):
         .count()
         == 1
     )
+
+
+def test_user_attribute_verification_sources(db, app, superuser, simple_user):
+    user = User.objects.create(username='user-foo')
+    attr = Attribute.objects.create(name='attr', label='Attr', kind='string')
+    attr_d = Attribute.objects.create(name='attrd', label='Attrd', kind='string')
+    av = AttributeValue.objects.create(owner=user, attribute=attr, content='attr-value')
+    av_d = AttributeValue.objects.create(owner=user, attribute=attr_d, content='attrd-value')
+
+    assert not av.verified
+    assert not av_d.verified
+    assert av.verification_sources is None
+    assert av_d.verification_sources is None
+    assert av.last_verified_on is None
+    assert av_d.last_verified_on is None
+
+    attr.set_value(user, 'foo', verified=True, verification_source='Foo')
+    attr_d.set_value(user, 'bar', verified=True, verification_source='Bar')
+
+    user_ct = ContentType.objects.get_for_model(User)
+    assert AttributeValue.objects.get(content_type=user_ct, object_id=user.id, attribute=attr).verified
+    assert AttributeValue.objects.get(content_type=user_ct, object_id=user.id, attribute=attr_d).verified
+    assert AttributeValue.objects.get(
+        content_type=user_ct, object_id=user.id, attribute=attr
+    ).last_verified_on
+    assert AttributeValue.objects.get(
+        content_type=user_ct, object_id=user.id, attribute=attr_d
+    ).last_verified_on
+    assert (
+        AttributeValue.objects.get(content_type=user_ct, object_id=user.id, attribute=attr).content == 'foo'
+    )
+    assert (
+        AttributeValue.objects.get(content_type=user_ct, object_id=user.id, attribute=attr_d).content == 'bar'
+    )
+    assert (
+        'Foo'
+        in AttributeValue.objects.get(
+            content_type=user_ct, object_id=user.id, attribute=attr
+        ).verification_sources
+    )
+    assert (
+        'Bar'
+        in AttributeValue.objects.get(
+            content_type=user_ct, object_id=user.id, attribute=attr_d
+        ).verification_sources
+    )
+
+    attr.multiple = True
+    attr.save()
+    attr_d.multiple = True
+    attr_d.save()
+
+    attr.set_value(user, ['fooz'], verified=True, verification_source='Fooz')
+    attr_d.set_value(user, ['baz'], verified=True, verification_source='Baz')
+    assert AttributeValue.objects.filter(content_type=user_ct, object_id=user.id, attribute=attr).count() == 2
+    assert (
+        AttributeValue.objects.filter(content_type=user_ct, object_id=user.id, attribute=attr_d).count() == 2
+    )
+
+    sources = [['Foo'], ['Fooz']]
+    for av in AttributeValue.objects.filter(content_type=user_ct, object_id=user.id, attribute=attr):
+        assert av.verification_sources in sources
+        sources.remove(av.verification_sources)
+
+    sources = [['Bar'], ['Baz']]
+    for av in AttributeValue.objects.filter(content_type=user_ct, object_id=user.id, attribute=attr_d):
+        assert av.verification_sources in sources
+        sources.remove(av.verification_sources)
-- 
2.36.1

