Skip to content

Commit

Permalink
More rigid postcode validation
Browse files Browse the repository at this point in the history
Validation is based on wikipedia list of postal codes [1]. Formats were
converted into regexes for countries.

1: http://en.wikipedia.org/wiki/List_of_postal_codes
  • Loading branch information
Izidor Matušov committed Jul 11, 2013
1 parent ae376a9 commit 7294a0d
Show file tree
Hide file tree
Showing 2 changed files with 250 additions and 2 deletions.
202 changes: 201 additions & 1 deletion oscar/apps/address/abstract_models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import re
import zlib

from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.core import exceptions

from oscar.core.compat import AUTH_USER_MODEL

Expand All @@ -23,6 +25,190 @@ class AbstractAddress(models.Model):
(MS, _("Ms")),
(DR, _("Dr")),
)

# Regex for each country. Not listed countries don't use postcodes
# Based on http://en.wikipedia.org/wiki/List_of_postal_codes
POSTCODES_REGEX = {
'AC': R'^[A-Z]{4}[0-9][A-Z]$',
'AD': R'^AD[0-9]{3}$',
'AF': R'^[0-9]{4}$',
'AI': R'^AI-2640$',
'AL': R'^[0-9]{4}$',
'AM': R'^[0-9]{4}$',
'AR': R'^([0-9]{4}|[A-Z][0-9]{4}[A-Z]{3}$',
'AS': R'^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$',
'AT': R'^[0-9]{4}$',
'AU': R'^[0-9]{4}$',
'AX': R'^[0-9]{5}$',
'AZ': R'^AZ[0-9]{4}$',
'BA': R'^[0-9]{5}$',
'BB': R'^BB[0-9]{5}$',
'BD': R'^[0-9]{4}$',
'BE': R'^[0-9]{4}$',
'BG': R'^[0-9]{4}$',
'BH': R'^[0-9]{3,4}$',
'BL': R'^[0-9]{5}$',
'BM': R'^[A-Z]{2}([0-9]{2}|[A-Z]{2})',
'BN': R'^[A-Z}{2}[0-9]]{4}$',
'BO': R'^[0-9]{4}$',
'BR': R'^[0-9]{5}(-[0-9]{3})?$',
'BT': R'^[0-9]{3}$',
'BY': R'^[0-9]{6}$',
'CA': R'^[A-Z][0-9][A-Z][0-9][A-Z][0-9]$',
'CC': R'^[0-9]{4}$',
'CH': R'^[0-9]{4}$',
'CL': R'^([0-9]{7}|[0-9]{3}-[0-9]{4})$',
'CN': R'^[0-9]{6}$',
'CO': R'^[0-9]{6}$',
'CR': R'^[0-9]{4,5}$',
'CU': R'^[0-9]{5}$',
'CV': R'^[0-9]{4}$',
'CX': R'^[0-9]{4}$',
'CY': R'^[0-9]{4}$',
'CZ': R'^[0-9]{5}$',
'DE': R'^[0-9]{5}$',
'DK': R'^[0-9]{4}$',
'DO': R'^[0-9]{5}$',
'DZ': R'^[0-9]{5}$',
'EC': R'^EC[0-9]{6}$',
'EE': R'^[0-9]{5}$',
'EG': R'^[0-9]{5}$',
'ES': R'^[0-9]{5}$',
'ET': R'^[0-9]{4}$',
'FI': R'^[0-9]{5}$',
'FK': R'^[A-Z]{4}[0-9][A-Z]{2}$',
'FM': R'^[0-9]{5}(-[0-9]{4})?$',
'FO': R'^[0-9]{3}$',
'FR': R'^[0-9]{5}$',
'GA': R'^[0-9]{2}.*[0-9]{2}$',
'GB': R'^[A-Z][A-Z0-9]{1,3}[0-9][A-Z]{2}$',
'GE': R'^[0-9]{4}$',
'GF': R'^[0-9]{5}$',
'GG': R'^([A-Z]{2}[0-9]{2,3}[A-Z]{2}$',
'GI': R'^GX111AA$',
'GL': R'^[0-9]{4}$',
'GP': R'^[0-9]{5}$',
'GR': R'^[0-9]{5}$',
'GS': R'^SIQQ1ZZ$',
'GT': R'^[0-9]{5}$',
'GU': R'^[0-9]{5}$',
'GW': R'^[0-9]{4}$',
'HM': R'^[0-9]{4}$',
'HN': R'^[0-9]{5}$',
'HR': R'^[0-9]{5}$',
'HT': R'^[0-9]{4}$',
'HU': R'^[0-9]{4}$',
'ID': R'^[0-9]{5}$',
'IL': R'^[0-9]{7}$',
'IM': R'^IM[0-9]{2,3}[A-Z]{2}$$',
'IN': R'^[0-9]{6}$',
'IO': R'^[A-Z]{4}[0-9][A-Z]{2}$',
'IQ': R'^[0-9]{5}$',
'IR': R'^[0-9]{5}-[0-9]{5}$',
'IS': R'^[0-9]{3}$',
'IT': R'^[0-9]{5}$',
'JE': R'^JE[0-9]{2}[A-Z]{2}$',
'JM': R'^JM[A-Z]{3}[0-9]{2}$',
'JO': R'^[0-9]{5}$',
'JP': R'^[0-9]{3}-?[0-9]{4}$',
'KE': R'^[0-9]{5}$',
'KG': R'^[0-9]{6}$',
'KH': R'^[0-9]{5}$',
'KR': R'^[0-9]{3}-?[0-9]{3}$',
'KY': R'^KY[0-9]-[0-9]{4}$',
'KZ': R'^[0-9]{6}$',
'LA': R'^[0-9]{5}$',
'LB': R'^[0-9]{8}$',
'LI': R'^[0-9]{4}$',
'LK': R'^[0-9]{5}$',
'LR': R'^[0-9]{4}$',
'LS': R'^[0-9]{3}$',
'LT': R'^[0-9]{5}$',
'LU': R'^[0-9]{4}$',
'LV': R'^LV-[0-9]{4}$',
'LY': R'^[0-9]{5}$',
'MA': R'^[0-9]{5}$',
'MC': R'^980[0-9]{2}$',
'MD': R'^MD-?[0-9]{4}$',
'ME': R'^[0-9]{5}$',
'MF': R'^[0-9]{5}$',
'MG': R'^[0-9]{3}$',
'MH': R'^[0-9]{5}$',
'MK': R'^[0-9]{4}$',
'MM': R'^[0-9]{5}$',
'MN': R'^[0-9]{5}$',
'MP': R'^[0-9]{5}$',
'MQ': R'^[0-9]{5}$',
'MT': R'^[A-Z]{3}[0-9]{4}$',
'MV': R'^[0-9]{4,5}$',
'MX': R'^[0-9]{5}$',
'MY': R'^[0-9]{5}$',
'MZ': R'^[0-9]{4}$',
'NA': R'^[0-9]{5}$',
'NC': R'^[0-9]{5}$',
'NE': R'^[0-9]{4}$',
'NF': R'^[0-9]{4}$',
'NG': R'^[0-9]{6}$',
'NI': R'^[0-9]{3}-[0-9]{3}-[0-9]$',
'NL': R'^[0-9]{4}[A-Z]{2}$',
'NO': R'^[0-9]{4}$',
'NP': R'^[0-9]{5}$',
'NZ': R'^[0-9]{4}$',
'OM': R'^[0-9]{3}$',
'PA': R'^[0-9]{6}$',
'PE': R'^[0-9]{5}$',
'PF': R'^[0-9]{5}$',
'PG': R'^[0-9]{3}$',
'PH': R'^[0-9]{4}$',
'PK': R'^[0-9]{5}$',
'PL': R'^[0-9]{2}-?[0-9]{3}$',
'PM': R'^[0-9]{5}$',
'PN': R'^[A-Z]{4}[0-9][A-Z]{2}$',
'PR': R'^[0-9]{5}$',
'PT': R'^[0-9]{4}(-?[0-9]{3})?$',
'PW': R'^[0-9]{5}$',
'PY': R'^[0-9]{4}$',
'RE': R'^[0-9]{5}$',
'RO': R'^[0-9]{6}$',
'RS': R'^[0-9]{5}$',
'RU': R'^[0-9]{6}$',
'SA': R'^[0-9]{5}$',
'SD': R'^[0-9]{5}$',
'SE': R'^[0-9]{5}$',
'SG': R'^([0-9]{2}|[0-9]{4}|[0-9]{6})$',
'SH': R'^(STHL1ZZ|TDCU1ZZ)$',
'SI': R'^(SI-)?[0-9]{4}$',
'SK': R'^[0-9]{5}$',
'SM': R'^[0-9]{5}$',
'SN': R'^[0-9]{5}$',
'SV': R'^01101$',
'SZ': R'^[A-Z][0-9]{3}$',
'TC': R'^TKCA1ZZ$',
'TD': R'^[0-9]{5}$',
'TH': R'^[0-9]{5}$',
'TJ': R'^[0-9]{6}$',
'TM': R'^[0-9]{6}$',
'TN': R'^[0-9]{4}$',
'TR': R'^[0-9]{5}$',
'TT': R'^[0-9]{6}$',
'TW': R'^[0-9]{5}$',
'UA': R'^[0-9]{5}$',
'US': R'^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$',
'UY': R'^[0-9]{5}$',
'UZ': R'^[0-9]{6}$',
'VA': R'^00120$',
'VC': R'^VC[0-9]{4}',
'VE': R'^[0-9]{4}[A-Z]?$',
'VG': R'^VG[0-9]{4}$',
'VI': R'^[0-9]{5}$',
'VN': R'^[0-9]{6}$',
'WF': R'^[0-9]{5}$',
'XK': R'^[0-9]{5}$',
'YT': R'^[0-9]{5}$',
'ZA': R'^[0-9]{4}$',
'ZM': R'^[0-9]{5}$',
}

