-
-
Notifications
You must be signed in to change notification settings - Fork 222
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
324 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
{ | ||
"$schema": "http://json-schema.org/draft-07/schema#", | ||
"type": "object", | ||
"required": [ | ||
"firstName", | ||
"lastName", | ||
"phoneNumber", | ||
"emailAddress", | ||
"nationalIdNumber", | ||
"streetAddress", | ||
"city", | ||
"postCode", | ||
"country" | ||
], | ||
"properties": { | ||
"firstName": { | ||
"type": "string" | ||
}, | ||
"lastName": { | ||
"type": "string" | ||
}, | ||
"phoneNumber": { | ||
"type": "string" | ||
}, | ||
"emailAddress": { | ||
"type": "string" | ||
}, | ||
"nationalIdNumber": { | ||
"type": "string" | ||
}, | ||
"streetAddress": { | ||
"type": "string" | ||
}, | ||
"city": { | ||
"type": "string" | ||
}, | ||
"postCode": { | ||
"type": "string" | ||
}, | ||
"country": { | ||
"type": "string" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,139 @@ | ||
from corehq.apps.integration.kyc.models import UserDataStore | ||
import json | ||
import os | ||
import re | ||
from datetime import datetime | ||
|
||
from django.conf import settings | ||
from django.utils.text import camel_case_to_spaces | ||
|
||
import jsonschema | ||
|
||
from corehq.apps.hqcase.utils import update_case | ||
from corehq.apps.integration.kyc.models import KycConfig, UserDataStore | ||
|
||
|
||
class UserCaseNotFound(Exception): | ||
pass | ||
|
||
|
||
def verify_all(domain): | ||
kyc_config = KycConfig.objects.get(domain=domain) | ||
user_objs = kyc_config.get_user_objects() | ||
return verify_users(user_objs, kyc_config) | ||
|
||
|
||
def verify_selected_ids(domain, selected_ids): | ||
kyc_config = KycConfig.objects.get(domain=domain) | ||
# ... | ||
|
||
|
||
def verify_users(user_objs, config): | ||
# TODO: An endpoint to verify a group of users does not seem to be | ||
# available using Chenosis | ||
results = [] | ||
for user_obj in user_objs: | ||
is_verified = verify_user(user_obj, config) | ||
save_result(user_obj, config, is_verified) | ||
results.append(is_verified) | ||
return results | ||
|
||
|
||
def verify_user(user_obj, config): | ||
""" | ||
Verify a user using the Chenosis MTN KYC API. | ||
Returns True if all user data is accurate above its required | ||
threshold, otherwise False. | ||
""" | ||
|
||
# Documentation: https://apiportal.chenosis.io/en/apis/kyc-verify#endpoints | ||
# | ||
# Example request: | ||
# | ||
# POST https://dev.api.chenosis.io/kycVerify/v1/customers/{customerId} | ||
# { | ||
# "firstName": "John", | ||
# "lastName": "Doe", | ||
# "phoneNumber": "27650600900", | ||
# "emailAddress": "[email protected]", | ||
# "nationalIdNumber": "123456789", | ||
# "streetAddress": "1st Park Avenue, Mzansi, Johannesburg", | ||
# "city": "Johannesburg", | ||
# "postCode": "20200", | ||
# "country": "South Africa" | ||
# } | ||
# | ||
# Example 200 response: | ||
# | ||
# { | ||
# "statusCode": "0000", | ||
# "statusMessage": "Success.", | ||
# "customerId": "27650600900", | ||
# "transactionId": "232TXYZ-212", | ||
# "data": { | ||
# "firstName": 100, | ||
# "lastName": 100, | ||
# "phoneNumber": 100, | ||
# "emailAddress": 100, | ||
# "nationalIdNumber": 100, | ||
# "streetAddress": 100, | ||
# "city": 100, | ||
# "postCode": 100, | ||
# "country": 100 | ||
# } | ||
# } | ||
|
||
# TODO: Determine what thresholds we want | ||
required_thresholds = { | ||
'firstName': 100, | ||
'lastName': 100, | ||
'phoneNumber': 100, | ||
'emailAddress': 100, | ||
'nationalIdNumber': 100, | ||
'streetAddress': 80, # Allow streetAddress to be less accurate | ||
'city': 100, | ||
'postCode': 100, | ||
'country': 0, # e.g "Ivory Coast" vs. "Republic of Côte d'Ivoire" vs. "CIV"? | ||
} | ||
|
||
user_data = get_user_data_for_api(user_obj, config) | ||
_validate_schema('kycVerify/v1', user_data) | ||
requests = config.get_connection_settings().get_requests() | ||
response = requests.post( | ||
f'/kycVerify/v1/customers/{user_data["phoneNumber"]}', | ||
json=user_data, | ||
) | ||
response.raise_for_status() | ||
field_scores = response.json().get('data', {}) | ||
return all(v >= required_thresholds[k] for k, v in field_scores.items()) | ||
|
||
|
||
def save_result(user_obj, config, is_verified): | ||
update = { | ||
'kyc_provider': config.provider.value, # TODO: Or config.provider.label? | ||
'kyc_last_verified_at': datetime.utcnow(), # TODO: UTC or project timezone? | ||
'kyc_is_verified': is_verified, | ||
} | ||
if config.user_data_store == UserDataStore.CUSTOM_USER_DATA: | ||
user_data = user_obj.get_user_data(config.domain) | ||
user_data.update(update) | ||
elif config.user_data_store == UserDataStore.USER_CASE: | ||
case = user_obj.get_usercase() | ||
update_case( | ||
config.domain, | ||
case.case_id, | ||
case_properties=update, | ||
device_id='corehq.apps.integration.kyc.services.save_result', | ||
) | ||
else: # UserDataStore.OTHER_CASE_TYPE | ||
update_case( | ||
config.domain, | ||
user_obj.case_id, | ||
case_properties=update, | ||
device_id='corehq.apps.integration.kyc.services.save_result', | ||
) | ||
|
||
|
||
def get_user_data_for_api(source, config): | ||
""" | ||
Returns a dictionary of user data for the API. | ||
|
@@ -39,3 +168,29 @@ def _get_source_data(source, config): | |
else: | ||
source_data = source.case_json | ||
return source_data | ||
|
||
|
||
def _validate_schema(endpoint, data): | ||
""" | ||
Validate the data against the schema for the given endpoint | ||
""" | ||
schema_filename = f'{_kebab_case(endpoint)}.json' | ||
schema_path = os.path.join( | ||
settings.BASE_DIR, 'corehq', 'apps', 'integration', 'kyc', 'schemas', | ||
schema_filename, | ||
) | ||
with open(schema_path) as f: | ||
schema = json.load(f) | ||
jsonschema.validate(data, schema) | ||
|
||
|
||
def _kebab_case(value): | ||
""" | ||
Convert a string to kebab-case | ||
>>> _kebab_case('Hello, World!') | ||
'hello-world' | ||
""" | ||
value = camel_case_to_spaces(value) | ||
value = re.sub(r"[^\w\s-]", "-", value.lower()) | ||
return re.sub(r"[-\s]+", "-", value).strip("-_") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
import doctest | ||
|
||
from django.test import TestCase | ||
|
||
import jsonschema | ||
import pytest | ||
|
||
from corehq.apps.app_manager.const import USERCASE_TYPE | ||
from corehq.apps.integration.kyc.models import KycConfig, UserDataStore | ||
from corehq.apps.integration.kyc.services import ( | ||
UserCaseNotFound, | ||
_get_source_data, | ||
_validate_schema, | ||
) | ||
from corehq.apps.users.models import CommCareUser | ||
from corehq.form_processor.models import CommCareCase | ||
|
||
DOMAIN = 'test-domain' | ||
|
||
|
||
def test_doctests(): | ||
import corehq.apps.integration.kyc.services as module | ||
results = doctest.testmod(module) | ||
assert results.failed == 0 | ||
|
||
|
||
class TestSaveResult(TestCase): | ||
# TODO: ... | ||
pass | ||
|
||
|
||
class TestGetSourceData(TestCase): | ||
|
||
def setUp(self): | ||
self.domain = DOMAIN | ||
self.commcare_user = CommCareUser( | ||
domain=self.domain, | ||
username='test_user', | ||
user_data={'custom_field': 'custom_value'} | ||
) | ||
self.commcare_case = CommCareCase( | ||
domain=self.domain, | ||
type='case_type', | ||
case_json={'case_property': 'case_value'} | ||
) | ||
self.user_case = CommCareCase( | ||
domain=self.domain, | ||
type=USERCASE_TYPE, | ||
case_json={'user_case_property': 'user_case_value'} | ||
) | ||
|
||
def test_custom_user_data(self): | ||
config = KycConfig( | ||
domain=self.domain, | ||
user_data_store=UserDataStore.CUSTOM_USER_DATA, | ||
) | ||
source_data = _get_source_data(self.commcare_user, config) | ||
assert source_data == {'custom_field': 'custom_value'} | ||
|
||
def test_user_case(self): | ||
config = KycConfig( | ||
domain=self.domain, | ||
user_data_store=UserDataStore.USER_CASE, | ||
) | ||
self.commcare_user.get_usercase = lambda: self.user_case | ||
source_data = _get_source_data(self.commcare_user, config) | ||
assert source_data == {'user_case_property': 'user_case_value'} | ||
|
||
def test_user_case_not_found(self): | ||
config = KycConfig( | ||
domain=self.domain, | ||
user_data_store=UserDataStore.USER_CASE, | ||
) | ||
self.commcare_user.get_usercase = lambda: None | ||
with pytest.raises(UserCaseNotFound): | ||
_get_source_data(self.commcare_user, config) | ||
|
||
def test_other_case_type(self): | ||
config = KycConfig( | ||
domain=self.domain, | ||
user_data_store=UserDataStore.OTHER_CASE_TYPE, | ||
) | ||
source_data = _get_source_data(self.commcare_case, config) | ||
assert source_data == {'case_property': 'case_value'} | ||
|
||
|
||
class TestValidateSchema(TestCase): | ||
|
||
def test_valid_schema(self): | ||
endpoint = 'kycVerify/v1' | ||
data = { | ||
'firstName': 'John', | ||
'lastName': 'Doe', | ||
'phoneNumber': '27650600900', | ||
'emailAddress': '[email protected]', | ||
'nationalIdNumber': '123456789', | ||
'streetAddress': '1st Park Avenue, Mzansi, Johannesburg', | ||
'city': 'Johannesburg', | ||
'postCode': '20200', | ||
'country': 'South Africa', | ||
} | ||
_validate_schema(endpoint, data) # Should not raise an exception | ||
|
||
def test_invalid_schema(self): | ||
endpoint = 'kycVerify/v1' | ||
data = { | ||
'firstName': 'John', | ||
'lastName': 'Doe', | ||
# Missing required fields | ||
} | ||
with pytest.raises(jsonschema.ValidationError): | ||
_validate_schema(endpoint, data) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters