From d320deef25059d1f349c386453f228debcf4cfe7 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Tue, 22 Dec 2009 21:10:40 +0000 Subject: [PATCH] Fixed #9289 - Added Swedish localflavor. Thanks to Andreas Pelme, Ludvig Ericson and Filip Noetzel for working on a patch. git-svn-id: http://code.djangoproject.com/svn/django/trunk@11969 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- AUTHORS | 2 + django/contrib/localflavor/se/__init__.py | 0 django/contrib/localflavor/se/forms.py | 157 +++++++++ django/contrib/localflavor/se/se_counties.py | 36 ++ django/contrib/localflavor/se/utils.py | 84 +++++ docs/ref/contrib/localflavor.txt | 56 +++ tests/regressiontests/forms/localflavor/se.py | 332 ++++++++++++++++++ tests/regressiontests/forms/tests.py | 2 + 8 files changed, 669 insertions(+) create mode 100644 django/contrib/localflavor/se/__init__.py create mode 100644 django/contrib/localflavor/se/forms.py create mode 100644 django/contrib/localflavor/se/se_counties.py create mode 100644 django/contrib/localflavor/se/utils.py create mode 100644 tests/regressiontests/forms/localflavor/se.py diff --git a/AUTHORS b/AUTHORS index c8d91f77c7..2788e4b22a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -328,6 +328,7 @@ answer newbie questions, and generally made Django that much better: Gopal Narayanan Fraser Nevett Sam Newman + Filip Noetzel Afonso Fernández Nogueira Neal Norwitz Todd O'Bryan @@ -338,6 +339,7 @@ answer newbie questions, and generally made Django that much better: Carlos Eduardo de Paula pavithran s Barry Pederson + Andreas Pelme permonik@mesias.brnonet.cz peter@mymart.com pgross@thoughtworks.com diff --git a/django/contrib/localflavor/se/__init__.py b/django/contrib/localflavor/se/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/contrib/localflavor/se/forms.py b/django/contrib/localflavor/se/forms.py new file mode 100644 index 0000000000..eebd580c45 --- /dev/null +++ b/django/contrib/localflavor/se/forms.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +Swedish specific Form helpers +""" +import re +from django import forms +from django.utils.translation import ugettext_lazy as _ +from django.forms.fields import EMPTY_VALUES +from django.contrib.localflavor.se.utils import (id_number_checksum, + validate_id_birthday, format_personal_id_number, valid_organisation, + format_organisation_number) + +__all__ = ('SECountySelect', 'SEOrganisationNumberField', + 'SEPersonalIdentityNumberField', 'SEPostalCodeField') + +SWEDISH_ID_NUMBER = re.compile(r'^(?P\d{2})?(?P\d{2})(?P\d{2})(?P\d{2})(?P[\-+])?(?P\d{3})(?P\d)$') +SE_POSTAL_CODE = re.compile(r'^[1-9]\d{2} ?\d{2}$') + +class SECountySelect(forms.Select): + """ + A Select form widget that uses a list of the Swedish counties (län) as its + choices. + + The cleaned value is the official county code -- see + http://en.wikipedia.org/wiki/Counties_of_Sweden for a list. + """ + + def __init__(self, attrs=None): + from se_counties import COUNTY_CHOICES + super(SECountySelect, self).__init__(attrs=attrs, + choices=COUNTY_CHOICES) + +class SEOrganisationNumberField(forms.CharField): + """ + A form field that validates input as a Swedish organisation number + (organisationsnummer). + + It accepts the same input as SEPersonalIdentityField (for sole + proprietorships (enskild firma). However, co-ordination numbers are not + accepted. + + It also accepts ordinary Swedish organisation numbers with the format + NNNNNNNNNN. + + The return value will be YYYYMMDDXXXX for sole proprietors, and NNNNNNNNNN + for other organisations. + """ + + default_error_messages = { + 'invalid': _('Enter a valid Swedish organisation number.'), + } + + def clean(self, value): + value = super(SEOrganisationNumberField, self).clean(value) + + if value in EMPTY_VALUES: + return u'' + + match = SWEDISH_ID_NUMBER.match(value) + if not match: + raise forms.ValidationError(self.error_messages['invalid']) + + gd = match.groupdict() + + # Compare the calculated value with the checksum + if id_number_checksum(gd) != int(gd['checksum']): + raise forms.ValidationError(self.error_messages['invalid']) + + # First: check if this is a real organisation_number + if valid_organisation(gd): + return format_organisation_number(gd) + + # Is this a single properitor (enskild firma)? + try: + birth_day = validate_id_birthday(gd, False) + return format_personal_id_number(birth_day, gd) + except ValueError: + raise forms.ValidationError(self.error_messages['invalid']) + + +class SEPersonalIdentityNumberField(forms.CharField): + """ + A form field that validates input as a Swedish personal identity number + (personnummer). + + The correct formats are YYYYMMDD-XXXX, YYYYMMDDXXXX, YYMMDD-XXXX, + YYMMDDXXXX and YYMMDD+XXXX. + + A + indicates that the person is older than 100 years, which will be taken + into consideration when the date is validated. + + The checksum will be calculated and checked. The birth date is checked to + be a valid date. + + By default, co-ordination numbers (samordningsnummer) will be accepted. To + only allow real personal identity numbers, pass the keyword argument + coordination_number=False to the constructor. + + The cleaned value will always have the format YYYYMMDDXXXX. + """ + + def __init__(self, coordination_number=True, *args, **kwargs): + self.coordination_number = coordination_number + super(SEPersonalIdentityNumberField, self).__init__(*args, **kwargs) + + default_error_messages = { + 'invalid': _('Enter a valid Swedish personal identity number.'), + 'coordination_number': _('Co-ordination numbers are not allowed.'), + } + + def clean(self, value): + value = super(SEPersonalIdentityNumberField, self).clean(value) + + if value in EMPTY_VALUES: + return u'' + + match = SWEDISH_ID_NUMBER.match(value) + if match is None: + raise forms.ValidationError(self.error_messages['invalid']) + + gd = match.groupdict() + + # compare the calculated value with the checksum + if id_number_checksum(gd) != int(gd['checksum']): + raise forms.ValidationError(self.error_messages['invalid']) + + # check for valid birthday + try: + birth_day = validate_id_birthday(gd) + except ValueError: + raise forms.ValidationError(self.error_messages['invalid']) + + # make sure that co-ordination numbers do not pass if not allowed + if not self.coordination_number and int(gd['day']) > 60: + raise forms.ValidationError(self.error_messages['coordination_number']) + + return format_personal_id_number(birth_day, gd) + + +class SEPostalCodeField(forms.RegexField): + """ + A form field that validates input as a Swedish postal code (postnummer). + Valid codes consist of five digits (XXXXX). The number can optionally be + formatted with a space after the third digit (XXX XX). + + The cleaned value will never contain the space. + """ + + default_error_messages = { + 'invalid': _('Enter a Swedish postal code in the format XXXXX.'), + } + + def __init__(self, *args, **kwargs): + super(SEPostalCodeField, self).__init__(SE_POSTAL_CODE, *args, **kwargs) + + def clean(self, value): + return super(SEPostalCodeField, self).clean(value).replace(' ', '') diff --git a/django/contrib/localflavor/se/se_counties.py b/django/contrib/localflavor/se/se_counties.py new file mode 100644 index 0000000000..db54fb9f39 --- /dev/null +++ b/django/contrib/localflavor/se/se_counties.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" +An alphabetical list of Swedish counties, sorted by codes. + +http://en.wikipedia.org/wiki/Counties_of_Sweden + +This exists in this standalone file so that it's only imported into memory +when explicitly needed. + +""" + +from django.utils.translation import ugettext_lazy as _ + +COUNTY_CHOICES = ( + ('AB', _(u'Stockholm')), + ('AC', _(u'Västerbotten')), + ('BD', _(u'Norrbotten')), + ('C', _(u'Uppsala')), + ('D', _(u'Södermanland')), + ('E', _(u'Östergötland')), + ('F', _(u'Jönköping')), + ('G', _(u'Kronoberg')), + ('H', _(u'Kalmar')), + ('I', _(u'Gotland')), + ('K', _(u'Blekinge')), + ('M', _(u'Skåne')), + ('N', _(u'Halland')), + ('O', _(u'Västra Götaland')), + ('S', _(u'Värmland')), + ('T', _(u'Örebro')), + ('U', _(u'Västmanland')), + ('W', _(u'Dalarna')), + ('X', _(u'Gävleborg')), + ('Y', _(u'Västernorrland')), + ('Z', _(u'Jämtland')), +) diff --git a/django/contrib/localflavor/se/utils.py b/django/contrib/localflavor/se/utils.py new file mode 100644 index 0000000000..7fe2b09812 --- /dev/null +++ b/django/contrib/localflavor/se/utils.py @@ -0,0 +1,84 @@ +import re +import datetime + +def id_number_checksum(gd): + """ + Calculates a Swedish ID number checksum, using the + "Luhn"-algoritm + """ + n = s = 0 + for c in (gd['year'] + gd['month'] + gd['day'] + gd['serial']): + tmp = ((n % 2) and 1 or 2) * int(c) + + if tmp > 9: + tmp = sum([int(i) for i in str(tmp)]) + + s += tmp + n += 1 + + if (s % 10) == 0: + return 0 + + return (((s / 10) + 1) * 10) - s + +def validate_id_birthday(gd, fix_coordination_number_day=True): + """ + Validates the birth_day and returns the datetime.date object for + the birth_day. + + If the date is an invalid birth day, a ValueError will be raised. + """ + + today = datetime.date.today() + + day = int(gd['day']) + if fix_coordination_number_day and day > 60: + day -= 60 + + if gd['century'] is None: + + # The century was not specified, and need to be calculated from todays date + current_year = today.year + year = int(today.strftime('%Y')) - int(today.strftime('%y')) + int(gd['year']) + + if ('%s%s%02d' % (gd['year'], gd['month'], day)) > today.strftime('%y%m%d'): + year -= 100 + + # If the person is older than 100 years + if gd['sign'] == '+': + year -= 100 + else: + year = int(gd['century'] + gd['year']) + + # Make sure the year is valid + # There are no swedish personal identity numbers where year < 1800 + if year < 1800: + raise ValueError + + # ValueError will be raise for invalid dates + birth_day = datetime.date(year, int(gd['month']), day) + + # birth_day must not be in the future + if birth_day > today: + raise ValueError + + return birth_day + +def format_personal_id_number(birth_day, gd): + # birth_day.strftime cannot be used, since it does not support dates < 1900 + return unicode(str(birth_day.year) + gd['month'] + gd['day'] + gd['serial'] + gd['checksum']) + +def format_organisation_number(gd): + if gd['century'] is None: + century = '' + else: + century = gd['century'] + + return unicode(century + gd['year'] + gd['month'] + gd['day'] + gd['serial'] + gd['checksum']) + +def valid_organisation(gd): + return gd['century'] in (None, 16) and \ + int(gd['month']) >= 20 and \ + gd['sign'] in (None, '-') and \ + gd['year'][0] in ('2', '5', '7', '8', '9') # group identifier + diff --git a/docs/ref/contrib/localflavor.txt b/docs/ref/contrib/localflavor.txt index d63d546efa..d0e9b7f8e7 100644 --- a/docs/ref/contrib/localflavor.txt +++ b/docs/ref/contrib/localflavor.txt @@ -61,6 +61,7 @@ Countries currently supported by :mod:`~django.contrib.localflavor` are: * Slovakia_ * `South Africa`_ * Spain_ + * Sweden_ * Switzerland_ * `United Kingdom`_ * `United States of America`_ @@ -101,6 +102,7 @@ Here's an example of how to use them:: .. _Slovakia: `Slovakia (sk)`_ .. _South Africa: `South Africa (za)`_ .. _Spain: `Spain (es)`_ +.. _Sweden: `Sweden (se)`_ .. _Switzerland: `Switzerland (ch)`_ .. _United Kingdom: `United Kingdom (uk)`_ .. _United States of America: `United States of America (us)`_ @@ -596,6 +598,60 @@ Spain (``es``) A ``Select`` widget that uses a list of Spanish regions as its choices. +Sweden (``se``) +=============== + +.. class:: se.forms.SECountySelect + + A Select form widget that uses a list of the Swedish counties (län) as its + choices. + + The cleaned value is the official county code -- see + http://en.wikipedia.org/wiki/Counties_of_Sweden for a list. + +.. class:: se.forms.SEOrganisationNumber + + A form field that validates input as a Swedish organisation number + (organisationsnummer). + + It accepts the same input as SEPersonalIdentityField (for sole + proprietorships (enskild firma). However, co-ordination numbers are not + accepted. + + It also accepts ordinary Swedish organisation numbers with the format + NNNNNNNNNN. + + The return value will be YYYYMMDDXXXX for sole proprietors, and NNNNNNNNNN + for other organisations. + +.. class:: se.forms.SEPersonalIdentityNumber + + A form field that validates input as a Swedish personal identity number + (personnummer). + + The correct formats are YYYYMMDD-XXXX, YYYYMMDDXXXX, YYMMDD-XXXX, + YYMMDDXXXX and YYMMDD+XXXX. + + A \+ indicates that the person is older than 100 years, which will be taken + into consideration when the date is validated. + + The checksum will be calculated and checked. The birth date is checked + to be a valid date. + + By default, co-ordination numbers (samordningsnummer) will be accepted. To + only allow real personal identity numbers, pass the keyword argument + coordination_number=False to the constructor. + + The cleaned value will always have the format YYYYMMDDXXXX. + +.. class:: se.forms.SEPostalCodeField + + A form field that validates input as a Swedish postal code (postnummer). + Valid codes consist of five digits (XXXXX). The number can optionally be + formatted with a space after the third digit (XXX XX). + + The cleaned value will never contain the space. + Switzerland (``ch``) ==================== diff --git a/tests/regressiontests/forms/localflavor/se.py b/tests/regressiontests/forms/localflavor/se.py new file mode 100644 index 0000000000..44bd953c6e --- /dev/null +++ b/tests/regressiontests/forms/localflavor/se.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +# Tests for the contrib/localflavor/se form fields. + +tests = r""" +# Monkey-patch datetime.date +>>> import datetime +>>> class MockDate(datetime.date): +... def today(cls): +... return datetime.date(2008, 5, 14) +... today = classmethod(today) +... +>>> olddate = datetime.date +>>> datetime.date = MockDate +>>> datetime.date.today() +MockDate(2008, 5, 14) + + +# SECountySelect ##################################################### +>>> from django.contrib.localflavor.se.forms import SECountySelect + +>>> w = SECountySelect() +>>> w.render('swedish_county', 'E') +u'' + +# SEOrganisationNumberField ####################################### + +>>> from django.contrib.localflavor.se.forms import SEOrganisationNumberField + +>>> f = SEOrganisationNumberField() + +# Ordinary personal identity numbers for sole proprietors +# The same rules as for SEPersonalIdentityField applies here +>>> f.clean('870512-1989') +u'198705121989' +>>> f.clean('19870512-1989') +u'198705121989' +>>> f.clean('870512-2128') +u'198705122128' +>>> f.clean('081015-6315') +u'190810156315' +>>> f.clean('081015+6315') +u'180810156315' +>>> f.clean('0810156315') +u'190810156315' + +>>> f.clean('081015 6315') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] +>>> f.clean('950231-4496') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] +>>> f.clean('6914104499') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] +>>> f.clean('950d314496') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] +>>> f.clean('invalid!!!') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] +>>> f.clean('870514-1111') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] + + +# Empty values +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f.clean(None) +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +# Co-ordination number checking +# Co-ordination numbers are not valid organisation numbers +>>> f.clean('870574-1315') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] + +>>> f.clean('870573-1311') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] + +# Test some different organisation numbers +>>> f.clean('556074-7569') # IKEA Linköping +u'5560747569' + +>>> f.clean('556074-3089') # Volvo Personvagnar +u'5560743089' + +>>> f.clean('822001-5476') # LJS (organisation) +u'8220015476' + +>>> f.clean('8220015476') # LJS (organisation) +u'8220015476' + +>>> f.clean('2120000449') # Katedralskolan Linköping (school) +u'2120000449' + +# Faux organisation number, which tests that the checksum can be 0 +>>> f.clean('232518-5060') +u'2325185060' + +>>> f.clean('556074+3089') # Volvo Personvagnar, bad format +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] + + +# Invalid checksum +>>> f.clean('2120000441') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] + +# Valid checksum but invalid organisation type +f.clean('1120000441') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish organisation number.'] + +# Empty values with required=False +>>> f = SEOrganisationNumberField(required=False) + +>>> f.clean(None) +u'' + +>>> f.clean('') +u'' + + +# SEPersonalIdentityNumberField ####################################### + +>>> from django.contrib.localflavor.se.forms import SEPersonalIdentityNumberField + +>>> f = SEPersonalIdentityNumberField() + +# Valid id numbers +>>> f.clean('870512-1989') +u'198705121989' + +>>> f.clean('870512-2128') +u'198705122128' + +>>> f.clean('19870512-1989') +u'198705121989' + +>>> f.clean('198705121989') +u'198705121989' + +>>> f.clean('081015-6315') +u'190810156315' + +>>> f.clean('0810156315') +u'190810156315' + +# This is a "special-case" in the checksum calculation, +# where the sum is divisible by 10 (the checksum digit == 0) +>>> f.clean('8705141060') +u'198705141060' + +# + means that the person is older than 100 years +>>> f.clean('081015+6315') +u'180810156315' + +# Bogus values +>>> f.clean('081015 6315') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + +>>> f.clean('950d314496') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + +>>> f.clean('invalid!!!') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + + +# Invalid dates + +# February 31st does not exist +>>> f.clean('950231-4496') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + +# Month 14 does not exist +>>> f.clean('6914104499') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + +# There are no Swedish personal id numbers where year < 1800 +>>> f.clean('17430309-7135') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + +# Invalid checksum +>>> f.clean('870514-1111') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + +# Empty values +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f.clean(None) +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +# Co-ordination number checking +>>> f.clean('870574-1315') +u'198705741315' + +>>> f.clean('870574+1315') +u'188705741315' + +>>> f.clean('198705741315') +u'198705741315' + +# Co-ordination number with bad checksum +>>> f.clean('870573-1311') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + + +# Check valid co-ordination numbers, that should not be accepted +# because of coordination_number=False +>>> f = SEPersonalIdentityNumberField(coordination_number=False) + +>>> f.clean('870574-1315') +Traceback (most recent call last): +... +ValidationError: [u'Co-ordination numbers are not allowed.'] + +>>> f.clean('870574+1315') +Traceback (most recent call last): +... +ValidationError: [u'Co-ordination numbers are not allowed.'] + +>>> f.clean('8705741315') +Traceback (most recent call last): +... +ValidationError: [u'Co-ordination numbers are not allowed.'] + +# Invalid co-ordination numbers should be treated as invalid, and not +# as co-ordination numbers +>>> f.clean('870573-1311') +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid Swedish personal identity number.'] + +# Empty values with required=False +>>> f = SEPersonalIdentityNumberField(required=False) + +>>> f.clean(None) +u'' + +>>> f.clean('') +u'' + +# SEPostalCodeField ############################################### +>>> from django.contrib.localflavor.se.forms import SEPostalCodeField +>>> f = SEPostalCodeField() +>>> +Postal codes can have spaces +>>> f.clean('589 37') +u'58937' + +... but the dont have to +>>> f.clean('58937') +u'58937' +>>> f.clean('abcasfassadf') +Traceback (most recent call last): +... +ValidationError: [u'Enter a Swedish postal code in the format XXXXX.'] + +# Only one space is allowed for separation +>>> f.clean('589 37') +Traceback (most recent call last): +... +ValidationError: [u'Enter a Swedish postal code in the format XXXXX.'] + +# The postal code must not start with 0 +>>> f.clean('01234') +Traceback (most recent call last): +... +ValidationError: [u'Enter a Swedish postal code in the format XXXXX.'] + +# Empty values +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +>>> f.clean(None) +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] + +# Empty values, required=False +>>> f = SEPostalCodeField(required=False) +>>> f.clean('') +u'' +>>> f.clean(None) +u'' + +# Revert the monkey patching +>>> datetime.date = olddate + +""" diff --git a/tests/regressiontests/forms/tests.py b/tests/regressiontests/forms/tests.py index 6f00a06fdd..18deb20242 100644 --- a/tests/regressiontests/forms/tests.py +++ b/tests/regressiontests/forms/tests.py @@ -21,6 +21,7 @@ from localflavor.jp import tests as localflavor_jp_tests from localflavor.nl import tests as localflavor_nl_tests from localflavor.pl import tests as localflavor_pl_tests from localflavor.ro import tests as localflavor_ro_tests +from localflavor.se import tests as localflavor_se_tests from localflavor.sk import tests as localflavor_sk_tests from localflavor.uk import tests as localflavor_uk_tests from localflavor.us import tests as localflavor_us_tests @@ -56,6 +57,7 @@ __test__ = { 'localflavor_nl_tests': localflavor_nl_tests, 'localflavor_pl_tests': localflavor_pl_tests, 'localflavor_ro_tests': localflavor_ro_tests, + 'localflavor_se_tests': localflavor_se_tests, 'localflavor_sk_tests': localflavor_sk_tests, 'localflavor_uk_tests': localflavor_uk_tests, 'localflavor_us_tests': localflavor_us_tests,