From 8221b8da8840f64e79ca9cc0e5735992de3d6129 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 11 May 2016 16:58:52 -0400 Subject: [PATCH 1/4] merge branch.sql into schema.sql --- sql/branch.sql | 28 ---------------------------- sql/schema.sql | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 28 deletions(-) delete mode 100644 sql/branch.sql diff --git a/sql/branch.sql b/sql/branch.sql deleted file mode 100644 index d82835a355..0000000000 --- a/sql/branch.sql +++ /dev/null @@ -1,28 +0,0 @@ -CREATE TABLE participant_identities -( id bigserial primary key -, participant_id bigint NOT NULL REFERENCES participants(id) -, country_id bigint NOT NULL REFERENCES countries(id) -, schema_name text NOT NULL -, info bytea NOT NULL -, _info_last_keyed timestamptz NOT NULL DEFAULT now() -, UNIQUE(participant_id, country_id) - ); - - --- fail_if_no_email - -CREATE FUNCTION fail_if_no_email() RETURNS trigger AS $$ - BEGIN - IF (SELECT email_address FROM participants WHERE id=NEW.participant_id) IS NULL THEN - RAISE EXCEPTION - USING ERRCODE=23100 - , MESSAGE='This operation requires a verified participant email address.'; - END IF; - RETURN NEW; - END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER enforce_email_for_participant_identity - BEFORE INSERT ON participant_identities - FOR EACH ROW - EXECUTE PROCEDURE fail_if_no_email(); diff --git a/sql/schema.sql b/sql/schema.sql index 08058e9dec..22d77c77e0 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -741,3 +741,35 @@ CREATE TABLE countries -- http://www.iso.org/iso/country_codes ); \i sql/countries.sql + + +-- https://github.com/gratipay/gratipay.com/pull/4028 + +CREATE TABLE participant_identities +( id bigserial primary key +, participant_id bigint NOT NULL REFERENCES participants(id) +, country_id bigint NOT NULL REFERENCES countries(id) +, schema_name text NOT NULL +, info bytea NOT NULL +, _info_last_keyed timestamptz NOT NULL DEFAULT now() +, UNIQUE(participant_id, country_id) + ); + + +-- fail_if_no_email + +CREATE FUNCTION fail_if_no_email() RETURNS trigger AS $$ + BEGIN + IF (SELECT email_address FROM participants WHERE id=NEW.participant_id) IS NULL THEN + RAISE EXCEPTION + USING ERRCODE=23100 + , MESSAGE='This operation requires a verified participant email address.'; + END IF; + RETURN NEW; + END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_email_for_participant_identity + BEFORE INSERT ON participant_identities + FOR EACH ROW + EXECUTE PROCEDURE fail_if_no_email(); From 3910e3ad8fed9c3d01278bb8bce58023c1f04721 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 11 May 2016 16:59:05 -0400 Subject: [PATCH 2/4] Bump version to 1957 --- www/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/version.txt b/www/version.txt index 39c15c31c4..b22803ae9b 100644 --- a/www/version.txt +++ b/www/version.txt @@ -1 +1 @@ -1956 +1957 From b5baac2bfb1b4557cac9e29a2f8d340dc4ec734c Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 11 May 2016 17:17:24 -0400 Subject: [PATCH 3/4] Bump version to 1958 --- www/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/version.txt b/www/version.txt index b22803ae9b..bba5da15f4 100644 --- a/www/version.txt +++ b/www/version.txt @@ -1 +1 @@ -1957 +1958 From e045ae219f8d4a88947a5bd6d097ee82d704e10b Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 30 Apr 2016 18:20:58 -0400 Subject: [PATCH 4/4] add schema and Python for [un]verifying identities --- .../models/participant/mixins/identity.py | 61 ++++++++++++- sql/branch.sql | 1 + tests/py/test_participant_identities.py | 86 +++++++++++++++++++ 3 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 sql/branch.sql 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):