diff --git a/docs/authors.rst b/docs/authors.rst index 8c0bb7c8..0bc2dc5f 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -96,6 +96,7 @@ Authors * Matias Dinota * Michał Sałaban * Mike Lissner +* Mohammed Al-Abdulhadi * Morgane Alonso * Naglis Jonaitis * Nishit Shah diff --git a/docs/changelog.rst b/docs/changelog.rst index 7227737b..9d93020e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,10 @@ Modifications to existing flavors: - Change `Kiev` to `Kyiv` 🇺🇦 according to ISO_3166-2:UA - Accept French Postal Services identifiers in forms (`gh-505 `_). +- Extended validation of Kuwaiti Civil ID to avoid allowing invalid century Civil IDs + (`gh-511 `_). +- Added birthdate extraction function from Kuwaiti Civil ID + (`gh-511 `_). Other changes: diff --git a/localflavor/kw/forms.py b/localflavor/kw/forms.py index 67aebb62..798a2fa7 100644 --- a/localflavor/kw/forms.py +++ b/localflavor/kw/forms.py @@ -1,7 +1,5 @@ """Kuwait-specific Form helpers.""" import re -import textwrap -from datetime import date from django.forms import ValidationError from django.forms.fields import RegexField, Select @@ -9,6 +7,7 @@ from .kw_areas import AREA_CHOICES from .kw_governorates import GOVERNORATE_CHOICES +from .utils import is_valid_civil_id id_re = re.compile(r'''^(?P\d) (?P\d\d) @@ -19,16 +18,7 @@ def is_valid_kw_civilid_checksum(value): - weight = (2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2) - calculated_checksum = 0 - for i in range(11): - calculated_checksum += int(value[i]) * weight[i] - - remainder = calculated_checksum % 11 - checkdigit = 11 - remainder - if checkdigit != int(value[11]): - return False - return True + return is_valid_civil_id(value) class KWCivilIDNumberField(RegexField): @@ -37,6 +27,7 @@ class KWCivilIDNumberField(RegexField): Checks the following rules to determine the validity of the number: * The number consist of 12 digits. + * The first(century) digit should be 1, 2, or 3. * The birthdate of the person is a valid date. * The calculated checksum equals to the last digit of the Civil ID. """ @@ -56,22 +47,7 @@ def clean(self, value): if value in self.empty_values: return value - cc = value[0] # Century value - yy, mm, dd = textwrap.wrap(value[1:7], 2) # pylint: disable=unbalanced-tuple-unpacking - - # Fix the dates so that those born - # in 2000+ pass the validation check - if int(cc) == 3: - yy = '20{}'.format(yy) - elif int(cc) == 2: - yy = '19{}'.format(yy) - - try: - date(int(yy), int(mm), int(dd)) - except ValueError: - raise ValidationError(self.error_messages['invalid'], code='invalid') - - if not is_valid_kw_civilid_checksum(value): + if not is_valid_civil_id(value): raise ValidationError(self.error_messages['invalid'], code='invalid') return value diff --git a/localflavor/kw/utils.py b/localflavor/kw/utils.py new file mode 100644 index 00000000..ee54bae0 --- /dev/null +++ b/localflavor/kw/utils.py @@ -0,0 +1,46 @@ +from datetime import date + + +def is_valid_civil_id(cid): + """ + Checks the validity of a Kuwaiti Civil ID number + by verifying the following: + * The number should consist of 12 digits + * The first digit should be 1, 2, or 3 + * The extracted birthdate should be a valid date + * The checksum should be equal to the last digit of the Civil ID + """ + # Civil ID can only start with 1, 2, or 3 till year 2100 + if len(cid) != 12 or not cid.isdigit() or cid[0] not in ('1', '2', '3'): + return False + + # calculate the Civil ID checksum + weight = (2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2) + initial = sum(x * y for x, y in zip(weight, map(int, cid[:-1]))) % 11 + checksum = 11 - initial + + # extract birthdate to check if it's a valid date + try: + get_birthdate_from_civil_id(cid) + except ValueError: + return False + + # verify if the checksum matches the last digit + return checksum == int(cid[11]) + + +def get_birthdate_from_civil_id(cid): + """ + Extracts the birthdate from a Kuwaiti Civil ID number + """ + by_century = { + '1': '18', + '2': '19', + '3': '20' + } + if cid[0] not in ('1', '2', '3'): + raise ValueError('Invalid first digit') + year = int('{}{}'.format(by_century[cid[0]], cid[1:3])) + month = int(cid[3:5]) + day = int(cid[5:7]) + return date(year, month, day) diff --git a/tests/test_kw.py b/tests/test_kw.py index e66330e9..057fa045 100644 --- a/tests/test_kw.py +++ b/tests/test_kw.py @@ -1,10 +1,38 @@ +from datetime import date + from django.test import SimpleTestCase from localflavor.kw.forms import KWAreaSelect, KWCivilIDNumberField, KWGovernorateSelect +from localflavor.kw.utils import is_valid_civil_id, get_birthdate_from_civil_id class KWLocalFlavorTests(SimpleTestCase): maxDiff = None + + def test_civil_id_checksum_valid(self): + self.assertTrue(is_valid_civil_id('286101901541')) + self.assertTrue(is_valid_civil_id('300092400929')) + self.assertTrue(is_valid_civil_id('282040701483')) + + def test_civil_id_checksum_invalid(self): + self.assertFalse(is_valid_civil_id('486101910006')) + self.assertFalse(is_valid_civil_id('289332013455')) + self.assertFalse(is_valid_civil_id('286191911111')) + + def test_get_birthdate_from_civil_id(self): + self.assertEqual( + get_birthdate_from_civil_id('286101901541'), + date(1986, 10, 19) + ) + self.assertEqual( + get_birthdate_from_civil_id('304022600325'), + date(2004, 2, 26) + ) + + def test_get_birthdate_from_civil_id_invalid_century(self): + self.assertRaises(ValueError, get_birthdate_from_civil_id, '486101910006') + self.assertRaises(ValueError, get_birthdate_from_civil_id, '886101910006') + def test_KWCivilIDNumberField(self): error_invalid = ['Enter a valid Kuwaiti Civil ID number'] valid = { @@ -17,6 +45,8 @@ def test_KWCivilIDNumberField(self): '300000000005': error_invalid, '289332Ol3455': error_invalid, '2*9332013455': error_invalid, + '486101911111': error_invalid, + '286191911111': error_invalid, } self.assertFieldOutput(KWCivilIDNumberField, valid, invalid)