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

Commit

Permalink
add schema and Python for [un]verifying identities
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed May 2, 2016
1 parent 7422191 commit 2753b9f
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 3 deletions.
60 changes: 57 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 @@ -94,7 +97,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 @@ -138,14 +141,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 @@ -156,9 +163,56 @@ 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.name
""", (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)
1 change: 1 addition & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ CREATE TABLE participant_identities
, country_id bigint NOT NULL REFERENCES countries(id)
, schema_name text NOT NULL
, info bytea NOT NULL
, is_verified boolean NOT NULL DEFAULT false
, UNIQUE(participant_id, country_id)
);
96 changes: 96 additions & 0 deletions tests/py/test_participant_identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,41 @@ def test_lim_lists_identity_metadata(self):
crusher.store_identity_info(self.USA, 'nothing-enforced', {'name': 'Crusher'})
assert [x.country.code3 for x in crusher.list_identity_metadata()] == ['USA']

def test_lim_lists_the_latest_identity_metadata(self):
crusher = self.make_participant('crusher')
crusher.store_identity_info(self.USA, 'nothing-enforced', {'name': 'Crusher'})
crusher.set_identity_verification(self.USA, True)
crusher.store_identity_info(self.USA, 'nothing-enforced', {'name': 'Bruiser'})
assert [x.is_verified for x in crusher.list_identity_metadata()] == [False]

def test_lim_lists_metadata_for_multiple_identities(self):
crusher = self.make_participant('crusher')
for country in (self.USA, self.TTO):
crusher.store_identity_info(country, 'nothing-enforced', {'name': 'Crusher'})
assert [x.country.code3 for x in crusher.list_identity_metadata()] == ['TTO', 'USA']

def test_lim_lists_latest_metadata_for_multiple_identities(self):
crusher = self.make_participant('crusher')
for country_id in (self.USA, self.TTO):
crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Crusher'})
crusher.set_identity_verification(country_id, True)
crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Bruiser'})
ids = crusher.list_identity_metadata()
assert [x.country.code3 for x in ids] == ['TTO', 'USA']
assert [x.is_verified for x in ids] == [False, False]

def test_lim_can_filter_on_is_verified(self):
crusher = self.make_participant('crusher')
for country_id in (self.USA, self.TTO):
crusher.store_identity_info(country_id, 'nothing-enforced', {'name': 'Crusher'})
crusher.set_identity_verification(self.TTO, True)

ids = crusher.list_identity_metadata(is_verified=True)
assert [x.country.code3 for x in ids] == ['TTO']

ids = crusher.list_identity_metadata(is_verified=False)
assert [x.country.code3 for x in ids] == ['USA']


# sii - store_identity_info

Expand All @@ -107,6 +136,15 @@ def test_sii_overwrites_first_identity(self):
assert [x.country.code3 for x in crusher.list_identity_metadata()] == ['TTO']
assert crusher.retrieve_identity_info(self.TTO)['name'] == 'Bruiser'

def test_sii_resets_is_verified(self):
crusher = self.make_participant('crusher')
crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'})
assert [x.is_verified for x in crusher.list_identity_metadata()] == [False] # starts False
crusher.set_identity_verification(self.TTO, True)
assert [x.is_verified for x in crusher.list_identity_metadata()] == [True] # can be set
crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Bruiser'})
assert [x.is_verified for x in crusher.list_identity_metadata()] == [False] # is reset

def test_sii_validates_identity(self):
crusher = self.make_participant('crusher')
raises( ParticipantIdentityInfoInvalid
Expand Down Expand Up @@ -143,3 +181,61 @@ def test__vi_validates_info(self):
def test__vi_chokes_on_unknown_schema(self):
err = raises(ParticipantIdentitySchemaUnknown, _validate_info, 'floo-floo', {'foo': 'bar'})
assert err.value.message == "unknown schema 'floo-floo'"


# siv - set_identity_verification

def test_is_verified_defaults_to_false(self):
crusher = self.make_participant('crusher')
crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'})
assert [x.is_verified for x in crusher.list_identity_metadata()] == [False]

def test_siv_sets_identity_verification(self):
crusher = self.make_participant('crusher')
crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'})
crusher.set_identity_verification(self.TTO, True)
assert [x.is_verified for x in crusher.list_identity_metadata()] == [True]

def test_siv_can_set_identity_verification_back_to_false(self):
crusher = self.make_participant('crusher')
crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'})
crusher.set_identity_verification(self.TTO, True)
crusher.set_identity_verification(self.TTO, False)
assert [x.is_verified for x in crusher.list_identity_metadata()] == [False]

def test_siv_is_a_noop_when_there_is_no_identity(self):
crusher = self.make_participant('crusher')
assert crusher.set_identity_verification(self.TTO, True) is None
assert crusher.set_identity_verification(self.TTO, False) is None
assert [x.is_verified for x in crusher.list_identity_metadata()] == []

def test_siv_logs_event_when_successful(self):
crusher = self.make_participant('crusher')
iid = crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'})
crusher.set_identity_verification(self.TTO, True) is None
self.assert_events( crusher.id
, [iid, iid]
, [self.TTO, self.TTO]
, ['insert identity', 'verify identity']
)

def test_siv_logs_event_when_set_to_false(self):
crusher = self.make_participant('crusher')
iid = crusher.store_identity_info(self.TTO, 'nothing-enforced', {'name': 'Crusher'})
crusher.set_identity_verification(self.TTO, True) is None
crusher.set_identity_verification(self.TTO, False) is None
self.assert_events( crusher.id
, [iid, iid, iid]
, [self.TTO, self.TTO, self.TTO]
, ['insert identity', 'verify identity', 'unverify identity']
)

def test_siv_still_logs_an_event_when_noop(self):
crusher = self.make_participant('crusher')
crusher.set_identity_verification(self.TTO, True)
crusher.set_identity_verification(self.TTO, False)
self.assert_events( crusher.id
, [None, None]
, [self.TTO, self.TTO]
, ['verify identity', 'unverify identity']
)

0 comments on commit 2753b9f

Please sign in to comment.