title = models.CharField(
_("Title"), max_length=64, choices=TITLE_CHOICES,
blank=True, null=True)
Expand Down Expand Up @@ -68,10 +254,24 @@ def _clean_fields(self):
if self.__dict__[field]:
self.__dict__[field] = self.__dict__[field].strip()

# Ensure postcodes are always uppercase
self.clean_postcode()

def clean_postcode(self):
"""
Validate postcode given the country
"""
if self.postcode:
# Ensure postcodes are always uppercase
self.postcode = self.postcode.upper()

postcode = self.postcode.replace(' ', '')
country_code = self.country.iso_3166_1_a2
regex = self.POSTCODES_REGEX.get(country_code, None)

# Validate postcode against regext for the country if available
if regex and not re.match(regex, postcode):
raise exceptions.ValidationError("Invalid postcode")

def _update_search_text(self):
search_fields = filter(
bool, [self.first_name, self.last_name,
Expand Down
50 changes: 49 additions & 1 deletion tests/unit/address_tests.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*-
from django.test import TestCase
from django.core import exceptions

from nose.tools import raises

from oscar.core.compat import get_user_model
from oscar.apps.address.models import UserAddress
from oscar.apps.address.models import UserAddress, Country


User = get_user_model()
Expand All @@ -22,3 +25,48 @@ def test_city_is_alias_of_line4(self):
line4="London",
postcode="n4 8ty")
self.assertEqual('London', a.city)


VALID_POSTCODES = [
('GB', 'N1 9RT'),
('SK', '991 41'),
('CZ', '612 00'),
('CC', '6799'),
('CY', '8240'),
('MC', '98000'),
('SH', 'STHL 1ZZ'),
('JP', '150-2345'),
('PG', '314'),
('HN', '41202'),
# It works for small cases as well
('GB', 'sw2 1rw'),
]


INVALID_POSTCODES = [
('GB', 'not-a-postcode'),
('DE', '123b4'),
]


def assert_valid_postcode(country_value, postcode_value):
country = Country(iso_3166_1_a2=country_value)
address = UserAddress(country=country, postcode=postcode_value)
address.clean_postcode()


@raises(exceptions.ValidationError)
def assert_invalid_postcode(country_value, postcode_value):
country = Country(iso_3166_1_a2=country_value)
address = UserAddress(country=country, postcode=postcode_value)
address.clean_postcode()


def test_postcode_is_validated_for_country():
for country, postcode in VALID_POSTCODES:
yield assert_valid_postcode, country, postcode


def test_postcode_is_only_valid():
for country, postcode in INVALID_POSTCODES:
yield assert_invalid_postcode, country, postcode

0 comments on commit 7294a0d

Please sign in to comment.