-
-
Notifications
You must be signed in to change notification settings - Fork 222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
KYC Service Layer #35774
base: master
Are you sure you want to change the base?
KYC Service Layer #35774
Changes from 4 commits
fc323ea
929185b
4ea342f
143f16c
4ae1201
1d2c951
7666d11
b06292d
945da40
d709e5d
7697748
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,14 @@ | ||
from django.conf import settings | ||
from django.core.exceptions import ValidationError | ||
from django.db import models | ||
from django.utils.translation import gettext as _ | ||
|
||
import jsonfield | ||
|
||
from corehq.apps.es.case_search import CaseSearchES | ||
from corehq.apps.users.models import CommCareUser | ||
from corehq.form_processor.models import CommCareCase | ||
from corehq.motech.const import OAUTH2_CLIENT | ||
from corehq.motech.models import ConnectionSettings | ||
|
||
|
||
|
@@ -18,6 +24,9 @@ class UserDataStore(object): | |
|
||
|
||
class KycProviders(models.TextChoices): | ||
# When adding a new provider: | ||
# 1. Add connection settings to `settings.py` if necessary | ||
# 2. Add it to `KycConfig.get_connections_settings()` | ||
MTN_KYC = 'mtn_kyc', _('MTN KYC') | ||
|
||
|
||
|
@@ -26,10 +35,75 @@ class KycConfig(models.Model): | |
user_data_store = models.CharField(max_length=25, choices=UserDataStore.CHOICES) | ||
other_case_type = models.CharField(max_length=126, null=True) | ||
api_field_to_user_data_map = jsonfield.JSONField(default=list) | ||
connection_settings = models.ForeignKey(ConnectionSettings, on_delete=models.PROTECT) | ||
provider = models.CharField(max_length=25, choices=KycProviders.choices, default=KycProviders.MTN_KYC) | ||
provider = models.CharField( | ||
max_length=25, | ||
choices=KycProviders.choices, | ||
default=KycProviders.MTN_KYC, | ||
) | ||
connection_settings = models.ForeignKey( | ||
ConnectionSettings, | ||
on_delete=models.PROTECT, | ||
# Assumes we can determine connection settings for provider | ||
null=True, | ||
blank=True, | ||
) | ||
|
||
class Meta: | ||
constraints = [ | ||
models.UniqueConstraint(fields=['domain', 'provider'], name='unique_domain_provider'), | ||
] | ||
|
||
def clean(self): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool 👍 |
||
super().clean() | ||
if ( | ||
self.user_data_store == UserDataStore.OTHER_CASE_TYPE | ||
and not self.other_case_type | ||
): | ||
raise ValidationError({ | ||
'other_case_type': _( | ||
'This field is required when "User Data Store" is set to ' | ||
'"Other Case Type".' | ||
) | ||
}) | ||
elif self.user_data_store != UserDataStore.OTHER_CASE_TYPE: | ||
self.other_case_type = None | ||
|
||
def get_connection_settings(self): | ||
if not self.connection_settings_id: | ||
if self.provider == KycProviders.MTN_KYC: | ||
kyc_settings = settings.MTN_KYC_CONNECTION_SETTINGS | ||
self.connection_settings = ConnectionSettings.objects.create( | ||
domain=self.domain, | ||
name=KycProviders.MTN_KYC.label, | ||
url=kyc_settings['url'], | ||
auth_type=OAUTH2_CLIENT, | ||
client_id=kyc_settings['client_id'], | ||
client_secret=kyc_settings['client_secret'], | ||
token_url=kyc_settings['token_url'], | ||
) | ||
self.save() | ||
# elif self.provider == KycProviders.NEW_PROVIDER_HERE: ... | ||
else: | ||
raise ValueError(f'Unable to determine connection settings for KYC provider {self.provider!r}.') | ||
return self.connection_settings | ||
|
||
def get_user_objects(self): | ||
""" | ||
Returns all CommCareUser or CommCareCase instances based on the | ||
user data store. | ||
""" | ||
if self.user_data_store in ( | ||
UserDataStore.CUSTOM_USER_DATA, | ||
UserDataStore.USER_CASE, | ||
): | ||
return CommCareUser.by_domain(self.domain) | ||
elif self.user_data_store == UserDataStore.OTHER_CASE_TYPE: | ||
assert self.other_case_type | ||
ajeety4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
case_ids = ( | ||
CaseSearchES() | ||
.domain(self.domain) | ||
.case_type(self.other_case_type) | ||
).get_ids() | ||
if not case_ids: | ||
return [] | ||
return CommCareCase.objects.get_cases(case_ids, self.domain) |
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" | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,10 +1,141 @@ | ||||||
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) | ||||||
# ... | ||||||
pass | ||||||
|
||||||
|
||||||
def verify_users(user_objs, config): | ||||||
# TODO: An endpoint to verify a group of users does not seem to be | ||||||
# available using Chenosis. If we have to do this with | ||||||
# multiple calls, consider using Celery gevent workers. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💯 |
||||||
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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like having just verification results is not very helpful to the caller. Consider adding an identifier (e.g.
|
||||||
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, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kaapstorm How do these scores function? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like a percentage of how close the value that was submitted is to the value that MTN has on record. We haven't been able to test this yet, because we don't (yet) have credentials we can use for testing. |
||||||
'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? | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am bit inclined towards storing the value here in case we want to make a conditional check on this later. |
||||||
'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', | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Consider using |
||||||
) | ||||||
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. | ||||||
|
@@ -26,11 +157,13 @@ def get_user_data_for_api(source, config): | |||||
|
||||||
def _get_source_data(source, config): | ||||||
""" | ||||||
Returns a dictionary of source data. | ||||||
``source`` is a CommCareUser or a CommCareCase. | ||||||
Returns a dictionary of source data. | ||||||
|
||||||
:param source: A CommCareUser or a CommCareCase. | ||||||
:param config: A KycConfig instance. | ||||||
""" | ||||||
if config.user_data_store == UserDataStore.CUSTOM_USER_DATA: | ||||||
source_data = source.get_user_data(config.domain) | ||||||
source_data = source.get_user_data(config.domain).to_dict() | ||||||
elif config.user_data_store == UserDataStore.USER_CASE: | ||||||
custom_user_case = source.get_usercase() | ||||||
if not custom_user_case: | ||||||
|
@@ -39,3 +172,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("-_") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be good to have a short documentation for steps to add a new provider.
Could be done in the follow up work along with TODOs.