Skip to content
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 Config Page #35716

Merged
merged 13 commits into from
Feb 4, 2025
112 changes: 112 additions & 0 deletions corehq/apps/integration/kyc/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import json

from django import forms
from django.utils.translation import gettext_lazy as _

from crispy_forms import bootstrap as twbscrispy
from crispy_forms import layout as crispy
from crispy_forms.helper import FormHelper

from corehq.apps.integration.kyc.models import (
KycConfig,
KycProviders,
UserDataStore,
)
from corehq.apps.userreports.ui.fields import JsonField
from corehq.motech.models import ConnectionSettings


class KycConfigureForm(forms.ModelForm):

class Meta:
model = KycConfig
fields = [
'user_data_store',
'other_case_type',
'provider',
'api_field_to_user_data_map',
'connection_settings',
]

user_data_store = forms.ChoiceField(
label=_('User Data Store'),
required=True,
choices=UserDataStore.CHOICES,
)
other_case_type = forms.CharField(
label=_('Other Case Type'),
required=False,
)
provider = forms.ChoiceField(
label=_('Provider'),
required=True,
choices=KycProviders.choices,
)
api_field_to_user_data_map = JsonField(
label=_('API Field to User Data Map'),
required=True,
expected_type=list,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model field is api_field_to_user_data_map = jsonfield.JSONField(default=dict). Should this be dict? i.e.

Suggested change
expected_type=list,
expected_type=dict,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model field has been updated to take list as a default type here. The expected format for the API mapping will be something like this:

[
  ...
  {
    "fieldName": "foo",
    "mapsTo": "bar",
    "source": "standard"
  }
  ...
]

)
connection_settings = forms.ModelChoiceField(
label=_('Connection Settings'),
required=True,
queryset=None,
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.instance = kwargs.pop('instance')
self.fields['connection_settings'].queryset = ConnectionSettings.objects.filter(
domain=self.instance.domain
)

self.helper = FormHelper()
self.helper.form_tag = False

self.helper.layout = crispy.Layout(
crispy.Div(
crispy.Field(
'user_data_store',
x_init='user_data_store = $el.value',
x_model='user_data_store',
),
crispy.Div(
'other_case_type',
x_init='other_case_type = $el.value',
x_show='otherCaseTypeChoice === user_data_store',
),
crispy.Div(
'provider',
x_init='provider = $el.value',
x_show='showProvider',
),
crispy.Div(
'api_field_to_user_data_map',
x_init='api_field_to_user_data_map = $el.value',
css_id='api-mapping',
),
crispy.Field(
'connection_settings',
x_init='connection_settings = $el.value',
),
twbscrispy.StrictButton(
_('Save'),
type='submit',
css_class='btn btn-primary',
),
x_data=json.dumps({
'user_data_store': self.instance.user_data_store,
'showProvider': len(KycProviders.choices) > 1,
'otherCaseTypeChoice': UserDataStore.OTHER_CASE_TYPE,
}),
)
)

def clean(self):
user_data_store = self.cleaned_data['user_data_store']
other_case_type = self.cleaned_data['other_case_type']
if user_data_store == UserDataStore.OTHER_CASE_TYPE and not other_case_type:
self.add_error('other_case_type', _('Please specify a value'))
elif user_data_store != UserDataStore.OTHER_CASE_TYPE:
self.cleaned_data['other_case_type'] = None
return self.cleaned_data
10 changes: 10 additions & 0 deletions corehq/apps/integration/kyc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,19 @@ class UserDataStore(object):
]


class KycProviders(models.TextChoices):
MTN_KYC = 'mtn_kyc', _('MTN KYC')


class KycConfig(models.Model):
domain = models.CharField(max_length=126, db_index=True)
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)

class Meta:
constraints = [
models.UniqueConstraint(fields=['domain', 'provider'], name='unique_domain_provider'),
]
Empty file.
60 changes: 60 additions & 0 deletions corehq/apps/integration/kyc/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from django.test import TestCase
from django.urls import reverse

from corehq.apps.domain.shortcuts import create_domain
from corehq.apps.integration.kyc.views import KycConfigurationView
from corehq.apps.users.models import WebUser
from corehq.util.test_utils import flag_enabled


class TestKycConfigurationView(TestCase):
domain = 'test-domain'
urlname = KycConfigurationView.urlname

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.domain_obj = create_domain(cls.domain)
cls.username = 'test-user'
cls.password = '1234'
cls.webuser = WebUser.create(
cls.domain,
cls.username,
cls.password,
None,
None,
is_admin=True,
)
cls.webuser.save()

@classmethod
def tearDownClass(cls):
cls.webuser.delete(None, None)
cls.domain_obj.delete()
super().tearDownClass()

@property
def endpoint(self):
return reverse(self.urlname, args=(self.domain,))

@property
def login_endpoint(self):
return reverse('domain_login', kwargs={'domain': self.domain})

def _make_request(self, is_logged_in=True):
if is_logged_in:
self.client.login(username=self.username, password=self.password)
return self.client.get(self.endpoint)

def test_not_logged_in(self):
response = self._make_request(is_logged_in=False)
self.assertRedirects(response, f'/accounts/login/?next={self.endpoint}')

def test_ff_not_enabled(self):
response = self._make_request()
self.assertEqual(response.status_code, 404)

