diff --git a/docs/authors.rst b/docs/authors.rst index 8825321a..0480f295 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -125,3 +125,4 @@ Authors * Vishal Pandey * Vladimir Nani * Abhineet Tamrakar +* Shalom Nyende diff --git a/docs/changelog.rst b/docs/changelog.rst index a0a4db76..e5ebfb31 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,7 @@ Changelog ------------------ New flavors: +- Kenya localflavor - Nepal LocalFlavor: Support for Nepal added (`gh-451 `_). diff --git a/localflavor/ke/__init__.py b/localflavor/ke/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/localflavor/ke/deprecation.py b/localflavor/ke/deprecation.py new file mode 100644 index 00000000..fb80ff5f --- /dev/null +++ b/localflavor/ke/deprecation.py @@ -0,0 +1,2 @@ +class RemovedInLocalflavor30Warning(PendingDeprecationWarning): + ... \ No newline at end of file diff --git a/localflavor/ke/forms.py b/localflavor/ke/forms.py new file mode 100644 index 00000000..3c6f929b --- /dev/null +++ b/localflavor/ke/forms.py @@ -0,0 +1,199 @@ +"""Kenya-specific Form Helpers""" + +import re + +from django.forms import ValidationError +from django.forms.fields import CharField, RegexField, Select +from django.utils.translation import gettext_lazy as _ + +from .ke_counties import COUNTY_CHOICES + +ke_po_box_re = re.compile(r"\A\d{5,5}\Z") +ke_kra_pin_regex = re.compile(r"^(A|P)\d{9}[A-Z]$") +ke_passport_regex = re.compile(r"^[A-Z]\d{6,7}$") +ke_national_id_regex = re.compile(r"^\d{7,8}$") + + +class KEPostalCodeField(CharField): + """ + A form field that validates its input as a Kenyan Postal Code. + .. versionadded:: 4.0 + """ + + default_error_messages = { + "invalid": _("Enter a valid Kenyan Postal code in the format 12345") + } + + def clean(self, value: str): + """Validates KE Postal Code + + Args: + value (_type_): _description_ + + Raises: + ValidationError: _description_ + + Returns: + _type_: _description_ + """ + value = super().clean(value) + if value in self.empty_values: + return self.empty_value + + # Strip out spaces and dashes + value = value.replace(" ", "").replace("-", "") + match = re.match(ke_po_box_re, value) + if not match: + raise ValidationError(self.error_messages.get("invalid")) + return value + + +class KEKRAPINField(CharField): + """ + TODO + + A form field that validates input as a Kenya Revenue Authority PIN + (Personal Identification Number) Number. + + A Kenyan KRA (Kenya Revenue Authority) PIN (Personal Identification Number) + + is typically 11 characters long, consisting of the letter 'A' or 'P' followed + + by 9 digits and ending with a letter (e.g., A123456789B or P987654321C). + + Validates 2 different formats: + + POXXXXXXXX - Company/Institution + + AXXXXXXXXX - Individuals + + .. versionadded:: 4.0 + + """ + + default_error_messages = { + "invalid": _( + "Enter a valid Kenyan KRA PIN Number in the format A123456789B or P987654321C" + ), + } + + def clean(self, value): + """Runs the validation checks + + Args: + value (_type_): _description_ + + Raises: + ValidationError: _description_ + + Returns: + _type_: _description_ + """ + value = super().clean(value) + if value in self.empty_values: + return self.empty_value + + # Strip out spaces and dashes + value = value.replace(" ", "").replace("-", "") + match = re.match(ke_kra_pin_regex, value) + if not match: + raise ValidationError(self.error_messages.get("invalid")) + return value.upper() + + +class KENationalIDNumberField(CharField): + """ + A form field that validates its input as a Kenyan National ID Number. + .. versionadded:: 4.0 + """ + + default_error_messages = { + "invalid": _( + "Enter a valid Kenyan National ID Number in the format 1234567 or 12345678" + ) + } + + def clean(self, value): + """Runs the validation checks for KE National ID Number""" + value = super().clean(value) + if value in self.empty_values: + return self.empty_value + + # Strip out spaces and dashes + value = value.replace(" ", "").replace("-", "") + match = re.match(ke_national_id_regex, value) + if not match: + raise ValidationError(self.error_messages.get("invalid")) + return value + + +class KEPassportNumberField(CharField): + """ + A form field that validates its input as a Kenyan Passport Number. + .. versionadded:: 4.0 + """ + + default_error_messages = { + "invalid": _( + "Enter a valid Kenyan Passport Number in the format A123456 or B1234567" + ) + } + + def clean(self, value): + """Runs the validation checks for KE Passport Number""" + value = super().clean(value) + if value in self.empty_values: + return self.empty_value + + # Strip out spaces and dashes + value = value.replace(" ", "").replace("-", "") + match = re.match(ke_passport_regex, value) + if not match: + raise ValidationError(self.error_messages.get("invalid")) + return value.upper() + + +class KENSSFNumberField(RegexField): + """ + TODO + + Kenya National Social Security Fund + """ + + ... + + +class KENHIFNumberField(RegexField): + """ + TODO + + Kenya National Hospital Insurance Fund + """ + + ... + + +class KECompanyRegNumberField(RegexField): + """ + Kenya Companies Reg. Number + """ + + ... + + +class KEPayBillNumber(RegexField): + """ + MPESA PayBill + """ + + ... + + +class KECountySelectField(Select): + """ + A Select widget listing Kenyan Counties as the choices + .. versionadded:: 4.0 + """ + + def __init__(self, attrs=None) -> None: + super().__init__(attrs, choices=COUNTY_CHOICES) diff --git a/localflavor/ke/ke_counties.py b/localflavor/ke/ke_counties.py new file mode 100644 index 00000000..0536e632 --- /dev/null +++ b/localflavor/ke/ke_counties.py @@ -0,0 +1,56 @@ +""" +Kenya Counties Data +""" + +from django.utils.translation import gettext_lazy as _ + +# The 47 counties of Kenya +COUNTY_CHOICES = ( + ("MOMBASA", _("MOMBASA")), + ("KWALE", _("KWALE")), + ("KILIFI", _("KILIFI")), + ("TANA RIVER", _("TANA RIVER")), + ("LAMU", _("LAMU")), + ("TAITA-TAVETA", _("TAITA-TAVETA")), + ("GARISSA", _("GARISSA")), + ("WAJIR", _("WAJIR")), + ("MANDERA", _("MANDERA")), + ("MARSABIT", _("MARSABIT")), + ("ISIOLO", _("ISIOLO")), + ("MERU", _("MERU")), + ("THARAKA-NITHI", _("THARAKA-NITHI")), + ("EMBU", _("EMBU")), + ("KITUI", _("KITUI")), + ("MACHAKOS", _("MACHAKOS")), + ("MAKUENI", _("MAKUENI")), + ("NYANDARUA", _("NYANDARUA")), + ("NYERI", _("NYERI")), + ("KIRINYAGA", _("KIRINYAGA")), + ("MURANGA", _("MURANGA")), + ("KIAMBU", _("KIAMBU")), + ("TURKANA", _("TURKANA")), + ("WEST POKOT", _("WEST POKOT")), + ("SAMBURU", _("SAMBURU")), + ("TRANS-NZOIA", _("TRANS-NZOIA")), + ("UASIN GISHU", _("UASIN GISHU")), + ("ELGEYO-MARAKWET", _("ELGEYO-MARAKWET")), + ("NANDI", _("NANDI")), + ("BARINGO", _("BARINGO")), + ("LAIKIPIA", _("LAIKIPIA")), + ("NAKURU", _("NAKURU")), + ("NAROK", _("NAROK")), + ("KAJIADO", _("KAJIADO")), + ("KERICHO", _("KERICHO")), + ("BOMET", _("BOMET")), + ("KAKAMEGA", _("KAKAMEGA")), + ("VIHIGA", _("VIHIGA")), + ("BUNGOMA", _("BUNGOMA")), + ("BUSIA", _("BUSIA")), + ("SIAYA", _("SIAYA")), + ("KISUMU", _("KISUMU")), + ("HOMA BAY", _("HOMA BAY")), + ("MIGORI", _("MIGORI")), + ("KISII", _("KISII")), + ("NYAMIRA", _("NYAMIRA")), + ("NAIROBI", _("NAIROBI")), +) diff --git a/localflavor/ke/models.py b/localflavor/ke/models.py new file mode 100644 index 00000000..73667c6a --- /dev/null +++ b/localflavor/ke/models.py @@ -0,0 +1,34 @@ +from typing import Any +from django.db.models import CharField +from django.utils.translation import gettext_lazy as _ + +from .forms import ( + KENationalIDNumberField, + KEKRAPINField, + KENHIFNumberField, + KENSSFNumberField, + KEPassportNumberField, + KEPostalCodeField as KEPostalCodeFormField, +) + + +class KEPostalCodeField(CharField): + """ + A model field that stores the Kenyan Postal Codes + .. versionadded:: 4.0 + """ + description = _("Kenya Postal Code") + + def __init__(self, *args, **kwargs) -> None: + kwargs.update(max_length=8) + super().__init__(*args, **kwargs) + + def formfield(self, **kwargs) -> Any: + defaults = {"form_class": KEPostalCodeFormField} + defaults.update(kwargs) + return super().formfield(**defaults) + + + + + \ No newline at end of file diff --git a/tests/test_ke.py b/tests/test_ke.py new file mode 100644 index 00000000..4446e191 --- /dev/null +++ b/tests/test_ke.py @@ -0,0 +1,59 @@ +from django.test import SimpleTestCase +from django.forms import ValidationError +from .forms import KEPostalCodeField, KEKRAPINField, KENationalIDNumberField, KEPassportNumberField + +class KEPostalCodeFieldTest(SimpleTestCase): + def test_valid_postal_code(self): + field = KEPostalCodeField() + valid_postal_codes = ["12345", "54321"] + for postal_code in valid_postal_codes: + self.assertEqual(field.clean(postal_code), "12345") + + def test_invalid_postal_code(self): + field = KEPostalCodeField() + invalid_postal_codes = ["1234", "ABCDE", "12 345", "12-345"] + for postal_code in invalid_postal_codes: + with self.assertRaises(ValidationError): + field.clean(postal_code) + +class KEKRAPINFieldTest(SimpleTestCase): + def test_valid_kra_pin(self): + field = KEKRAPINField() + valid_pins = ["A123456789B", "P987654321C"] + for pin in valid_pins: + self.assertEqual(field.clean(pin), pin) + + def test_invalid_kra_pin(self): + field = KEKRAPINField() + invalid_pins = ["1234567890", "A123456789", "P987654321", "A12-3456789B"] + for pin in invalid_pins: + with self.assertRaises(ValidationError): + field.clean(pin) + +class KENationalIDNumberFieldTest(SimpleTestCase): + def test_valid_national_id(self): + field = KENationalIDNumberField() + valid_ids = ["1234567", "12345678"] + for id_number in valid_ids: + self.assertEqual(field.clean(id_number), id_number) + + def test_invalid_national_id(self): + field = KENationalIDNumberField() + invalid_ids = ["12345", "12345A", "12-34567", "123456789"] + for id_number in invalid_ids: + with self.assertRaises(ValidationError): + field.clean(id_number) + +class KEPassportNumberFieldTest(SimpleTestCase): + def test_valid_passport_number(self): + field = KEPassportNumberField() + valid_passports = ["A123456", "B1234567"] + for passport in valid_passports: + self.assertEqual(field.clean(passport), passport) + + def test_invalid_passport_number(self): + field = KEPassportNumberField() + invalid_passports = ["12345", "A1234567B", "AB-123456"] + for passport in invalid_passports: + with self.assertRaises(ValidationError): + field.clean(passport)