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

add schema and Python for [un]verifying identities #4031

Merged
merged 1 commit into from
May 11, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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