From ea90746143934f477f8b29496d4d3bbc0019ea79 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 11 May 2016 17:49:28 -0400 Subject: [PATCH 1/2] Bump version to 1960 --- www/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/version.txt b/www/version.txt index 4bf4242a74..adf4269e68 100644 --- a/www/version.txt +++ b/www/version.txt @@ -1 +1 @@ -1959 +1960 From 4b656a265280c1ee4925745305c4bc352089118a Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 11 May 2016 17:52:30 -0400 Subject: [PATCH 2/2] implement has_verified_identity Conflicts: sql/branch.sql --- .../models/participant/mixins/identity.py | 26 ++++++ sql/branch.sql | 3 + tests/py/test_participant_identities.py | 93 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 sql/branch.sql diff --git a/gratipay/models/participant/mixins/identity.py b/gratipay/models/participant/mixins/identity.py index d12b658f75..ae9b0268f7 100644 --- a/gratipay/models/participant/mixins/identity.py +++ b/gratipay/models/participant/mixins/identity.py @@ -37,6 +37,12 @@ class IdentityMixin(object): """ + #: ``True`` if the participant has at least one verified identity on file, + #: ``False`` otherwise. This attribute is read-only. It is updated with + #: :py:meth:`set_identity_verification` and :py:meth:`clear_identity`. + + has_verified_identity = False + def store_identity_info(self, country_id, schema_name, info): """Store the participant's national identity information for a given country. @@ -219,6 +225,7 @@ def set_identity_verification(self, country_id, is_verified): ) add_event(cursor, 'participant', payload) + self._update_has_verified_identity(cursor) def clear_identity(self, country_id): @@ -243,6 +250,25 @@ def clear_identity(self, country_id): , action='clear identity' ) add_event(cursor, 'participant', payload) + self._update_has_verified_identity(cursor) + + + def _update_has_verified_identity(self, cursor): + has_verified_identity = cursor.one(""" + + WITH verified_identities AS + ( SELECT * + FROM participant_identities + WHERE participant_id=%(participant_id)s + AND is_verified + ) + UPDATE participants + SET has_verified_identity=(SELECT count(*) FROM verified_identities) > 0 + WHERE id=%(participant_id)s + RETURNING has_verified_identity + + """, dict(participant_id=self.id)) + self.set_attributes(has_verified_identity=has_verified_identity) # Rekeying diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..5016e5099f --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,3 @@ +-- participants.has_verified_identity + +ALTER TABLE participants ADD COLUMN has_verified_identity bool NOT NULL DEFAULT false; diff --git a/tests/py/test_participant_identities.py b/tests/py/test_participant_identities.py index 9081f99a27..28bcbb354a 100644 --- a/tests/py/test_participant_identities.py +++ b/tests/py/test_participant_identities.py @@ -2,11 +2,13 @@ from cryptography.fernet import InvalidToken from gratipay.testing import Harness +from gratipay.models.participant import Participant from gratipay.models.participant.mixins import identity, Identity from gratipay.models.participant.mixins.identity import _validate_info, rekey from gratipay.models.participant.mixins.identity import ParticipantIdentityInfoInvalid from gratipay.models.participant.mixins.identity import ParticipantIdentitySchemaUnknown from gratipay.security.crypto import EncryptingPacker, Fernet +from postgres.orm import ReadOnly from psycopg2 import IntegrityError from pytest import raises @@ -250,6 +252,97 @@ def test_ci_still_logs_an_event_when_noop(self): self.assert_events(self.crusher.id, [None], [self.TT], ['clear identity']) + # hvi - has_verified_identity + + def test_hvi_defaults_to_false(self): + assert self.crusher.has_verified_identity is False + + def test_hvi_is_read_only(self): + with raises(ReadOnly): + self.crusher.has_verified_identity = True + + def test_hvi_becomes_true_when_an_identity_is_verified(self): + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.TTO, True) + assert self.crusher.has_verified_identity + assert Participant.from_username('crusher').has_verified_identity + + def test_hvi_becomes_false_when_the_identity_is_unverified(self): + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.TTO, True) + self.crusher.set_identity_verification(self.TTO, False) + assert not self.crusher.has_verified_identity + assert not Participant.from_username('crusher').has_verified_identity + + def test_hvi_stays_true_when_a_secondary_identity_is_verified(self): + self.crusher.store_identity_info(self.USA, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.USA, True) + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.TTO, True) + assert self.crusher.has_verified_identity + assert Participant.from_username('crusher').has_verified_identity + + def test_hvi_stays_true_when_the_secondary_identity_is_unverified(self): + self.crusher.store_identity_info(self.USA, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.USA, True) + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.TTO, True) + self.crusher.set_identity_verification(self.TTO, False) + assert self.crusher.has_verified_identity + assert Participant.from_username('crusher').has_verified_identity + + def test_hvi_goes_back_to_false_when_both_are_unverified(self): + self.crusher.store_identity_info(self.USA, 'nothing-enforced', {}) + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.TTO, True) + self.crusher.set_identity_verification(self.USA, True) + self.crusher.set_identity_verification(self.TTO, False) + self.crusher.set_identity_verification(self.USA, False) + assert not self.crusher.has_verified_identity + assert not Participant.from_username('crusher').has_verified_identity + + def test_hvi_changes_are_scoped_to_a_participant(self): + self.crusher.store_identity_info(self.USA, 'nothing-enforced', {}) + + bruiser = self.make_participant('bruiser', email_address='bruiser@example.com') + bruiser.store_identity_info(self.USA, 'nothing-enforced', {}) + + self.crusher.set_identity_verification(self.USA, True) + + assert self.crusher.has_verified_identity + assert Participant.from_username('crusher').has_verified_identity + assert not bruiser.has_verified_identity + assert not Participant.from_username('bruiser').has_verified_identity + + def test_hvi_resets_when_identity_is_cleared(self): + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.TTO, True) + self.crusher.clear_identity(self.TTO) + assert not self.crusher.has_verified_identity + assert not Participant.from_username('crusher').has_verified_identity + + def test_hvi_doesnt_reset_when_penultimate_identity_is_cleared(self): + self.crusher.store_identity_info(self.USA, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.USA, True) + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.TTO, True) + self.crusher.set_identity_verification(self.TTO, False) + self.crusher.clear_identity(self.TTO) + assert self.crusher.has_verified_identity + assert Participant.from_username('crusher').has_verified_identity + + def test_hvi_does_reset_when_both_identities_are_cleared(self): + self.crusher.store_identity_info(self.USA, 'nothing-enforced', {}) + self.crusher.store_identity_info(self.TTO, 'nothing-enforced', {}) + self.crusher.set_identity_verification(self.USA, True) + self.crusher.set_identity_verification(self.TTO, True) + self.crusher.set_identity_verification(self.TTO, False) + self.crusher.set_identity_verification(self.USA, False) + self.crusher.clear_identity(self.TTO) + assert not self.crusher.has_verified_identity + assert not Participant.from_username('crusher').has_verified_identity + + # fine - fail_if_no_email def test_fine_fails_if_no_email(self):