diff --git a/localflavor/generic/checksums.py b/localflavor/generic/checksums.py index 351c9e65d..8c262d80b 100644 --- a/localflavor/generic/checksums.py +++ b/localflavor/generic/checksums.py @@ -3,9 +3,10 @@ """ from django.utils import six -__all__ = ['luhn'] +__all__ = ['luhn', 'ean'] LUHN_ODD_LOOKUP = (0, 2, 4, 6, 8, 1, 3, 5, 7, 9) # sum_of_digits(index * 2) +EAN_LOOKUP = (3, 1) def luhn(candidate): @@ -22,3 +23,24 @@ def luhn(candidate): return ((evens + odds) % 10 == 0) except ValueError: # Raised if an int conversion fails return False + + +def ean(candidate): + """ + Checks a candidate number for validity according to the EAN checksum calculation. + Note that this validator does not enforce any length checks (usually 13 or 8). + + http://en.wikipedia.org/wiki/International_Article_Number_(EAN) + """ + if not isinstance(candidate, six.string_types): + candidate = str(candidate) + if len(candidate) <= 1: + return False + given_number, given_checksum = candidate[:-1], candidate[-1] + try: + calculated_checksum = sum( + int(digit) * EAN_LOOKUP[i % 2] for i, digit in enumerate(reversed(given_number))) + calculated_checksum = 10 - (calculated_checksum % 10) + return str(calculated_checksum) == given_checksum + except ValueError: # Raised if an int conversion fails + return False diff --git a/localflavor/generic/validators.py b/localflavor/generic/validators.py index d8500cd73..8c978387c 100644 --- a/localflavor/generic/validators.py +++ b/localflavor/generic/validators.py @@ -1,11 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import re + import string from django.core.exceptions import ValidationError, ImproperlyConfigured from django.utils.translation import ugettext_lazy as _ + from .countries.iso_3166 import ISO_3166_1_ALPHA2_COUNTRY_CODES +from . import checksums # Dictionary of ISO country code to IBAN length. # @@ -20,6 +24,7 @@ # http://www.ecbs.org/iban/france-bank-account-number.html # https://www.nordea.com/V%C3%A5ra+tj%C3%A4nster/Internationella+produkter+och+tj%C3%A4nster/Cash+Management/IBAN+countries/908472.html + IBAN_COUNTRY_CODE_LENGTH = {'AL': 28, # Albania 'AD': 24, # Andorra 'AE': 23, # United Arab Emirates @@ -209,3 +214,25 @@ def __call__(self, value): country_code = value[4:6] if country_code not in ISO_3166_1_ALPHA2_COUNTRY_CODES: raise ValidationError(_('%s is not a valid country code.') % country_code) + + +class EANValidator(object): + """ + A generic validator for EAN like codes with the last digit being the checksum. + + http://en.wikipedia.org/wiki/International_Article_Number_(EAN) + """ + message = _('Not a valid EAN code.') + + def __init__(self, strip_nondigits=False, message=None): + if message is not None: + self.message = message + self.strip_nondigits = strip_nondigits + + def __call__(self, value): + if value is None: + return value + if self.strip_nondigits: + value = re.compile(r'[^\d]+').sub('', value) + if not checksums.ean(value): + raise ValidationError(self.message) diff --git a/tests/test_checksums.py b/tests/test_checksums.py index e3e34b1dc..dae5a5e57 100644 --- a/tests/test_checksums.py +++ b/tests/test_checksums.py @@ -5,11 +5,16 @@ class TestUtilsChecksums(unittest.TestCase): + def assertChecksumOuput(self, checksum, result_pairs): + for value, output in result_pairs: + self.assertEqual(checksum(value), output, "Expected %s(%s) == %s but got %s" % ( + checksum.__name__, repr(value), output, not output)) + def test_luhn(self): """ Check that function(value) equals output. """ - items = ( + result_pairs = ( (4111111111111111, True), ('4111111111111111', True), (4222222222222, True), @@ -30,5 +35,18 @@ def test_luhn(self): (None, False), (object(), False), ) - for value, output in items: - self.assertEqual(checksums.luhn(value), output, value) + self.assertChecksumOuput(checksums.luhn, result_pairs) + + def test_ean(self): + result_pairs = ( + ('73513537', True), + (73513537, True), + ('73513538', False), + (73513538, False), + ('4006381333931', True), + (4006381333931, True), + ('abc', False), + (None, False), + (object(), False), + ) + self.assertChecksumOuput(checksums.ean, result_pairs) diff --git a/tests/test_generic.py b/tests/test_generic.py index 44493c49b..2dee5260c 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -7,7 +7,7 @@ from localflavor.generic.countries.sepa import IBAN_SEPA_COUNTRIES from localflavor.generic.models import BICField, IBANField -from localflavor.generic.validators import BICValidator, IBANValidator +from localflavor.generic.validators import BICValidator, IBANValidator, EANValidator from localflavor.generic.forms import DateField, DateTimeField, SplitDateTimeField, BICFormField, IBANFormField @@ -337,3 +337,63 @@ def test_bic_model_field(self): def test_default_form(self): bic_model_field = BICField() self.assertEqual(type(bic_model_field.formfield()), type(BICFormField())) + + +class EANTests(TestCase): + + def test_ean_validator(self): + valid = [ + '4006381333931', + '73513537', + + '012345678905', + '0012345678905', + + None, + ] + error_message = 'Not a valid EAN code.' + invalid = [ + '400.6381.3339.31', + '4006381333930', + '', + '0', + 'DÉUTDEFF', + ] + + validator = EANValidator() + for value in valid: + validator(value) + + for value in invalid: + self.assertRaisesMessage(ValidationError, error_message, validator, value) + + def test_ean_validator_strip_nondigits(self): + valid = [ + '4006381333931', + '400.6381.3339.31', + '73513537', + '73-51-3537', + '73 51 3537', + '73A51B3537', + + '012345678905', + '0012345678905', + + None, + ] + error_message = 'Not a valid EAN code.' + invalid = [ + '4006381333930', + '400-63-813-339-30', + '400 63 813 339 30', + '', + '0', + 'DÉUTDEFF', + ] + + validator = EANValidator(strip_nondigits=True) + for value in valid: + validator(value) + + for value in invalid: + self.assertRaisesMessage(ValidationError, error_message, validator, value)