diff --git a/gratipay/models/participant/mixins/identity.py b/gratipay/models/participant/mixins/identity.py index 7fd0e7a2cf..cebf271c6b 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) @@ -96,7 +99,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 @@ -140,14 +143,18 @@ 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 :py:meth:`retrieve_identity_info` to get the actual data. @@ -158,12 +165,60 @@ def list_identity_metadata(self): 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.code - """, (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) # Rekeying diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..8f86330e01 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1 @@ +ALTER TABLE participant_identities ADD COLUMN is_verified boolean NOT NULL DEFAULT false; diff --git a/tests/py/test_participant_identities.py b/tests/py/test_participant_identities.py index 2e525df86b..b0322d08af 100644 --- a/tests/py/test_participant_identities.py +++ b/tests/py/test_participant_identities.py @@ -80,11 +80,37 @@ def test_lim_lists_identity_metadata(self): self.crusher.store_identity_info(self.US, 'nothing-enforced', {'name': 'Crusher'}) assert [x.country.code for x in self.crusher.list_identity_metadata()] == ['US'] + def test_lim_lists_the_latest_identity_metadata(self): + self.crusher.store_identity_info(self.US, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(self.US, True) + self.crusher.store_identity_info(self.US, 'nothing-enforced', {'name': 'Bruiser'}) + assert [x.is_verified for x in self.crusher.list_identity_metadata()] == [False] + def test_lim_lists_metadata_for_multiple_identities(self): for country in (self.US, self.TT): self.crusher.store_identity_info(country, 'nothing-enforced', {'name': 'Crusher'}) assert [x.country.code for x in self.crusher.list_identity_metadata()] == ['TT', 'US'] + def test_lim_lists_latest_metadata_for_multiple_identities(self): + for country_id in (self.US, self.TT): + self.crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(country_id, True) + self.crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Bruiser'}) + ids = self.crusher.list_identity_metadata() + assert [x.country.code for x in ids] == ['TT', 'US'] + assert [x.is_verified for x in ids] == [False, False] + + def test_lim_can_filter_on_is_verified(self): + for country_id in (self.US, self.TT): + self.crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(self.TT, True) + + ids = self.crusher.list_identity_metadata(is_verified=True) + assert [x.country.code for x in ids] == ['TT'] + + ids = self.crusher.list_identity_metadata(is_verified=False) + assert [x.country.code for x in ids] == ['US'] + # sii - store_identity_info @@ -103,6 +129,15 @@ def test_sii_overwrites_first_identity(self): assert [x.country.code for x in self.crusher.list_identity_metadata()] == ['TT'] assert self.crusher.retrieve_identity_info(self.TT)['name'] == 'Bruiser' + def test_sii_resets_is_verified(self): + check = lambda: [x.is_verified for x in self.crusher.list_identity_metadata()] + self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Crusher'}) + assert check() == [False] + self.crusher.set_identity_verification(self.TT, True) + assert check() == [True] + self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Bruiser'}) + assert check() == [False] + def test_sii_validates_identity(self): raises( ParticipantIdentityInfoInvalid , self.crusher.store_identity_info @@ -139,6 +174,57 @@ def test__vi_chokes_on_unknown_schema(self): assert err.value.message == "unknown schema 'floo-floo'" + # siv - set_identity_verification + + def test_is_verified_defaults_to_false(self): + self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Crusher'}) + assert [x.is_verified for x in self.crusher.list_identity_metadata()] == [False] + + def test_siv_sets_identity_verification(self): + self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(self.TT, True) + assert [x.is_verified for x in self.crusher.list_identity_metadata()] == [True] + + def test_siv_can_set_identity_verification_back_to_false(self): + self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(self.TT, True) + self.crusher.set_identity_verification(self.TT, False) + assert [x.is_verified for x in self.crusher.list_identity_metadata()] == [False] + + def test_siv_is_a_noop_when_there_is_no_identity(self): + assert self.crusher.set_identity_verification(self.TT, True) is None + assert self.crusher.set_identity_verification(self.TT, False) is None + assert [x.is_verified for x in self.crusher.list_identity_metadata()] == [] + + def test_siv_logs_event_when_successful(self): + iid = self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(self.TT, True) is None + self.assert_events( self.crusher.id + , [iid, iid] + , [self.TT, self.TT] + , ['insert identity', 'verify identity'] + ) + + def test_siv_logs_event_when_set_to_false(self): + iid = self.crusher.store_identity_info(self.TT, 'nothing-enforced', {'name': 'Crusher'}) + self.crusher.set_identity_verification(self.TT, True) is None + self.crusher.set_identity_verification(self.TT, False) is None + self.assert_events( self.crusher.id + , [iid, iid, iid] + , [self.TT, self.TT, self.TT] + , ['insert identity', 'verify identity', 'unverify identity'] + ) + + def test_siv_still_logs_an_event_when_noop(self): + self.crusher.set_identity_verification(self.TT, True) + self.crusher.set_identity_verification(self.TT, False) + self.assert_events( self.crusher.id + , [None, None] + , [self.TT, self.TT] + , ['verify identity', 'unverify identity'] + ) + + # fine - fail_if_no_email def test_fine_fails_if_no_email(self):