Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Commit

Permalink
Merge pull request #4031 from gratipay/verifying-identities
Browse files Browse the repository at this point in the history
add schema and Python for [un]verifying identities
  • Loading branch information
chadwhitacre committed May 11, 2016
2 parents b5baac2 + e045ae2 commit 8635d4c
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 3 deletions.
61 changes: 58 additions & 3 deletions gratipay/models/participant/mixins/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE participant_identities ADD COLUMN is_verified boolean NOT NULL DEFAULT false;
86 changes: 86 additions & 0 deletions tests/py/test_participant_identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 8635d4c

Please sign in to comment.