From f78859bd995fabb115a3d96746a3444fb53401bc Mon Sep 17 00:00:00 2001 From: Domenico DiNicola Date: Tue, 21 Jan 2025 18:28:23 -0600 Subject: [PATCH] imto --- src/aurora/config/fragments/constance.py | 9 ++ src/aurora/core/constance.py | 54 ++++++++++ src/aurora/core/fields/__init__.py | 1 + src/aurora/core/fields/imto.py | 64 ++++++++++++ src/aurora/core/registry.py | 1 + tests/fields/test_imto.py | 122 +++++++++++++++++++++++ 6 files changed, 251 insertions(+) create mode 100644 src/aurora/core/constance.py create mode 100644 src/aurora/core/fields/imto.py create mode 100644 tests/fields/test_imto.py diff --git a/src/aurora/config/fragments/constance.py b/src/aurora/config/fragments/constance.py index 31a51312..92e2fec0 100644 --- a/src/aurora/config/fragments/constance.py +++ b/src/aurora/config/fragments/constance.py @@ -11,6 +11,13 @@ "choices": (("html", "HTML"), ("line", "NEWLINE"), ("space", "SPACES")), }, ], + "write_only_input": [ + "django.forms.fields.CharField", + { + "required": False, + "widget": "aurora.core.constance.WriteOnlyInput", + }, + ], } CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" CONSTANCE_DATABASE_CACHE_BACKEND = env("CONSTANCE_DATABASE_CACHE_BACKEND") @@ -43,5 +50,7 @@ str, ), "WAF_ADMIN_ALLOWED_HOSTNAMES": ("", "admin website hostname (regex)", str), + "IMTO_NAME_ENQUIRY_URL": ("", "IMTO Name Enquiry Service URL", str), + "IMTO_TOKEN": ("", "IMTO Service Token", "write_only_input"), } ) diff --git a/src/aurora/core/constance.py b/src/aurora/core/constance.py new file mode 100644 index 00000000..a06980ca --- /dev/null +++ b/src/aurora/core/constance.py @@ -0,0 +1,54 @@ +import logging +from typing import Any + +from django.forms import ChoiceField, HiddenInput, Textarea, TextInput +from django.template import Context, Template +from django.utils.safestring import SafeString, mark_safe + +from constance import config + +logger = logging.getLogger(__name__) + + +class ObfuscatedInput(HiddenInput): + def render( + self, + name: str, + value: Any, + attrs: dict[str, str] | None = None, + renderer: Any | None = None, + ) -> "SafeString": + context = self.get_context(name, value, attrs) + context["value"] = str(value) + context["label"] = "Set" if value else "Not Set" + + tpl = Template('{{ label }}') + return mark_safe(tpl.render(Context(context))) # noqa: S308 + + +class WriteOnlyWidget: + def format_value(self, value: Any) -> str: + return super().format_value("***") + + def value_from_datadict(self, data: dict[str, Any], files: Any, name: str) -> Any: + value = data.get(name) + if value == "***": + return getattr(config, name) + return value + + +class WriteOnlyTextarea(WriteOnlyWidget, Textarea): + pass + + +class WriteOnlyInput(WriteOnlyWidget, TextInput): + pass + + +class GroupChoiceField(ChoiceField): + def __init__(self, **kwargs: Any) -> None: + from django.contrib.auth.models import Group + + ret: list[tuple[str | int, str]] = [(c["name"], c["name"]) for c in Group.objects.values("pk", "name")] + kwargs["choices"] = ret + super().__init__(**kwargs) diff --git a/src/aurora/core/fields/__init__.py b/src/aurora/core/fields/__init__.py index 6aab894b..57540868 100644 --- a/src/aurora/core/fields/__init__.py +++ b/src/aurora/core/fields/__init__.py @@ -10,6 +10,7 @@ from .label import LabelOnlyField # noqa from .mixins import SmartFieldMixin # noqa from .multi_checkbox import MultiCheckboxField # noqa +from .imto import IMTONameEnquiryField # noqa from .radio import RadioField, YesNoChoice, YesNoRadio # noqa from .remote_ip import RemoteIpField # noqa from .selected import AjaxSelectField, SelectField, SmartSelectWidget # noqa diff --git a/src/aurora/core/fields/imto.py b/src/aurora/core/fields/imto.py new file mode 100644 index 00000000..00867006 --- /dev/null +++ b/src/aurora/core/fields/imto.py @@ -0,0 +1,64 @@ +import requests +from constance import config +from django import forms +from django.core.exceptions import ValidationError +from django.forms import MultiWidget +from django.forms.widgets import TextInput + + +# https://documenter.getpostman.com/view/20828158/2s93sW7aEc#4fda8a6a-0c21-42b2-a86b-03dce41303ad +class IMTONameEnquiryMultiWidget(MultiWidget): + def __init__(self, *args, **kwargs): + self.widgets = ( + TextInput({"placeholder": "Bank Code"}), + TextInput({"placeholder": "Account Number"}), + TextInput({"placeholder": "Account Full Name"}), + ) + super().__init__(self.widgets, *args, **kwargs) + + def decompress(self, value): + return value.rsplit("|") if value else [None, None, None] + + +class IMTONameEnquiryField(forms.fields.MultiValueField): + widget = IMTONameEnquiryMultiWidget + + def __init__(self, *args, **kwargs): + list_fields = [forms.fields.CharField(max_length=16), forms.fields.CharField(max_length=32)] + super().__init__(list_fields, *args, **kwargs) + + def compress(self, values): + return "|".join(values) + + def validate(self, value): + super().validate(value) + try: + account_number, bank_code, account_full_name = value.rsplit("|") + except ValueError: + raise ValidationError("ValueError: not enough values to unpack") + + headers = { + "x-token": config.IMTO_TOKEN, + } + body = {"accountNumber": account_number, "bankCode": bank_code} + response = requests.post(config.IMTO_NAME_ENQUIRY_URL, headers=headers, json=body, timeout=60) + + if ( + response.status_code == 200 + and not response.json()["error"] + and response.json()["code"] == "00" + and response.json()["data"]["beneficiaryAccountName"] == account_full_name + ): + return + + if response.status_code == 500: + message = response.reason + elif response.json()["error"]: + message = response.json()["error"] + elif 300 <= response.status_code < 500: + message = f"Error {response.status_code}" + elif response.json()["data"]["beneficiaryAccountName"] != account_full_name: + message = f"Wrong account holder {account_full_name}" + else: + message = "Generic Error" + raise ValidationError(message) diff --git a/src/aurora/core/registry.py b/src/aurora/core/registry.py index 6345da18..cd5891b7 100644 --- a/src/aurora/core/registry.py +++ b/src/aurora/core/registry.py @@ -107,6 +107,7 @@ def __contains__(self, y): field_registry.register(fields.HiddenField) field_registry.register(MathCaptchaField) field_registry.register(fields.CaptchaField) +field_registry.register(fields.IMTONameEnquiryField) form_registry = Registry(forms.BaseForm) diff --git a/tests/fields/test_imto.py b/tests/fields/test_imto.py new file mode 100644 index 00000000..e0592ce9 --- /dev/null +++ b/tests/fields/test_imto.py @@ -0,0 +1,122 @@ +from unittest.mock import Mock, patch + +import pytest +from django.core.exceptions import ValidationError + +from aurora.core.fields import IMTONameEnquiryField + + +@pytest.mark.django_db +@patch("aurora.core.fields.imto.requests.post") +def test_imto_name_enquiry_ok(mock_post): + mock_post.return_value = Mock( + status_code=200, + json=lambda: { + "message": "operation successful", + "data": { + "lookupParam": "100001241210170958777131925463_DANIEL ADEBAYO ADEDOYIN_22000000051_3", + "beneficiaryAccountNumber": "8168208035", + "beneficiaryBankCode": "100004", + "beneficiaryAccountName": "DANIEL ADEBAYO ADEDOYIN", + }, + "code": "00", + "error": "", + }, + ) + fld = IMTONameEnquiryField() + assert fld.validate("bank|account|DANIEL ADEBAYO ADEDOYIN") is None + + +@pytest.mark.django_db +@patch("aurora.core.fields.imto.requests.post") +def test_imto_name_enquiry_ko_not_matching_name(mock_post): + mock_post.return_value = Mock( + status_code=200, + json=lambda: { + "message": "operation successful", + "data": { + "lookupParam": "100001241210170958777131925463_DANIEL ADEBAYO ADEDOYIN_22000000051_3", + "beneficiaryAccountNumber": "8168208035", + "beneficiaryBankCode": "100004", + "beneficiaryAccountName": "DANIEL ADEBAYO ADEDOYIN", + }, + "code": "00", + "error": "", + }, + ) + fld = IMTONameEnquiryField() + with pytest.raises(ValidationError, match="Wrong account holder mimmo"): + assert fld.validate("bank|account|mimmo") is None + + +def test_imto_name_enquiry_ko_value_error(): + fld = IMTONameEnquiryField() + with pytest.raises(ValidationError, match="ValueError: not enough values to unpack"): + fld.validate("only_one_value") + + +@pytest.mark.django_db +@patch("aurora.core.fields.imto.requests.post") +def test_imto_name_enquiry_error_status_code(mock_post): + mock_post.return_value = Mock( + status_code=400, + json=lambda: { + "message": "operation successful", + "data": { + "lookupParam": "100001241210170958777131925463_DANIEL ADEBAYO ADEDOYIN_22000000051_3", + "beneficiaryAccountNumber": "8168208035", + "beneficiaryBankCode": "100004", + "beneficiaryAccountName": "DANIEL ADEBAYO ADEDOYIN", + }, + "code": "00", + "error": "", + }, + ) + fld = IMTONameEnquiryField() + with pytest.raises(ValidationError, match="Error 400"): + assert fld.validate("bank|account|mimmo") is None + + +@pytest.mark.django_db +@patch("aurora.core.fields.imto.requests.post") +def test_imto_name_enquiry_error_500(mock_post): + mock_post.return_value = Mock( + status_code=500, + reason="Internal Server Error", + json=lambda: { + "message": "operation successful", + "data": { + "lookupParam": "100001241210170958777131925463_DANIEL ADEBAYO ADEDOYIN_22000000051_3", + "beneficiaryAccountNumber": "8168208035", + "beneficiaryBankCode": "100004", + "beneficiaryAccountName": "DANIEL ADEBAYO ADEDOYIN", + }, + "code": "00", + "error": "", + }, + ) + fld = IMTONameEnquiryField() + with pytest.raises(ValidationError, match="Internal Server Error"): + assert fld.validate("bank|account|mimmo") is None + + +@pytest.mark.django_db +@patch("aurora.core.fields.imto.requests.post") +def test_imto_name_enquiry_error_error(mock_post): + mock_post.return_value = Mock( + status_code=400, + json=lambda: { + "message": "operation successful", + "data": { + "lookupParam": "100001241210170958777131925463_DANIEL ADEBAYO ADEDOYIN_22000000051_3", + "beneficiaryAccountNumber": "8168208035", + "beneficiaryBankCode": "100004", + "beneficiaryAccountName": "DANIEL ADEBAYO ADEDOYIN", + }, + "code": "00", + "error": "Bad Request", + }, + ) + fld = IMTONameEnquiryField() + with pytest.raises(ValidationError, match="Bad Request"): + assert fld.validate("bank|account|mimmo") is None