@flag_enabled('KYC_VERIFICATION')
def test_success(self):
response = self._make_request()
self.assertEqual(response.status_code, 200)
9 changes: 9 additions & 0 deletions corehq/apps/integration/kyc/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import re_path as url

from corehq.apps.integration.kyc.views import KycConfigurationView


urlpatterns = [
url(r'^configure/$', KycConfigurationView.as_view(),
name=KycConfigurationView.urlname),
]
66 changes: 66 additions & 0 deletions corehq/apps/integration/kyc/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _

from corehq import toggles
from corehq.apps.domain.decorators import login_required
from corehq.apps.domain.views.base import BaseDomainView
from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.integration.kyc.forms import KycConfigureForm
from corehq.apps.integration.kyc.models import KycConfig
from corehq.util.htmx_action import HqHtmxActionMixin


@method_decorator(login_required, name='dispatch')
@method_decorator(use_bootstrap5, name='dispatch')
@method_decorator(toggles.KYC_VERIFICATION.required_decorator(), name='dispatch')
class KycConfigurationView(BaseDomainView, HqHtmxActionMixin):
section_name = _("Data")
urlname = 'kyc_configuration'
template_name = 'kyc/kyc_config_base.html'
page_title = _('KYC Configuration')

form_class = KycConfigureForm
form_template_partial_name = 'kyc/partials/kyc_config_form_partial.html'

@property
def page_url(self):
return reverse(self.urlname, args=[self.domain])

@property
def section_url(self):
return reverse(self.urlname, args=(self.domain,))

@property
def page_context(self):
return {
'kyc_config_form': self.config_form,
}

@property
def config(self):
try:
# Currently a domain can only save one config so we shouldn't
# expect more than one per domain
return KycConfig.objects.get(domain=self.domain)
except KycConfig.DoesNotExist:
return KycConfig(domain=self.domain)

@property
def config_form(self):
if self.request.method == 'POST':
return self.form_class(self.request.POST, instance=self.config)
return self.form_class(instance=self.config)

def post(self, request, *args, **kwargs):
form = self.config_form
show_success = False
if form.is_valid():
form.save(commit=True)
Copy link
Contributor

@Charl1996 Charl1996 Feb 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume the fact that the form is a ModelForm will persist the model to the database with commit=True?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is correct. The save() function's help text reads "Save this form's self.instance object if commit=True...". I also verified this in my local testing by checking that the instance is actually getting saved with this function call.

show_success = True

context = {
'kyc_config_form': form,
'show_success': show_success,
}
return self.render_htmx_partial_response(request, self.form_template_partial_name, context)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.17 on 2025-01-29 08:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('integration', '0007_alter_kycconfig_api_field_to_user_data_map'),
]

operations = [
migrations.AddField(
model_name='kycconfig',
name='provider',
field=models.CharField(choices=[('mtn_kyc', 'MTN KYC')], default='mtn_kyc', max_length=25),
),
migrations.AddConstraint(
model_name='kycconfig',
constraint=models.UniqueConstraint(fields=('domain', 'provider',), name='unique_domain_provider'),
),
]
17 changes: 17 additions & 0 deletions corehq/apps/integration/static/integration/js/kyc/kyc_configure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import $ from "jquery";
import baseAce from "hqwebapp/js/base_ace";
import "hqwebapp/js/htmx_and_alpine";

document.body.addEventListener("htmx:afterSwap", function () {
const $jsonField = $("#api-mapping").find("textarea");
if (!$jsonField) {
return;
}

// We need to wait for the JSON field to be rendered before initializing the Ace editor
setTimeout(() => {
const jsonWidget = baseAce.initJsonWidget($jsonField);
const fieldVal = $jsonField.val();
jsonWidget.getSession().setValue(fieldVal);
}, 50);
});
8 changes: 8 additions & 0 deletions corehq/apps/integration/templates/kyc/kyc_config_base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% extends "hqwebapp/bootstrap5/base_section.html" %}
{% load hq_shared_tags %}
{% load i18n %}
{% js_entry 'integration/js/kyc/kyc_configure' %}

{% block page_content %}
<div>{% include 'kyc/partials/kyc_config_form_partial.html' %}</div>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{% load crispy_forms_tags %}
{% load i18n %}

<div id="kyc-configure-form">
{% if show_success %}
<div class="alert alert-success alert-dismissable fade show" role="alert">
{% blocktrans %}
Configuration saved successfully!
{% endblocktrans %}
<button
type="button"
class="btn-close float-end"
data-bs-dismiss="alert"
aria-label="{% trans Close %}"
></button>
</div>
{% endif %}

<form
hx-post="{{ request.path_info }}"
hx-target="#kyc-configure-form"
hx-swap="outerHTML"
>
{% crispy kyc_config_form %}
</form>
</div>
7 changes: 7 additions & 0 deletions corehq/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
CaseManagementMap,
CaseGroupingReport,
)
from corehq.apps.integration.kyc.views import KycConfigurationView

from . import toggles

Expand Down Expand Up @@ -353,3 +354,9 @@ def EDIT_DATA_INTERFACES(domain_obj):
CaseGroupingReport,
)),
)

KYC_VERIFICATION = (
(_("KYC Verification"), (
KycConfigurationView,
)),
)
Loading