diff --git a/docs/authors.rst b/docs/authors.rst index b83f031e5..923022616 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -80,6 +80,7 @@ Authors * Stefan Kjartansson * tadeo * Thiago Avelino +* Thor K. Høgås * Tino de Bruijn * Trey Hunner * Tyler Ball diff --git a/docs/changelog.rst b/docs/changelog.rst index a039693bf..250761583 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,8 @@ New flavors: New fields for existing flavors: -- None +- Added NOBankAccountNumber form field. + (`gh-275 `_) Modifications to existing flavors: diff --git a/localflavor/no/forms.py b/localflavor/no/forms.py index 9d564e93a..7d7369ddd 100644 --- a/localflavor/no/forms.py +++ b/localflavor/no/forms.py @@ -7,11 +7,10 @@ from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select +from django.forms.fields import CharField, Field, RegexField, Select from django.utils.translation import ugettext_lazy as _ from localflavor.generic.forms import DeprecatedPhoneNumberFormFieldMixin - from .no_municipalities import MUNICIPALITY_CHOICES @@ -98,6 +97,70 @@ def _get_birthday(self, value): return birthday +class NOBankAccountNumber(CharField): + """ + A form field for Norwegian bank account numbers. + + Performs MOD11 with the custom weights for the Norwegian bank account numbers, + including a check for a remainder of 0, in which event the checksum is also 0. + + Usually their string representation is along the lines of ZZZZ.YY.XXXXX, where the last X is the check digit. + They're always a total of 11 digits long, with 10 out of these 11 being the actual account number itself. + + * Accepts, and strips, account numbers with extra spaces. + * Accepts, and strips, account numbers provided in form of XXXX.YY.XXXXX. + + .. note:: No consideration is taking for banking clearing numbers as of yet, seeing as these are only used between + banks themselves. + + .. versionadded:: 1.5 + """ + + default_error_messages = { + 'invalid': _('Enter a valid Norwegian bank account number.'), + 'invalid_checksum': _('Invalid control digit. Enter a valid Norwegian bank account number.'), + 'invalid_length': _('Invalid length. Norwegian bank account numbers are 11 digits long.'), + } + + def validate(self, value): + super(NOBankAccountNumber, self).validate(value) + + if value is '': + # It's alright to be empty. + return + elif not value.isdigit(): + # You must only contain decimals. + raise ValidationError(self.error_messages['invalid']) + elif len(value) is not 11: + # They only have one length: the number is 10! + # That being said, you always store them with the check digit included, so 11. + raise ValidationError(self.error_messages['invalid_length']) + + # The control/check digit is the last digit + check_digit = int(value[-1]) + bank_number = value[:-1] + + # These are the weights by which we multiply to get our checksum digit + weights = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2] + result = sum(w * (int(x)) for w, x in zip(weights, bank_number)) + remainder = result % 11 + # The checksum is 0 in the event there's no remainder, seeing as we cannot have a checksum of 11 + # when 11 is one digit longer than we've got room for + checksum = 0 if remainder is 0 else 11 - remainder + + if checksum != check_digit: + raise ValidationError(self.error_messages['invalid_checksum']) + + def to_python(self, value): + value = super(NOBankAccountNumber, self).to_python(value) + return value.replace('.', '').replace(' ', '') + + def prepare_value(self, value): + if value in EMPTY_VALUES: + return value + return '{}.{}.{}'.format(value[0:4], value[4:6], value[6:11]) + + class NOPhoneNumberField(RegexField, DeprecatedPhoneNumberFormFieldMixin): """ Field with phonenumber validation. diff --git a/tests/test_no.py b/tests/test_no.py index cfc5fe6dd..9052c09a5 100644 --- a/tests/test_no.py +++ b/tests/test_no.py @@ -3,7 +3,8 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.translation import override -from localflavor.no.forms import NOMunicipalitySelect, NOPhoneNumberField, NOSocialSecurityNumber, NOZipCodeField +from localflavor.no.forms import (NOBankAccountNumber, NOMunicipalitySelect, NOPhoneNumberField, NOSocialSecurityNumber, + NOZipCodeField) class NOLocalFlavorTests(SimpleTestCase): @@ -38,6 +39,39 @@ def test_NOPhoneNumberField(self): } self.assertFieldOutput(NOPhoneNumberField, valid, invalid) + def test_NOBankAccountNumber(self): + error_format = [_('Enter a valid Norwegian bank account number.')] + error_checksum = [_('Invalid control digit. Enter a valid Norwegian bank account number.')] + error_length = [_('Invalid length. Norwegian bank account numbers are 11 digits long.')] + + # A good source of loads of highly-likely-to-be-valid examples are available at + # http://www.skatteetaten.no/no/Person/Skatteoppgjor/Restskatt/Kontonummer-til-skatteoppkreverkontorene/ + valid = { + '7694 05 12057': '76940512057', + '7694.05.12057': '76940512057', + '7694.05.12057 ': '76940512057', + '1111.00.22222': '11110022222', + '5555.88.43216': '55558843216', + '63450618537': '63450618537', + ' 6345.06.20027 ': '63450620027', + } + invalid = { + '76940512056': error_checksum, # invalid check digit + '1111.00.22228': error_checksum, # invalid check digit + 'abcdefgh': error_format, # illegal characters, though it'll fail to create the checksum + '1111a00b22222': error_format, # illegal characters + '769405120569': error_length, # invalid length (and control number for that matter) + } + self.assertFieldOutput(NOBankAccountNumber, valid, invalid) + + def test_NOBankAccountNumber_formatting(self): + form = NOBankAccountNumber() + self.assertEqual(form.prepare_value('76940512057'), '7694.05.12057') + # In the event there's already empty/blank/null values present. + # Any invalid data should be stopped by form.validate, which the above test should take care of. + self.assertEqual(form.prepare_value(None), None) + self.assertEqual(form.prepare_value(''), '') + def test_NOSocialSecurityNumber(self): error_format = [_('Enter a valid Norwegian social security number.')]