-
-
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 3 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,141 @@ | ||
from django.core.exceptions import ValidationError | ||
from django.test import TestCase | ||
|
||
import pytest | ||
|
||
from corehq.apps.app_manager.const import USERCASE_TYPE | ||
from corehq.apps.es.case_search import case_search_adapter | ||
from corehq.apps.es.tests.utils import es_test | ||
from corehq.apps.integration.kyc.models import KycConfig, UserDataStore | ||
from corehq.apps.users.models import CommCareUser | ||
from corehq.form_processor.tests.utils import create_case | ||
from corehq.motech.models import ConnectionSettings | ||
|
||
DOMAIN = 'test-domain' | ||
|
||
|
||
class TestGetConnectionSettings(TestCase): | ||
|
||
def test_valid_without_connection_settings(self): | ||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.USER_CASE, | ||
) | ||
# Does not raise `django.db.utils.IntegrityError` | ||
config.save() | ||
|
||
def test_get_connection_settings(self): | ||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.USER_CASE, | ||
) | ||
assert ConnectionSettings.objects.count() == 0 | ||
|
||
connx = config.get_connection_settings() | ||
assert isinstance(connx, ConnectionSettings) | ||
# First call creates ConnectionSettings | ||
assert ConnectionSettings.objects.count() == 1 | ||
|
||
connx = config.get_connection_settings() | ||
assert isinstance(connx, ConnectionSettings) | ||
# Subsequent calls get existing ConnectionSettings | ||
assert ConnectionSettings.objects.count() == 1 | ||
|
||
def test_bad_config(self): | ||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.USER_CASE, | ||
provider='invalid', | ||
) | ||
with pytest.raises( | ||
ValueError, | ||
match="^Unable to determine connection settings for KYC provider " | ||
"'invalid'.$", | ||
): | ||
config.get_connection_settings() | ||
|
||
|
||
class TestGetUserObjectsUsers(TestCase): | ||
|
||
def setUp(self): | ||
self.commcare_user = CommCareUser.create( | ||
DOMAIN, f'test.user@{DOMAIN}.commcarehq.org', 'Passw0rd!', | ||
None, None, | ||
user_data={'custom_field': 'custom_value'}, | ||
) | ||
self.addCleanup(self.commcare_user.delete, DOMAIN, deleted_by=None) | ||
self.user_case = create_case( | ||
DOMAIN, | ||
case_type=USERCASE_TYPE, | ||
user_id=self.commcare_user._id, | ||
name='test.user', | ||
external_id=self.commcare_user._id, | ||
save=True, | ||
case_json={'user_case_property': 'user_case_value'}, | ||
) | ||
|
||
def test_custom_user_data(self): | ||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.CUSTOM_USER_DATA, | ||
) | ||
commcare_users = config.get_user_objects() | ||
assert len(commcare_users) == 1 | ||
user_data = commcare_users[0].get_user_data(DOMAIN).to_dict() | ||
assert user_data == { | ||
'commcare_profile': '', | ||
'custom_field': 'custom_value', | ||
} | ||
|
||
def test_user_case(self): | ||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.USER_CASE, | ||
) | ||
commcare_users = config.get_user_objects() | ||
assert len(commcare_users) == 1 | ||
user_case = commcare_users[0].get_usercase() | ||
assert user_case.case_json == { | ||
'user_case_property': 'user_case_value', | ||
} | ||
|
||
|
||
@es_test(requires=[case_search_adapter]) | ||
class TestGetUserObjectsCases(TestCase): | ||
|
||
def setUp(self): | ||
self.other_case = create_case( | ||
DOMAIN, | ||
case_type='other_case_type', | ||
save=True, | ||
case_json={'other_case_property': 'other_case_value'}, | ||
) | ||
case_search_adapter.bulk_index([self.other_case], refresh=True) | ||
|
||
def test_other_case_type(self): | ||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.OTHER_CASE_TYPE, | ||
other_case_type='other_case_type', | ||
) | ||
commcare_cases = config.get_user_objects() | ||
assert len(commcare_cases) == 1 | ||
assert commcare_cases[0].case_json == { | ||
'other_case_property': 'other_case_value', | ||
} | ||
|
||
def test_other_case_type_clean(self): | ||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.OTHER_CASE_TYPE, | ||
) | ||
with pytest.raises(ValidationError): | ||
config.clean() | ||
|
||
def test_assert_other_case_type(self): | ||
ajeety4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
config = KycConfig( | ||
domain=DOMAIN, | ||
user_data_store=UserDataStore.OTHER_CASE_TYPE, | ||
) | ||
with pytest.raises(AssertionError): | ||
config.get_user_objects() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# Generated by Django 4.2.18 on 2025-02-16 22:53 | ||
|
||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
("motech", "0017_connectionsettings_use_aes_cbc_encryption"), | ||
("integration", "0008_kycconfig_provider_kycconfig_unique_domain_provider"), | ||
] | ||
|
||
operations = [ | ||
migrations.AlterField( | ||
model_name="kycconfig", | ||
name="connection_settings", | ||
field=models.ForeignKey( | ||
blank=True, | ||
null=True, | ||
on_delete=django.db.models.deletion.PROTECT, | ||
to="motech.connectionsettings", | ||
), | ||
), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1172,6 +1172,14 @@ def _pkce_required(client_id): | |
# used by periodic tasks that delete soft deleted data older than PERMANENT_DELETION_WINDOW days | ||
PERMANENT_DELETION_WINDOW = 30 # days | ||
|
||
# Used by `corehq.apps.integration.kyc`. Override in localsettings.py | ||
MTN_KYC_CONNECTION_SETTINGS = { | ||
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 probably behind on recent updates with MTN ) 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 turns out that the API service provider will have a contract with the platform provider (i.e. Dimagi), and will grant access to them, and not to each project ... ... which has just made me think that saving the ConnectionSettings instance is not a good idea. Projects/users can't read the secrets of ConnectionSettings instances, but they can change them. So either we need to have a way to write-protect ConnectioinSettings, or we need to create them on the fly without persisting them. |
||
'url': 'https://dev.api.chenosis.io/', | ||
'token_url': 'https://dev.api.chenosis.io/oauth/client/accesstoken', | ||
'client_id': 'test', | ||
'client_secret': 'password', | ||
} | ||
|
||
|
||
try: | ||
# try to see if there's an environmental variable set for local_settings | ||
|
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.