diff --git a/gratipay/models/country.py b/gratipay/models/country.py new file mode 100644 index 0000000000..3912b85e20 --- /dev/null +++ b/gratipay/models/country.py @@ -0,0 +1,7 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +from postgres.orm import Model + + +class Country(Model): + typname = 'countries' diff --git a/gratipay/models/participant/__init__.py b/gratipay/models/participant/__init__.py index 440a1a9a03..f184a95ada 100644 --- a/gratipay/models/participant/__init__.py +++ b/gratipay/models/participant/__init__.py @@ -2,6 +2,7 @@ """ from __future__ import print_function, unicode_literals +import json from datetime import timedelta from decimal import Decimal import pickle @@ -1072,6 +1073,54 @@ def update_is_free_rider(self, is_free_rider, cursor=None): self.set_attributes(is_free_rider=is_free_rider) + # Identities + # ========== + + def save_identity(self, country, info=None, is_verified=False): + """ + """ + if info is None: + assert is_verified is None + else: + info = json.dumps(info) + participant_id = self.id + self.db.run(""" + + INSERT INTO participant_identities + (ctime, participant, country, info, is_verified) + VALUES ( COALESCE (( SELECT ctime + FROM participant_identities + WHERE participant=%(participant_id)s + AND country=(SELECT id FROM countries WHERE code3=%(country)s) + LIMIT 1 + ), CURRENT_TIMESTAMP) + , %(participant_id)s + , (SELECT id FROM countries WHERE code3=%(country)s) + , %(info)s + , %(is_verified)s + ) + + """, locals()) + + + def get_identities(self): + """ + """ + return self.db.all( """ + + SELECT ctime + , mtime + , c.*::countries AS country + , info + , is_verified + FROM current_participant_identities + JOIN countries c ON country=c.id + WHERE participant=%s + ORDER BY c.name + + """, (self.id,)) + + # Random Junk # =========== diff --git a/gratipay/testing/__init__.py b/gratipay/testing/__init__.py index 257f9095f4..bb39e8f332 100644 --- a/gratipay/testing/__init__.py +++ b/gratipay/testing/__init__.py @@ -65,7 +65,7 @@ class Harness(unittest.TestCase): db = client.website.db platforms = client.website.platforms tablenames = db.all("SELECT tablename FROM pg_tables " - "WHERE schemaname='public'") + "WHERE schemaname='public' AND tablename != 'countries'") seq = itertools.count(0) diff --git a/gratipay/wireup.py b/gratipay/wireup.py index b944633db3..1d4628dd1e 100644 --- a/gratipay/wireup.py +++ b/gratipay/wireup.py @@ -31,6 +31,7 @@ from gratipay.elsewhere.venmo import Venmo from gratipay.models.account_elsewhere import AccountElsewhere from gratipay.models.community import Community +from gratipay.models.country import Country from gratipay.models.exchange_route import ExchangeRoute from gratipay.models.participant import Participant from gratipay.models.team import Team @@ -53,7 +54,7 @@ def db(env): maxconn = env.database_maxconn db = GratipayDB(dburl, maxconn=maxconn) - for model in (AccountElsewhere, Community, ExchangeRoute, Participant, Team): + for model in (AccountElsewhere, Community, Country, ExchangeRoute, Participant, Team): db.register_model(model) gratipay.billing.payday.Payday.db = db diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..0f0982a922 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,64 @@ +CREATE TABLE countries -- http://www.iso.org/iso/country_codes +( id bigserial primary key +, code2 text NOT NULL UNIQUE +, code3 text NOT NULL UNIQUE +, name text NOT NULL UNIQUE + ); + +\i sql/countries.sql + +CREATE TABLE participant_identities +( id bigserial primary key +, ctime timestamp with time zone NOT NULL +, mtime timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +, participant bigint NOT NULL REFERENCES participants(id) +, country bigint NOT NULL REFERENCES countries(id) +, info json +, is_verified boolean DEFAULT FALSE + ); + + +-- ctime mtime participant country info is_verified +-- 2014-08-10 2014-08-10 2388765 8767 {"address": "foo"} false +-- +-- ctime mtime participant country info is_verified +-- 2014-08-10 2014-08-12 2388765 8767 {"address": "bar"} true +-- 2014-08-10 2014-08-10 2388765 8767 {"address": "foo"} false +-- +-- ctime mtime participant country info is_verified +-- 2014-08-10 2014-08-13 2388765 8767 {"address": "bar"} false +-- 2014-08-10 2014-08-12 2388765 8767 {"address": "bar"} true +-- 2014-08-10 2014-08-10 2388765 8767 {"address": "foo"} true +-- +-- ctime mtime participant country info is_verified +-- 2014-08-10 2014-08-14 2388765 8767 {"address": "bar"} true +-- 2014-08-10 2014-08-13 2388765 8767 {"address": "bar"} false +-- 2014-08-10 2014-08-12 2388765 8767 {"address": "foo"} true +-- 2014-08-10 2014-08-10 2388765 8767 {"address": "foo"} false +-- +-- # user removes their registration +-- +-- ctime mtime participant country info is_verified +-- 2014-08-10 2014-08-15 2388765 8767 NULL NULL +-- 2014-08-10 2014-08-14 2388765 8767 {"address": "bar"} true +-- 2014-08-10 2014-08-13 2388765 8767 {"address": "bar"} false +-- 2014-08-10 2014-08-12 2388765 8767 {"address": "foo"} true +-- 2014-08-10 2014-08-10 2388765 8767 {"address": "foo"} false +-- +-- # user reregisters for the same country - same ctime! +-- +-- ctime mtime participant country info is_verified +-- 2014-08-10 2014-08-16 2388765 8767 {"address": "baz"} false +-- 2014-08-10 2014-08-15 2388765 8767 NULL NULL +-- 2014-08-10 2014-08-14 2388765 8767 {"address": "bar"} true +-- 2014-08-10 2014-08-13 2388765 8767 {"address": "bar"} false +-- 2014-08-10 2014-08-12 2388765 8767 {"address": "foo"} true +-- 2014-08-10 2014-08-10 2388765 8767 {"address": "foo"} false + + +CREATE VIEW current_participant_identities AS + SELECT * FROM ( + SELECT DISTINCT ON (participant, country) * + FROM participant_identities + ORDER BY participant, country, mtime DESC + ) AS _ WHERE info IS NOT NULL; diff --git a/sql/countries.sql b/sql/countries.sql new file mode 100644 index 0000000000..047522c519 --- /dev/null +++ b/sql/countries.sql @@ -0,0 +1,253 @@ +-- scraped from https://www.iso.org/obp/ui/#search +INSERT INTO countries + (code2, code3, name) + VALUES ('AD', 'AND', 'Andorra') + , ('AE', 'ARE', 'United Arab Emirates (the)') + , ('AF', 'AFG', 'Afghanistan') + , ('AG', 'ATG', 'Antigua and Barbuda') + , ('AI', 'AIA', 'Anguilla') + , ('AL', 'ALB', 'Albania') + , ('AM', 'ARM', 'Armenia') + , ('AO', 'AGO', 'Angola') + , ('AQ', 'ATA', 'Antarctica') + , ('AR', 'ARG', 'Argentina') + , ('AS', 'ASM', 'American Samoa') + , ('AT', 'AUT', 'Austria') + , ('AU', 'AUS', 'Australia') + , ('AW', 'ABW', 'Aruba') + , ('AX', 'ALA', 'Åland Islands') + , ('AZ', 'AZE', 'Azerbaijan') + , ('BA', 'BIH', 'Bosnia and Herzegovina') + , ('BB', 'BRB', 'Barbados') + , ('BD', 'BGD', 'Bangladesh') + , ('BE', 'BEL', 'Belgium') + , ('BF', 'BFA', 'Burkina Faso') + , ('BG', 'BGR', 'Bulgaria') + , ('BH', 'BHR', 'Bahrain') + , ('BI', 'BDI', 'Burundi') + , ('BJ', 'BEN', 'Benin') + , ('BL', 'BLM', 'Saint Barthélemy') + , ('BM', 'BMU', 'Bermuda') + , ('BN', 'BRN', 'Brunei Darussalam') + , ('BO', 'BOL', 'Bolivia (Plurinational State of)') + , ('BQ', 'BES', 'Bonaire, Sint Eustatius and Saba') + , ('BR', 'BRA', 'Brazil') + , ('BS', 'BHS', 'Bahamas (the)') + , ('BT', 'BTN', 'Bhutan') + , ('BV', 'BVT', 'Bouvet Island') + , ('BW', 'BWA', 'Botswana') + , ('BY', 'BLR', 'Belarus') + , ('BZ', 'BLZ', 'Belize') + , ('CA', 'CAN', 'Canada') + , ('CC', 'CCK', 'Cocos (Keeling) Islands (the)') + , ('CD', 'COD', 'Congo (the Democratic Republic of the)') + , ('CF', 'CAF', 'Central African Republic (the)') + , ('CG', 'COG', 'Congo (the)') + , ('CH', 'CHE', 'Switzerland') + , ('CI', 'CIV', 'Côte dIvoire') + , ('CK', 'COK', 'Cook Islands (the)') + , ('CL', 'CHL', 'Chile') + , ('CM', 'CMR', 'Cameroon') + , ('CN', 'CHN', 'China') + , ('CO', 'COL', 'Colombia') + , ('CR', 'CRI', 'Costa Rica') + , ('CU', 'CUB', 'Cuba') + , ('CV', 'CPV', 'Cabo Verde') + , ('CW', 'CUW', 'Curaçao') + , ('CX', 'CXR', 'Christmas Island') + , ('CY', 'CYP', 'Cyprus') + , ('CZ', 'CZE', 'Czech Republic (the)') + , ('DE', 'DEU', 'Germany') + , ('DJ', 'DJI', 'Djibouti') + , ('DK', 'DNK', 'Denmark') + , ('DM', 'DMA', 'Dominica') + , ('DO', 'DOM', 'Dominican Republic (the)') + , ('DZ', 'DZA', 'Algeria') + , ('EC', 'ECU', 'Ecuador') + , ('EE', 'EST', 'Estonia') + , ('EG', 'EGY', 'Egypt') + , ('EH', 'ESH', 'Western Sahara*') + , ('ER', 'ERI', 'Eritrea') + , ('ES', 'ESP', 'Spain') + , ('ET', 'ETH', 'Ethiopia') + , ('FI', 'FIN', 'Finland') + , ('FJ', 'FJI', 'Fiji') + , ('FK', 'FLK', 'Falkland Islands (the) [Malvinas]') + , ('FM', 'FSM', 'Micronesia (Federated States of)') + , ('FO', 'FRO', 'Faroe Islands (the)') + , ('FR', 'FRA', 'France') + , ('GA', 'GAB', 'Gabon') + , ('GB', 'GBR', 'United Kingdom of Great Britain and Northern Ireland (the)') + , ('GD', 'GRD', 'Grenada') + , ('GE', 'GEO', 'Georgia') + , ('GF', 'GUF', 'French Guiana') + , ('GG', 'GGY', 'Guernsey') + , ('GH', 'GHA', 'Ghana') + , ('GI', 'GIB', 'Gibraltar') + , ('GL', 'GRL', 'Greenland') + , ('GM', 'GMB', 'Gambia (the)') + , ('GN', 'GIN', 'Guinea') + , ('GP', 'GLP', 'Guadeloupe') + , ('GQ', 'GNQ', 'Equatorial Guinea') + , ('GR', 'GRC', 'Greece') + , ('GS', 'SGS', 'South Georgia and the South Sandwich Islands') + , ('GT', 'GTM', 'Guatemala') + , ('GU', 'GUM', 'Guam') + , ('GW', 'GNB', 'Guinea-Bissau') + , ('GY', 'GUY', 'Guyana') + , ('HK', 'HKG', 'Hong Kong') + , ('HM', 'HMD', 'Heard Island and McDonald Islands') + , ('HN', 'HND', 'Honduras') + , ('HR', 'HRV', 'Croatia') + , ('HT', 'HTI', 'Haiti') + , ('HU', 'HUN', 'Hungary') + , ('ID', 'IDN', 'Indonesia') + , ('IE', 'IRL', 'Ireland') + , ('IL', 'ISR', 'Israel') + , ('IM', 'IMN', 'Isle of Man') + , ('IN', 'IND', 'India') + , ('IO', 'IOT', 'British Indian Ocean Territory (the)') + , ('IQ', 'IRQ', 'Iraq') + , ('IR', 'IRN', 'Iran (Islamic Republic of)') + , ('IS', 'ISL', 'Iceland') + , ('IT', 'ITA', 'Italy') + , ('JE', 'JEY', 'Jersey') + , ('JM', 'JAM', 'Jamaica') + , ('JO', 'JOR', 'Jordan') + , ('JP', 'JPN', 'Japan') + , ('KE', 'KEN', 'Kenya') + , ('KG', 'KGZ', 'Kyrgyzstan') + , ('KH', 'KHM', 'Cambodia') + , ('KI', 'KIR', 'Kiribati') + , ('KM', 'COM', 'Comoros (the)') + , ('KN', 'KNA', 'Saint Kitts and Nevis') + , ('KP', 'PRK', 'Korea (the Democratic Peoples Republic of)') + , ('KR', 'KOR', 'Korea (the Republic of)') + , ('KW', 'KWT', 'Kuwait') + , ('KY', 'CYM', 'Cayman Islands (the)') + , ('KZ', 'KAZ', 'Kazakhstan') + , ('LA', 'LAO', 'Lao Peoples Democratic Republic (the)') + , ('LB', 'LBN', 'Lebanon') + , ('LC', 'LCA', 'Saint Lucia') + , ('LI', 'LIE', 'Liechtenstein') + , ('LK', 'LKA', 'Sri Lanka') + , ('LR', 'LBR', 'Liberia') + , ('LS', 'LSO', 'Lesotho') + , ('LT', 'LTU', 'Lithuania') + , ('LU', 'LUX', 'Luxembourg') + , ('LV', 'LVA', 'Latvia') + , ('LY', 'LBY', 'Libya') + , ('MA', 'MAR', 'Morocco') + , ('MC', 'MCO', 'Monaco') + , ('MD', 'MDA', 'Moldova (the Republic of)') + , ('ME', 'MNE', 'Montenegro') + , ('MF', 'MAF', 'Saint Martin (French part)') + , ('MG', 'MDG', 'Madagascar') + , ('MH', 'MHL', 'Marshall Islands (the)') + , ('MK', 'MKD', 'Macedonia (the former Yugoslav Republic of)') + , ('ML', 'MLI', 'Mali') + , ('MM', 'MMR', 'Myanmar') + , ('MN', 'MNG', 'Mongolia') + , ('MO', 'MAC', 'Macao') + , ('MP', 'MNP', 'Northern Mariana Islands (the)') + , ('MQ', 'MTQ', 'Martinique') + , ('MR', 'MRT', 'Mauritania') + , ('MS', 'MSR', 'Montserrat') + , ('MT', 'MLT', 'Malta') + , ('MU', 'MUS', 'Mauritius') + , ('MV', 'MDV', 'Maldives') + , ('MW', 'MWI', 'Malawi') + , ('MX', 'MEX', 'Mexico') + , ('MY', 'MYS', 'Malaysia') + , ('MZ', 'MOZ', 'Mozambique') + , ('NA', 'NAM', 'Namibia') + , ('NC', 'NCL', 'New Caledonia') + , ('NE', 'NER', 'Niger (the)') + , ('NF', 'NFK', 'Norfolk Island') + , ('NG', 'NGA', 'Nigeria') + , ('NI', 'NIC', 'Nicaragua') + , ('NL', 'NLD', 'Netherlands (the)') + , ('NO', 'NOR', 'Norway') + , ('NP', 'NPL', 'Nepal') + , ('NR', 'NRU', 'Nauru') + , ('NU', 'NIU', 'Niue') + , ('NZ', 'NZL', 'New Zealand') + , ('OM', 'OMN', 'Oman') + , ('PA', 'PAN', 'Panama') + , ('PE', 'PER', 'Peru') + , ('PF', 'PYF', 'French Polynesia') + , ('PG', 'PNG', 'Papua New Guinea') + , ('PH', 'PHL', 'Philippines (the)') + , ('PK', 'PAK', 'Pakistan') + , ('PL', 'POL', 'Poland') + , ('PM', 'SPM', 'Saint Pierre and Miquelon') + , ('PN', 'PCN', 'Pitcairn') + , ('PR', 'PRI', 'Puerto Rico') + , ('PS', 'PSE', 'Palestine, State of') + , ('PT', 'PRT', 'Portugal') + , ('PW', 'PLW', 'Palau') + , ('PY', 'PRY', 'Paraguay') + , ('QA', 'QAT', 'Qatar') + , ('RE', 'REU', 'Réunion') + , ('RO', 'ROU', 'Romania') + , ('RS', 'SRB', 'Serbia') + , ('RU', 'RUS', 'Russian Federation (the)') + , ('RW', 'RWA', 'Rwanda') + , ('SA', 'SAU', 'Saudi Arabia') + , ('SB', 'SLB', 'Solomon Islands') + , ('SC', 'SYC', 'Seychelles') + , ('SD', 'SDN', 'Sudan (the)') + , ('SE', 'SWE', 'Sweden') + , ('SG', 'SGP', 'Singapore') + , ('SH', 'SHN', 'Saint Helena, Ascension and Tristan da Cunha') + , ('SI', 'SVN', 'Slovenia') + , ('SJ', 'SJM', 'Svalbard and Jan Mayen') + , ('SK', 'SVK', 'Slovakia') + , ('SL', 'SLE', 'Sierra Leone') + , ('SM', 'SMR', 'San Marino') + , ('SN', 'SEN', 'Senegal') + , ('SO', 'SOM', 'Somalia') + , ('SR', 'SUR', 'Suriname') + , ('SS', 'SSD', 'South Sudan') + , ('ST', 'STP', 'Sao Tome and Principe') + , ('SV', 'SLV', 'El Salvador') + , ('SX', 'SXM', 'Sint Maarten (Dutch part)') + , ('SY', 'SYR', 'Syrian Arab Republic') + , ('SZ', 'SWZ', 'Swaziland') + , ('TC', 'TCA', 'Turks and Caicos Islands (the)') + , ('TD', 'TCD', 'Chad') + , ('TF', 'ATF', 'French Southern Territories (the)') + , ('TG', 'TGO', 'Togo') + , ('TH', 'THA', 'Thailand') + , ('TJ', 'TJK', 'Tajikistan') + , ('TK', 'TKL', 'Tokelau') + , ('TL', 'TLS', 'Timor-Leste') + , ('TM', 'TKM', 'Turkmenistan') + , ('TN', 'TUN', 'Tunisia') + , ('TO', 'TON', 'Tonga') + , ('TR', 'TUR', 'Turkey') + , ('TT', 'TTO', 'Trinidad and Tobago') + , ('TV', 'TUV', 'Tuvalu') + , ('TW', 'TWN', 'Taiwan (Province of China)') + , ('TZ', 'TZA', 'Tanzania, United Republic of') + , ('UA', 'UKR', 'Ukraine') + , ('UG', 'UGA', 'Uganda') + , ('UM', 'UMI', 'United States Minor Outlying Islands (the)') + , ('US', 'USA', 'United States of America (the)') + , ('UY', 'URY', 'Uruguay') + , ('UZ', 'UZB', 'Uzbekistan') + , ('VA', 'VAT', 'Holy See (the)') + , ('VC', 'VCT', 'Saint Vincent and the Grenadines') + , ('VE', 'VEN', 'Venezuela (Bolivarian Republic of)') + , ('VG', 'VGB', 'Virgin Islands (British)') + , ('VI', 'VIR', 'Virgin Islands (U.S.)') + , ('VN', 'VNM', 'Viet Nam') + , ('VU', 'VUT', 'Vanuatu') + , ('WF', 'WLF', 'Wallis and Futuna') + , ('WS', 'WSM', 'Samoa') + , ('YE', 'YEM', 'Yemen') + , ('YT', 'MYT', 'Mayotte') + , ('ZA', 'ZAF', 'South Africa') + , ('ZM', 'ZMB', 'Zambia') + , ('ZW', 'ZWE', 'Zimbabwe') + ; diff --git a/tests/py/test_participant_identities.py b/tests/py/test_participant_identities.py new file mode 100644 index 0000000000..de17b055f8 --- /dev/null +++ b/tests/py/test_participant_identities.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +from aspen.utils import utcnow +from gratipay.testing import Harness + + +class Tests(Harness): + + # gi - get_identities + + def test_gi_gets_an_identity(self): + crusher = self.make_participant('crusher') + self.db.run( "INSERT INTO participant_identities (ctime, participant, country, info) " + "VALUES (now(), %s, (SELECT id FROM countries WHERE code3='USA'), %s)" + , (crusher.id, json.dumps({'name': 'Crusher'})) + ) + ids = crusher.get_identities() + assert len(ids) == 1 + assert ids[0].info == {'name': 'Crusher'} + + def test_gi_gets_the_current_identity(self): + crusher = self.make_participant('crusher') + ctime = utcnow() + self.db.run( "INSERT INTO participant_identities " + "(ctime, mtime, participant, country, info) " + "VALUES (%s, %s, %s, (SELECT id FROM countries WHERE code3='USA'), %s)" + , (ctime, ctime, crusher.id, json.dumps({'name': 'Crusher'})) + ) + modified = {'name': 'Bruiser'} + self.db.run( "INSERT INTO participant_identities " + "(ctime, participant, country, info) " + "VALUES (%s, %s, (SELECT id FROM countries WHERE code3='USA'), %s)" + , (ctime, crusher.id, json.dumps(modified)) + ) + ids = crusher.get_identities() + assert len(ids) == 1 + assert ids[0].info == {'name': 'Bruiser'} + + def test_gi_gets_multiple_identities(self): + crusher = self.make_participant('crusher') + for country in ('USA', 'TTO'): + self.db.run( "INSERT INTO participant_identities " + "(ctime, participant, country, info) " + "VALUES (now(), %s, (SELECT id FROM countries WHERE code3=%s), %s)" + , (crusher.id, country, json.dumps({'name': 'Crusher'})) + ) + ids = crusher.get_identities() + assert len(ids) == 2 + assert ids[0].info == ids[1].info == {'name': 'Crusher'} + assert ids[0].country.code3 == 'TTO' + assert ids[1].country.code3 == 'USA' + + def test_gi_gets_multiple_current_identities(self): + crusher = self.make_participant('crusher') + for country in ('USA', 'TTO'): + ctime = utcnow() + self.db.run( "INSERT INTO participant_identities " + "(ctime, mtime, participant, country, info) " + "VALUES (%s, %s, %s, (SELECT id FROM countries WHERE code3=%s), %s)" + , (ctime, ctime, crusher.id, country, json.dumps({'name': 'Crusher'})) + ) + modified = {'name': 'Bruiser'} + self.db.run( "INSERT INTO participant_identities " + "(ctime, participant, country, info) " + "VALUES (%s, %s, (SELECT id FROM countries WHERE code3=%s), %s)" + , (ctime, crusher.id, country, json.dumps(modified)) + ) + ids = crusher.get_identities() + assert len(ids) == 2 + assert ids[0].info == ids[1].info == {'name': 'Bruiser'} + assert ids[0].country.code3 == 'TTO' + assert ids[1].country.code3 == 'USA' + + + # si - save_identity + + def test_si_saves_identity(self): + crusher = self.make_participant('crusher') + crusher.save_identity('TTO', {'name': 'Crusher', 'schema_version': -1}) + assert [x.country.code3 for x in crusher.get_identities()] == ['TTO'] + + def test_si_saves_second_identity(self): + crusher = self.make_participant('crusher') + crusher.save_identity('TTO', {'name': 'Crusher', 'schema_version': -1}) + crusher.save_identity('USA', {'name': 'Crusher', 'schema_version': -1}) + assert [x.country.code3 for x in crusher.get_identities()] == ['TTO', 'USA'] + + def test_si_overwrites_first_identity(self): + crusher = self.make_participant('crusher') + crusher.save_identity('TTO', {'name': 'Crusher', 'schema_version': -1}) + crusher.save_identity('TTO', {'name': 'Bruiser', 'schema_version': -1}) + ids = crusher.get_identities() + assert [x.country.code3 for x in ids] == ['TTO'] + assert [x.info['name'] for x in ids] == ['Bruiser'] + + def test_si_clears_identity(self): + crusher = self.make_participant('crusher') + crusher.save_identity('TTO', {'name': 'Crusher', 'schema_version': -1}) + crusher.save_identity('TTO', {'name': 'Bruiser', 'schema_version': -1}) + crusher.save_identity('TTO', None, is_verified=None) + assert crusher.get_identities() == []