From ebf81b496e58fbafea4530feab877038953e3c93 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 30 Apr 2016 18:20:58 -0400 Subject: [PATCH] add schema and Python for [un]verifying identities --- .../models/participant/mixins/identity.py | 65 +++++++++++-- sql/branch.sql | 1 + tests/py/test_participant_identities.py | 96 +++++++++++++++++++ 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/gratipay/models/participant/mixins/identity.py b/gratipay/models/participant/mixins/identity.py index 22c379a709..b1379dfd9e 100644 --- a/gratipay/models/participant/mixins/identity.py +++ b/gratipay/models/participant/mixins/identity.py @@ -59,6 +59,9 @@ def store_identity_info(self, country_id, schema_name, info): field is opaque in the database layer). For now there is only one available schema: ``nothing-enforced``. + New participant identity information for a given country always starts + out unverified. + """ _validate_info(schema_name, info) info = self.encrypting_packer.pack(info) @@ -94,7 +97,7 @@ def _add_event(action): identity_id, old_schema_name = cursor.one(""" UPDATE participant_identities - SET schema_name=%(schema_name)s, info=%(info)s + SET schema_name=%(schema_name)s, info=%(info)s, is_verified=false WHERE participant_id=%(participant_id)s AND country_id=%(country_id)s RETURNING id, schema_name @@ -138,28 +141,78 @@ def retrieve_identity_info(self, country_id): return info - def list_identity_metadata(self): + def list_identity_metadata(self, is_verified=None): """Return a list of identity metadata records, sorted by country name. + :param bool is_verified: filter records by whether or not the + information is verified; ``None`` returns both + Identity metadata records have the following attributes: :var int id: the record's primary key in the ``participant_identities`` table :var Country country: the country this identity applies to :var unicode schema_name: the name of the schema that the data itself conforms to + :var bool is_verified: whether or not the information has been verified The national identity information itself is not included, only - metadata. Use this method together with - :py:meth:`retrieve_identity_info` to get the actual data. + metadata. Use :py:meth:`retrieve_identity_info` to get the actual data. """ - return self.db.all( """ + return self.db.all(""" SELECT pi.id , c.*::countries AS country , schema_name + , is_verified FROM participant_identities pi JOIN countries c ON pi.country_id=c.id WHERE participant_id=%s + AND COALESCE(is_verified = %s, true) ORDER BY c.name - """, (self.id,)) + """, (self.id, is_verified)) + # The COALESCE lets us pass in is_verified instead of concatenating SQL. + # (recall that `* = null` evaluates to null, while `true = false` is false. + + + def set_identity_verification(self, country_id, is_verified): + """Set the verification status of the participant's national identity for a given country. + + :param int country_id: an ``id`` from the ``countries`` table + :param bool is_verified: whether the information has been verified or not + + This is a no-op if the participant has no identity on file for the + given ``country_id``. + + """ + is_verified = bool(is_verified) + action = 'verify' if is_verified else 'unverify' + + with self.db.get_cursor() as cursor: + old = cursor.one(""" + + SELECT id, is_verified + FROM participant_identities + WHERE participant_id=%(participant_id)s + AND country_id=%(country_id)s + + """, dict(locals(), participant_id=self.id)) + + cursor.run(""" + + UPDATE participant_identities + SET is_verified=%(is_verified)s + WHERE participant_id=%(participant_id)s + AND country_id=%(country_id)s + + """, dict(locals(), participant_id=self.id)) + + payload = dict( id=self.id + , identity_id=old.id if old else None + , country_id=country_id + , new_value=is_verified + , old_value=old.is_verified if old else None + , action=action + ' identity' + ) + + add_event(cursor, 'participant', payload) diff --git a/sql/branch.sql b/sql/branch.sql index f50abdad88..77a948ac66 100644 --- a/sql/branch.sql +++ b/sql/branch.sql @@ -13,5 +13,6 @@ CREATE TABLE participant_identities , country_id bigint NOT NULL REFERENCES countries(id) , schema_name text NOT NULL , info bytea NOT NULL +, is_verified boolean NOT NULL DEFAULT false , UNIQUE(participant_id, country_id) ); diff --git a/tests/py/test_participant_identities.py b/tests/py/test_participant_identities.py index c1f251002b..4c01d79f9a 100644 --- a/tests/py/test_participant_identities.py +++ b/tests/py/test_participant_identities.py @@ -80,12 +80,41 @@ def test_lim_lists_identity_metadata(self): crusher.store_identity_info(self.USA, 'nothing-enforced', {'name': 'Crusher'}) assert [x.country.code3 for x in crusher.list_identity_metadata()] == ['USA'] + def test_lim_lists_the_latest_identity_metadata(self): + crusher = self.make_participant('crusher') + crusher.store_identity_info(self.USA, 'nothing-enforced', {'name': 'Crusher'}) + crusher.set_identity_verification(self.USA, True) + crusher.store_identity_info(self.USA, 'nothing-enforced', {'name': 'Bruiser'}) + assert [x.is_verified for x in crusher.list_identity_metadata()] == [False] + def test_lim_lists_metadata_for_multiple_identities(self): crusher = self.make_participant('crusher') for country in (self.USA, self.TTO): crusher.store_identity_info(country, 'nothing-enforced', {'name': 'Crusher'}) assert [x.country.code3 for x in crusher.list_identity_metadata()] == ['TTO', 'USA'] + def test_lim_lists_latest_metadata_for_multiple_identities(self): + crusher = self.make_participant('crusher') + for country_id in (self.USA, self.TTO): + crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Crusher'}) + crusher.set_identity_verification(country_id, True) + crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Bruiser'}) + ids = crusher.list_identity_metadata() + assert [x.country.code3 for x in ids] == ['TTO', 'USA'] + assert [x.is_verified for x in ids] == [False, False] + + def test_lim_can_filter_on_is_verified(self): + crusher = self.make_participant('crusher') + for country_id in (self.USA, self.TTO): + crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Crusher'}) + crusher.set_identity_verification(self.TTO, True) + + ids = crusher.list_identity_metadata(is_verified=True) + assert [x.country.code3 for x in ids] == ['TTO'] + + ids = crusher.list_identity_metadata(is_verified=False) + assert [x.country.code3 for x in ids] == ['USA'] + # sii - store_identity_info @@ -107,6 +136,15 @@ def test_sii_overwrites_first_identity(self): assert [x.country.code3 for x in crusher.list_identity_metadata()] == ['TTO'] assert crusher.retrieve_identity_info(self.TTO)['name'] == 'Bruiser' + def test_sii_resets_is_verified(self): + crusher = self.make_participant('crusher') + crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'}) + assert [x.is_verified for x in crusher.list_identity_metadata()] == [False] # starts False + crusher.set_identity_verification(self.TTO, True) + assert [x.is_verified for x in crusher.list_identity_metadata()] == [True] # can be set + crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Bruiser'}) + assert [x.is_verified for x in crusher.list_identity_metadata()] == [False] # is reset + def test_sii_validates_identity(self): crusher = self.make_participant('crusher') raises( ParticipantIdentityInfoInvalid @@ -143,3 +181,61 @@ def test__vi_validates_info(self): def test__vi_chokes_on_unknown_schema(self): err = raises(ParticipantIdentitySchemaUnknown, _validate_info, 'floo-floo', {'foo': 'bar'}) assert err.value.message == "unknown schema 'floo-floo'" + + + # siv - set_identity_verification + + def test_is_verified_defaults_to_false(self): + crusher = self.make_participant('crusher') + crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'}) + assert [x.is_verified for x in crusher.list_identity_metadata()] == [False] + + def test_siv_sets_identity_verification(self): + crusher = self.make_participant('crusher') + crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'}) + crusher.set_identity_verification(self.TTO, True) + assert [x.is_verified for x in crusher.list_identity_metadata()] == [True] + + def test_siv_can_set_identity_verification_back_to_false(self): + crusher = self.make_participant('crusher') + crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'}) + crusher.set_identity_verification(self.TTO, True) + crusher.set_identity_verification(self.TTO, False) + assert [x.is_verified for x in crusher.list_identity_metadata()] == [False] + + def test_siv_is_a_noop_when_there_is_no_identity(self): + crusher = self.make_participant('crusher') + assert crusher.set_identity_verification(self.TTO, True) is None + assert crusher.set_identity_verification(self.TTO, False) is None + assert [x.is_verified for x in crusher.list_identity_metadata()] == [] + + def test_siv_logs_event_when_successful(self): + crusher = self.make_participant('crusher') + iid = crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'}) + crusher.set_identity_verification(self.TTO, True) is None + self.assert_events( crusher.id + , [iid, iid] + , [self.TTO, self.TTO] + , ['insert identity', 'verify identity'] + ) + + def test_siv_logs_event_when_set_to_false(self): + crusher = self.make_participant('crusher') + iid = crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'}) + crusher.set_identity_verification(self.TTO, True) is None + crusher.set_identity_verification(self.TTO, False) is None + self.assert_events( crusher.id + , [iid, iid, iid] + , [self.TTO, self.TTO, self.TTO] + , ['insert identity', 'verify identity', 'unverify identity'] + ) + + def test_siv_still_logs_an_event_when_noop(self): + crusher = self.make_participant('crusher') + crusher.set_identity_verification(self.TTO, True) + crusher.set_identity_verification(self.TTO, False) + self.assert_events( crusher.id + , [None, None] + , [self.TTO, self.TTO] + , ['verify identity', 'unverify identity'] + )