diff --git a/AUTHORS b/AUTHORS index 825ba8e658..363c7df100 100644 --- a/AUTHORS +++ b/AUTHORS @@ -257,6 +257,7 @@ answer newbie questions, and generally made Django that much better: Brian Ray remco@diji.biz rhettg@gmail.com + ricardojbarrios@gmail.com Matt Riggott Henrique Romano Armin Ronacher diff --git a/django/contrib/localflavor/es/__init__.py b/django/contrib/localflavor/es/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/contrib/localflavor/es/es_provinces.py b/django/contrib/localflavor/es/es_provinces.py new file mode 100644 index 0000000000..9f5e12680b --- /dev/null +++ b/django/contrib/localflavor/es/es_provinces.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +from django.utils.translation import ugettext_lazy as _ + +PROVINCE_CHOICES = ( + ('01', _('Arava')), + ('02', _('Albacete')), + ('03', _('Alacant')), + ('04', _('Almeria')), + ('05', _('Avila')), + ('06', _('Badajoz')), + ('07', _('Illes Balears')), + ('08', _('Barcelona')), + ('09', _('Burgos')), + ('10', _('Caceres')), + ('11', _('Cadiz')), + ('12', _('Castello')), + ('13', _('Ciudad Real')), + ('14', _('Cordoba')), + ('15', _('A Coruna')), + ('16', _('Cuenca')), + ('17', _('Girona')), + ('18', _('Granada')), + ('19', _('Guadalajara')), + ('20', _('Guipuzkoa')), + ('21', _('Huelva')), + ('22', _('Huesca')), + ('23', _('Jaen')), + ('24', _('Leon')), + ('25', _('Lleida')), + ('26', _('La Rioja')), + ('27', _('Lugo')), + ('28', _('Madrid')), + ('29', _('Malaga')), + ('30', _('Murcia')), + ('31', _('Navarre')), + ('32', _('Ourense')), + ('33', _('Asturias')), + ('34', _('Palencia')), + ('35', _('Las Palmas')), + ('36', _('Pontevedra')), + ('37', _('Salamanca')), + ('38', _('Santa Cruz de Tenerife')), + ('39', _('Cantabria')), + ('40', _('Segovia')), + ('41', _('Seville')), + ('42', _('Soria')), + ('43', _('Tarragona')), + ('44', _('Teruel')), + ('45', _('Toledo')), + ('46', _('Valencia')), + ('47', _('Valladolid')), + ('48', _('Bizkaia')), + ('49', _('Zamora')), + ('50', _('Zaragoza')), + ('51', _('Ceuta')), + ('52', _('Melilla')), +) + diff --git a/django/contrib/localflavor/es/es_regions.py b/django/contrib/localflavor/es/es_regions.py new file mode 100644 index 0000000000..3c1ea0e974 --- /dev/null +++ b/django/contrib/localflavor/es/es_regions.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from django.utils.translation import ugettext_lazy as _ + +REGION_CHOICES = ( + ('AN', _('Andalusia')), + ('AR', _('Aragon')), + ('O', _('Principality of Asturias')), + ('IB', _('Balearic Islands')), + ('PV', _('Basque Country')), + ('CN', _('Canary Islands')), + ('S', _('Cantabria')), + ('CM', _('Castile-La Mancha')), + ('CL', _('Castile and Leon')), + ('CT', _('Catalonia')), + ('EX', _('Extremadura')), + ('GA', _('Galicia')), + ('LO', _('La Rioja')), + ('M', _('Madrid')), + ('MU', _('Region of Murcia')), + ('NA', _('Foral Community of Navarre')), + ('VC', _('Valencian Community')), +) + diff --git a/django/contrib/localflavor/es/forms.py b/django/contrib/localflavor/es/forms.py new file mode 100644 index 0000000000..29b41828f6 --- /dev/null +++ b/django/contrib/localflavor/es/forms.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +""" +Spanish-specific Form helpers +""" + +from django.newforms import ValidationError +from django.newforms.fields import RegexField, Select, EMPTY_VALUES +from django.utils.translation import ugettext as _ +import re + +class ESPostalCodeField(RegexField): + """ + A form field that validates its input as a spanish postal code. + + Spanish postal code is a five digits string, with two first digits + between 01 and 52, assigned to provinces code. + """ + def __init__(self, *args, **kwargs): + super(ESPostalCodeField, self).__init__( + r'^(0[1-9]|[1-4][0-9]|5[0-2])\d{3}$', + max_length=None, min_length=None, + error_message=_('Enter a valid postal code in the range and format 01XXX - 52XXX.'), + *args, **kwargs) + +class ESPhoneNumberField(RegexField): + """ + A form field that validates its input as a Spanish phone number. + Information numbers are ommited. + + Spanish phone numbers are nine digit numbers, where first digit is 6 (for + cell phones), 8 (for special phones), or 9 (for landlines and special + phones) + + TODO: accept and strip characters like dot, hyphen... in phone number + """ + def __init__(self, *args, **kwargs): + super(ESPhoneNumberField, self).__init__(r'^(6|8|9)\d{8}$', + max_length=None, min_length=None, + error_message=_('Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'), + *args, **kwargs) + +class ESIdentityCardNumberField(RegexField): + """ + Spanish NIF/NIE/CIF (Fiscal Identification Number) code. + + Validates three diferent formats: + + NIF (individuals): 12345678A + CIF (companies): A12345678 + NIE (foreigners): X12345678A + + according to a couple of simple checksum algorithms. + + Value can include a space or hyphen separator between number and letters. + Number length is not checked for NIF (or NIE), old values start with a 1, + and future values can contain digits greater than 8. The CIF control digit + can be a number or a letter depending on company type. Algorithm is not + public, and different authors have different opinions on which ones allows + letters, so both validations are assumed true for all types. + """ + def __init__(self, only_nif=False, *args, **kwargs): + self.only_nif = only_nif + self.nif_control = 'TRWAGMYFPDXBNJZSQVHLCKE' + self.cif_control = 'JABCDEFGHI' + self.cif_types = 'ABCDEFGHKLMNPQS' + self.nie_types = 'XT' + if self.only_nif: + self.id_types = 'NIF or NIE' + else: + self.id_types = 'NIF, NIE, or CIF' + super(ESIdentityCardNumberField, self).__init__(r'^([%s]?)[ -]?(\d+)[ -]?([%s]?)$' % (self.cif_types + self.nie_types + self.cif_types.lower() + self.nie_types.lower(), self.nif_control + self.nif_control.lower()), + max_length=None, min_length=None, + error_message=_('Please enter a valid %s.' % self.id_types), + *args, **kwargs) + + def clean(self, value): + super(ESIdentityCardNumberField, self).clean(value) + if value in EMPTY_VALUES: + return u'' + nif_get_checksum = lambda d: self.nif_control[int(d)%23] + + value = value.upper().replace(' ', '').replace('-', '') + m = re.match(r'^([%s]?)[ -]?(\d+)[ -]?([%s]?)$' % (self.cif_types + self.nie_types, self.nif_control), value) + letter1, number, letter2 = m.groups() + + if not letter1 and letter2: + # NIF + if letter2 == nif_get_checksum(number): + return value + else: + raise ValidationError, _('Invalid checksum for NIF.') + elif letter1 in self.nie_types and letter2: + # NIE + if letter2 == nif_get_checksum(number): + return value + else: + raise ValidationError, _('Invalid checksum for NIE.') + elif not self.only_nif and letter1 in self.cif_types and len(number) in [7, 8]: + # CIF + if not letter2: + number, letter2 = number[:-1], int(number[-1]) + checksum = cif_get_checksum(number) + if letter2 in [checksum, self.cif_control[checksum]]: + return value + else: + raise ValidationError, _('Invalid checksum for CIF.') + else: + raise ValidationError, _('Please enter a valid %s.' % self.id_types) + +class ESCCCField(RegexField): + """ + A form field that validates its input as a Spanish bank account or CCC + (Codigo Cuenta Cliente). + + Spanish CCC is in format EEEE-OOOO-CC-AAAAAAAAAA where: + + E = entity + O = office + C = checksum + A = account + + It's also valid to use a space as delimiter, or to use no delimiter. + + First checksum digit validates entity and office, and last one + validates account. Validation is done multiplying every digit of 10 + digit value (with leading 0 if necessary) by number in its position in + string 1, 2, 4, 8, 5, 10, 9, 7, 3, 6. Sum resulting numbers and extract + it from 11. Result is checksum except when 10 then is 1, or when 11 + then is 0. + + TODO: allow IBAN validation too + """ + def __init__(self, *args, **kwargs): + super(ESCCCField, self).__init__(r'^\d{4}[ -]?\d{4}[ -]?\d{2}[ -]?\d{10}$', + max_length=None, min_length=None, + error_message=_('Please enter a valid bank account number in format XXXX-XXXX-XX-XXXXXXXXXX.'), + *args, **kwargs) + + def clean(self, value): + super(ESCCCField, self).clean(value) + if value in EMPTY_VALUES: + return u'' + control_str = [1, 2, 4, 8, 5, 10, 9, 7, 3, 6] + m = re.match(r'^(\d{4})[ -]?(\d{4})[ -]?(\d{2})[ -]?(\d{10})$', value) + entity, office, checksum, account = m.groups() + get_checksum = lambda d: str(11 - sum([int(digit) * int(control) for digit, control in zip(d, control_str)]) % 11).replace('10', '1').replace('11', '0') + if get_checksum('00' + entity + office) + get_checksum(account) == checksum: + return value + else: + raise ValidationError, _('Invalid checksum for bank account number.') + +class ESRegionSelect(Select): + """ + A Select widget that uses a list of spanish regions as its choices. + """ + def __init__(self, attrs=None): + from es_regions import REGION_CHOICES + super(ESRegionSelect, self).__init__(attrs, choices=REGION_CHOICES) + +class ESProvinceSelect(Select): + """ + A Select widget that uses a list of spanish provinces as its choices. + """ + def __init__(self, attrs=None): + from es_provinces import PROVINCE_CHOICES + super(ESProvinceSelect, self).__init__(attrs, choices=PROVINCE_CHOICES) + + +def cif_get_checksum(number): + s1 = sum([int(digit) for pos, digit in enumerate(number) if int(pos) % 2]) + s2 = sum([sum([int(unit) for unit in str(int(digit) * 2)]) for pos, digit in enumerate(number) if not int(pos) % 2]) + return 10 - ((s1 + s2) % 10) + diff --git a/tests/regressiontests/forms/localflavor/es.py b/tests/regressiontests/forms/localflavor/es.py new file mode 100644 index 0000000000..f149aa9cbe --- /dev/null +++ b/tests/regressiontests/forms/localflavor/es.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +# Tests for the contrib/localflavor/ ES form fields. + +tests = r""" +# ESPostalCodeField ############################################################## + +ESPostalCodeField validates that data is a five-digit spanish postal code. +>>> from django.contrib.localflavor.es.forms import ESPostalCodeField +>>> f = ESPostalCodeField() +>>> f.clean('08028') +u'08028' +>>> f.clean('28080') +u'28080' +>>> f.clean('53001') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('0801') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('080001') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('00999') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('08 01') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('08A01') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f = ESPostalCodeField(required=False) +>>> f.clean('08028') +u'08028' +>>> f.clean('28080') +u'28080' +>>> f.clean('53001') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('0801') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('080001') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('00999') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('08 01') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('08A01') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid postal code in the range and format 01XXX - 52XXX.'] +>>> f.clean('') +u'' + +# ESPhoneNumberField ############################################################## + +ESPhoneNumberField validates that data is a nine-digit spanish phone number. +>>> from django.contrib.localflavor.es.forms import ESPhoneNumberField +>>> f = ESPhoneNumberField() +>>> f.clean('650010101') +u'650010101' +>>> f.clean('931234567') +u'931234567' +>>> f.clean('800123123') +u'800123123' +>>> f.clean('555555555') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('789789789') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('99123123') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('9999123123') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f = ESPhoneNumberField(required=False) +>>> f.clean('650010101') +u'650010101' +>>> f.clean('931234567') +u'931234567' +>>> f.clean('800123123') +u'800123123' +>>> f.clean('555555555') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('789789789') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('99123123') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('9999123123') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid phone number in one of the formats 6XXXXXXXX, 8XXXXXXXX or 9XXXXXXXX.'] +>>> f.clean('') +u'' + +# ESIdentityCardNumberField ############################################################## + +ESIdentityCardNumberField validates that data is a identification spanish code for companies or individuals (CIF, NIF or NIE). +>>> from django.contrib.localflavor.es.forms import ESIdentityCardNumberField +>>> f = ESIdentityCardNumberField() +>>> f.clean('78699688J') +'78699688J' +>>> f.clean('78699688-J') +'78699688J' +>>> f.clean('78699688 J') +'78699688J' +>>> f.clean('78699688 j') +'78699688J' +>>> f.clean('78699688T') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for NIF.'] +>>> f.clean('X0901797J') +'X0901797J' +>>> f.clean('X-6124387-Q') +'X6124387Q' +>>> f.clean('X 0012953 G') +'X0012953G' +>>> f.clean('x-3287690-r') +'X3287690R' +>>> f.clean('t-03287690r') +'T03287690R' +>>> f.clean('X-03287690') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid NIF, NIE, or CIF.'] +>>> f.clean('X-03287690-T') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for NIE.'] +>>> f.clean('B38790911') +'B38790911' +>>> f.clean('B-3879091A') +'B3879091A' +>>> f.clean('B 38790917') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for CIF.'] +>>> f.clean('B 38790911') +'B38790911' +>>> f.clean('P-3900800-H') +'P3900800H' +>>> f.clean('P 39008008') +'P39008008' +>>> f.clean('C-28795565') +'C28795565' +>>> f.clean('C 2879556E') +'C2879556E' +>>> f.clean('C28795567') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for CIF.'] +>>> f.clean('I38790911') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid NIF, NIE, or CIF.'] +>>> f.clean('78699688-2') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid NIF, NIE, or CIF.'] +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f = ESIdentityCardNumberField(required=False) +>>> f.clean('78699688J') +'78699688J' +>>> f.clean('78699688-J') +'78699688J' +>>> f.clean('78699688 J') +'78699688J' +>>> f.clean('78699688 j') +'78699688J' +>>> f.clean('78699688T') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for NIF.'] +>>> f.clean('X0901797J') +'X0901797J' +>>> f.clean('X-6124387-Q') +'X6124387Q' +>>> f.clean('X 0012953 G') +'X0012953G' +>>> f.clean('x-3287690-r') +'X3287690R' +>>> f.clean('t-03287690r') +'T03287690R' +>>> f.clean('X-03287690') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid NIF, NIE, or CIF.'] +>>> f.clean('X-03287690-T') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for NIE.'] +>>> f.clean('B38790911') +'B38790911' +>>> f.clean('B-3879091A') +'B3879091A' +>>> f.clean('B 38790917') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for CIF.'] +>>> f.clean('B 38790911') +'B38790911' +>>> f.clean('P-3900800-H') +'P3900800H' +>>> f.clean('P 39008008') +'P39008008' +>>> f.clean('C-28795565') +'C28795565' +>>> f.clean('C 2879556E') +'C2879556E' +>>> f.clean('C28795567') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for CIF.'] +>>> f.clean('I38790911') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid NIF, NIE, or CIF.'] +>>> f.clean('78699688-2') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid NIF, NIE, or CIF.'] +>>> f.clean('') +u'' + +# ESCCCField ############################################################## + +ESCCCField validates that data is a spanish bank account number (codigo cuenta cliente). + +>>> from django.contrib.localflavor.es.forms import ESCCCField +>>> f = ESCCCField() +>>> f.clean('20770338793100254321') +'20770338793100254321' +>>> f.clean('2077 0338 79 3100254321') +'2077 0338 79 3100254321' +>>> f.clean('2077-0338-79-3100254321') +'2077-0338-79-3100254321' +>>> f.clean('2077.0338.79.3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid bank account number in format XXXX-XXXX-XX-XXXXXXXXXX.'] +>>> f.clean('2077-0338-78-3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for bank account number.'] +>>> f.clean('2077-0338-89-3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for bank account number.'] +>>> f.clean('2077-03-3879-3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid bank account number in format XXXX-XXXX-XX-XXXXXXXXXX.'] +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f = ESCCCField(required=False) +>>> f.clean('20770338793100254321') +'20770338793100254321' +>>> f.clean('2077 0338 79 3100254321') +'2077 0338 79 3100254321' +>>> f.clean('2077-0338-79-3100254321') +'2077-0338-79-3100254321' +>>> f.clean('2077.0338.79.3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid bank account number in format XXXX-XXXX-XX-XXXXXXXXXX.'] +>>> f.clean('2077-0338-78-3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for bank account number.'] +>>> f.clean('2077-0338-89-3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Invalid checksum for bank account number.'] +>>> f.clean('2077-03-3879-3100254321') +Traceback (most recent call last): +... +ValidationError: [u'Please enter a valid bank account number in format XXXX-XXXX-XX-XXXXXXXXXX.'] +>>> f.clean('') +u'' + +# ESRegionSelect ############################################################## + +ESRegionSelect is a Select widget that uses a list of Spain regions as its choices. +>>> from django.contrib.localflavor.es.forms import ESRegionSelect +>>> w = ESRegionSelect() +>>> w.render('regions', 'CT') +u'' + +# ESProvincenSelect ############################################################## + +ESProvinceSelect is a Select widget that uses a list of Spain provinces as its choices. +>>> from django.contrib.localflavor.es.forms import ESProvinceSelect +>>> w = ESProvinceSelect() +>>> w.render('provinces', '08') +u'' + +""" + diff --git a/tests/regressiontests/forms/tests.py b/tests/regressiontests/forms/tests.py index a732f9fdb0..aa33386d09 100644 --- a/tests/regressiontests/forms/tests.py +++ b/tests/regressiontests/forms/tests.py @@ -9,6 +9,7 @@ from localflavor.ca import tests as localflavor_ca_tests from localflavor.ch import tests as localflavor_ch_tests from localflavor.cl import tests as localflavor_cl_tests from localflavor.de import tests as localflavor_de_tests +from localflavor.es import tests as localflavor_es_tests from localflavor.fi import tests as localflavor_fi_tests from localflavor.fr import tests as localflavor_fr_tests from localflavor.generic import tests as localflavor_generic_tests @@ -35,6 +36,7 @@ __test__ = { 'localflavor_ch_tests': localflavor_ch_tests, 'localflavor_cl_tests': localflavor_cl_tests, 'localflavor_de_tests': localflavor_de_tests, + 'localflavor_es_tests': localflavor_es_tests, 'localflavor_fi_tests': localflavor_fi_tests, 'localflavor_fr_tests': localflavor_fr_tests, 'localflavor_generic_tests': localflavor_generic_tests,