Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Ticket 18903: GBPhoneNumberField #356

Closed
wants to merge 48 commits into from

4 participants

@brad

This pull adds support for fields representing UK phone numbers, as well as tests and documentation.

@g1smd

Your fork didn't pick up the vital change in
g1smd@d3bbcc1
from September 7th as I added that change a bit later in the day. Please add it to your repo. :-)

Thanks for your input so far. The conversion is fantastic. I have this code working and tested in Java and PHP already so I know the RegEx patterns used for those are 100% correct.

On September 10th I imported your four changes from September 7th and 8th into my repo.
On September 11th I made seven further commits to my repo g1smd/django but have no idea how to get them across to your stream.

I have changed the two RegEx patterns to multi-line format as that makes them much easier to read. I also made several small but vital changes to those patterns. (I'll update the wiki examples later).

Note that it is the valid_format pattern that stops numbers with incorrect NSN length making it through to later stages. This one allows a very wide range of input formats but chucks out a lot of garbage.

By splitting input format checking, NSN extraction, number range and length checking, and the output number formatting into separate processes much more detailed checks can be made at each stage.

I have also added a comment block listing accepted number formats for entry.

The final commits add a space after the +44 prefix, and fixes multiple identical cut and paste typos made in an earlier commit and some other typos and fixes.

Please update your repo to include all of this new code before making further changes, as I can then easily track the diffs after that point.

Thanks again for your fantastic effort in fixing up my broken python guesswork - and please check that I haven't introduced a silly syntax typo somewhere in the new code. In particular I don't know when to use ' or """ around values.

@apollo13
Owner

@brad: You probably want to read https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/working-with-git/ and given https://groups.google.com/forum/?fromgroups=#!topic/django-developers/4EvlunsJ0LY I suggest you delay your work to after 1.5 since I highly doubt we'd merge your stuff before moving localflavor out. Btw why do you need such crazy checking at all? Even if the regex says a phone number is valid it doesn't mean it actually exists, so you have to call them to verify…

@g1smd

Unlike the US and Canada where phone numbers always have 10 digits including a 3 digit area code, GB numbers have area codes from 2 to 5 digits in length and total number length of 9 or 10 digits - and sometimes mixed length numbers within the same area code. There's a lot more scope for making an error, but a few RegEx patterns can eliminate the vast majority of issues. The idea here is to eliminate the worst of the errors.

Additionally, many users don't always format their number correctly. Someone might write the London number 020 3000 5555 as 02030 005 555 or as +44 203 000 5555 akin to someone writing the New York number 212-555-7777 as 2125-557-777 or as +12-125-557-777. The user should be allowed to do that and then a few lines of simple code can reformat the number correctly for display.

International numbers are also often misunderstood, with people writing +44 20 3000 5555 as 00 44 20 3000 5555 (which won't work from the US or Canada) or as 011 44 20 3000 5555 (which won't work from Europe). These issues are also easily solved and the number can then be presented in the correct format.

@brad

@apollo13 Thanks for pointing that out. I am aware that localflavors is getting moved out and decided to take my chances anyway. It's not the end of the world to me if I have to reapply the patch there.

@g1smd Can you please work on your changes and make sure they apply cleanly to my ticket_18903 branch? I'm getting conflicts and it is not clear the best way to resolve them.

@g1smd

I'm not sure what to do. There's thousands of changes made by other people. I've no idea which apply or not.

@brad

Yes, I've got the latest changes from django master in my branch. A merge should automatically accept all those thousands of changes without issue.

@g1smd

All done.

I had to manually edit gb/forms.py again (actually just replaced it with a copy I already had as a backup in another folder).

@g1smd

I have also refactored the RegEx patterns slightly and made them more efficient.

@brad

That is much better, except those regexes aren't working for me (the unit tests fail). Can you explain what the differences are?

@g1smd

If they all fail, I assume a typo. The new patterns are more strict and reject various area codes, prefixes and number ranges that do not exist and allow a range of dial prefixes with a variety of punctuation in various places.

Some of the RegEx patterns begin r' and others begin r""". I don't understand the difference. These RegEx patterns are working in another application, so I guess it's a python syntax problem/typo. I have now changed the ' to """ in several places.

Are you aware that the output is now formatted, rather than just a string of digits? The number format conforms to that listed at: http://www.aa-asterisk.org.uk/index.php/Number_format

Updated tests. Any further problems?

@brad

Yeah, same problem as before, error: bad character range when parsing the gb_number_parts regex.
The """ just allows for multi-line strings. I don't think inline comments can be used in these expressions, but even with them removed I see the same error.

@g1smd

OK. That's something concrete to go on.

Found a likely candidate, and fixed it. The '[\s-\d]' should probably be '[\s\d-]' here.

Inline comments should be usable with the 're.X' flag if it works anything like the PHP RegEx '/x' flag. Just be sure there are no slashes in the comments.

@brad

Good work, the tests are now passing!

@g1smd

Hurrah!

I'm not fazed by RegEx issues.

I have no idea what a pep8 is, but thanks for taking the time to do that too. :-) Good stuff.

What happens next?

@g1smd

Is it worth adding more test data?

0114 223 4567 => Valid: +44 114 223 4567
01145 345 567 => Valid: +44 114 534 5567 <= typo (fixed in later commit)
+44 1213 456 789 => Valid: +44 121 345 6789
00 44 (0) 1697 73555 => Valid: +44 16977 3555
011 44 11 4890 2345 => Valid: +44 114 890 2345 <= typo (fixed in later commit)
025 4555 6777 => NOT VALID
0119 456 4567 => NOT VALID
0623 111 3456 => NOT VALID
0756 334556 => NOT VALID

@brad

Yes, ideally we could cover all cases covered by the regular expressions.

@g1smd

With optional spaces, hyphens and brackets, and the various area code and number lengths, there's over 400 valid combinations.

@brad

I'm not too concerned about space/hyphen variations, since those cases are covered simply by removing those characters from the string. I'm more concerned with different area code lengths which I did not cover in my original test set.

@g1smd

Those listed above, plus these:

020 3000 5000 => Valid: +44 20 3000 5000
0121 555 7777 => Valid: +44 121 555 7777
01750 615777 => Valid: +44 1750 615777
019467 55555 => Valid: +44 19467 55555
01750 62555 => Valid: +44 1750 62555
016977 3555 => Valid: +44 16977 3555
0500 777888 => Valid: +44 500 777888
020 5000 5000 => NOT VALID
0171 555 7777 => NOT VALID
01999 777888 => NOT VALID
01750 61777 => NOT VALID

@brad

Can you please specify what the formatted result should be for each valid number?

@g1smd

Amended list above. Also added several 'non-valid' numbers. Added to file and pushed.

@brad

The test doesn't pass. Unfortunately it doesn't inform me which number(s) failed. I'll use the process of elimination I guess.

@g1smd

Ack. It'll be a silly typo.

@brad

'01145 345 567': '+44 114 534 5567'
and
'011 44 11 4890 2345': '+44 114 890 2345'
both fail at the valid_gb_phone_range step. I printed out number_parts for each number:
{u'prefix': u'+44 ', u'extension': None, u'NSN': u'1145345567'}
and
{u'prefix': u'+44 ', u'extension': None, u'NSN': u'1148902345'}

does that look right? Is the problem in the test or the validation?

@g1smd

Yes, stupid typo. I had intended to use the 0141 area code, but typed 0114 which has less valid ranges.

Fixed and pushed. Apologies!

@brad

Looks good, I think this is ready for checkin.

@g1smd

Does anything else need amending on the ticket?

@brad

I don't think so, now we just wait for it to catch someone's eye and mark it "Ready for Checkin". I don't think either of us should mark it so, since we were both involved in the patch.

@brad

I'm going to go ahead and add @apollo13 's comment here since it seems important but it was deleted:

Given this crazy regex I suggest you test if it's vulnerable to backtracking attack (http://www.regular-expressions.info/catastrophic.html -- yes you actually can DOS a server with something like this, we had this in URLField a while ago)

@g1smd

I've not come across that before, but I don't think it applies here because the patterns to match are always a fixed number of digits, and designed to parse left to right - and fail without much in the way of backtracking. There's very limited use of + or * quantifiers which seem to be the main part of the problem described in the article.

However, I'll look into it a bit more.

@apollo13
Owner

@brad githubs ui fooled me :/

@brad

@apollo13 hehe, it happens to all of us

@g1smd

Are there any improvements that can be made to error messages?

"Not a valid format and/or number length" for non-matching valid_gb_format.

"Not a valid range or not a valid number length for this range" for non-matching valid_gb_range.

@brad

OK, I have added both messages and the tests now check that the error message is correct.

@g1smd

Thanks!

I don't know how deep to take the error checking.

I'm tempted to also add another simple code chunk right near the beginning:

If number matches [^\+\(\)\dext.\#\s-]

with error message

"Input contains invalid characters or punctuation. Use only digits and '+', '(', ')', 'ext.', 'x' and '#' with optional spaces and/or hyphens."

with test number 020@7000 8000 (preferred);

or even simply automatically clear all those unwanted junk characters from the input before processing (not preferred).

Additionally, it would be possible to add

If NOT match (r"""^\(?(?:(?:0(?:0|11)\)?[\s-]?\(?|\+)44\)?[\s-]?\(?(?:0\)?[\s-]?\(?)?|0).*$""")

with error message:

"Number must begin 0, +44, 00 44 or 011 44 followed by 9 or 10 digits and optional 'ext.', 'x' or '#' extension.

with test number 00 33 1 44 55 66 77 (preferred);

or else amend the existing 'Not a valid format and/or number length.' error message to instead read 'Not a valid format and/or number length. Number must begin 0, +44, 00 44 or 011 44 followed by 9 or 10 digits and optional 'ext.', 'x' or '#' extension.' (not preferred).

We also need a test number like 020 7000 9000 x4567 to test extension handling; result +44 20 7000 9000 #4567.

I don't know how much feedback to give the user. I want to keep things simple, but also feel that more specific error messages are a good idea where possible.

It's not easy to give specific warnings about too many digits or too few digits. That will have to be skipped here, relying only on the current "non valid length" message to cover both cases.

There are loads of other checks that could be done - such as warning when multiple contiguous spaces or hyphens or multiple unwanted punctuation is used (or automatically removing it), but there's diminishing returns on module speed. I'm not too bothered about checking for stuff like ((020))---7000---8000 as it should already be obvious to the user that they've already typed way too much extra crud.

Where's the dividing line between interpreting what the user wanted and just getting on with it (hence allowing 00 44 and 011 44 in addition to the usual +44) or saying 'you've botched the input, do it again'? I tend to write more detailed code to make a simpler or more intuitive user experience, but I know that not everyone holds that view perhaps preferring the bare minimum code for improved speed.

@brad

I added tests for numbers with extensions. Please review my changes. I think this is as far as we should take the validation. While I appreciate the complexities of these numbers, there is a balance like you say between too much and too little validation. I fear it will be too much if it is taken any further, I think we have struck a good balance here, you are welcome to keep adding to it if you like.

@g1smd

Looks OK.

I'll hold off until there is some user feedback. If users want more detailed error messages they can be added later.

@g1smd

I added a comment to the ticket... ""We think this is 'Ready for Checkin'""" asking for others to take a look.

@adrianholovaty
Collaborator

Hey there -- django.contrib.localflavor is now deprecated, and we're not making any more changes to it. Could you reopen this pull request for the shiny new package django-localflavor-gb? Here's the link: https://github.com/django/django-localflavor-gb

Sorry we didn't get to this pull request before the deprecation. I hope it's not too much of a pain to migrate this to the new package.

@g1smd

@brad has the latest version. My copy is out of sync.

@brad

No problem, I'll take care of it. Thanks for the heads up, Adrian.

@g1smd

Looks good. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Sep 2, 2012
  1. @g1smd
  2. @g1smd

    Comments.

    g1smd authored
Commits on Sep 4, 2012
  1. @g1smd
Commits on Sep 5, 2012
  1. @g1smd

    Syntax fixes.

    g1smd authored
  2. @g1smd

    More python code.

    g1smd authored
  3. @g1smd
  4. @g1smd

    Removing rogue punctuation.

    g1smd authored
  5. @g1smd

    Fix RegEx inline comment.

    g1smd authored
  6. @g1smd

    Rogue comment markers.

    g1smd authored
  7. @g1smd

    More code snippets.

    g1smd authored
Commits on Sep 7, 2012
  1. @g1smd

    Typo in comments.

    g1smd authored
  2. @brad
  3. @g1smd
  4. @brad

    working GBPhoneNumberField

    brad authored
  5. @brad

    test_GBPhoneNumberField

    brad authored
Commits on Sep 8, 2012
  1. @brad
  2. @brad

    GBPhoneNumberField docs

    brad authored
Commits on Sep 11, 2012
  1. @g1smd
  2. @g1smd
  3. @g1smd
  4. @g1smd
  5. @g1smd
  6. @g1smd
  7. @g1smd
  8. @g1smd
  9. @g1smd
  10. @g1smd
  11. @g1smd
  12. @g1smd
  13. @g1smd
  14. @g1smd
  15. @g1smd
Commits on Sep 12, 2012
  1. @brad
  2. @g1smd
Commits on Sep 16, 2012
  1. @g1smd
  2. @g1smd
Commits on Sep 17, 2012
  1. @g1smd

    Syntax change.

    g1smd authored
  2. @g1smd
  3. @g1smd

    Updated phone number tests.

    g1smd authored
  4. @g1smd

    Minor change to character group.

    g1smd authored
  5. @brad

    pep8 fixes

    brad authored
Commits on Sep 18, 2012
  1. @g1smd
  2. @brad
  3. @g1smd

    Minor amendments to tests.

    g1smd authored
  4. @g1smd

    Minor amendments to tests.

    g1smd authored
  5. @brad
Commits on Sep 19, 2012
  1. @brad

    use more precise error messages

    brad authored
Commits on Sep 23, 2012
  1. @brad

    add extension tests

    brad authored
This page is out of date. Refresh to see the latest.
View
308 django/contrib/localflavor/gb/forms.py
@@ -7,6 +7,7 @@
import re
from django.contrib.localflavor.gb.gb_regions import GB_NATIONS_CHOICES, GB_REGION_CHOICES
+from django.core.validators import EMPTY_VALUES
from django.forms.fields import CharField, Select
from django.forms import ValidationError
from django.utils.translation import ugettext_lazy as _
@@ -31,8 +32,8 @@ class GBPostcodeField(CharField):
def clean(self, value):
value = super(GBPostcodeField, self).clean(value)
- if value == '':
- return value
+ if value in EMPTY_VALUES:
+ return ''
postcode = value.upper().strip()
# Put a single space before the incode (second part).
postcode = self.space_regex.sub(r' \1', postcode)
@@ -40,6 +41,7 @@ def clean(self, value):
raise ValidationError(self.error_messages['invalid'])
return postcode
+
class GBCountySelect(Select):
"""
A Select widget that uses a list of UK Counties/Regions as its choices.
@@ -47,9 +49,311 @@ class GBCountySelect(Select):
def __init__(self, attrs=None):
super(GBCountySelect, self).__init__(attrs, choices=GB_REGION_CHOICES)
+
class GBNationSelect(Select):
"""
A Select widget that uses a list of UK Nations as its choices.
"""
def __init__(self, attrs=None):
super(GBNationSelect, self).__init__(attrs, choices=GB_NATIONS_CHOICES)
+
+
+class GBPhoneNumberField(CharField):
+ """
+ ==ACCEPTS==
+ This table lists the valid accepted input formats for GB numbers.
+ International:
+ + 44_ null 20_3000_5000 null
+ (+ 44_( 0)_ 20)_3000_5000 #5555
+ 00_ 44)_ 0)_( 121_555_7777 _#5555
+ 00_( 44)_( 0_ 121)_555_7777 #555
+ (00)_ 0_( 1750_615_777 _#555
+ (00)_( 1750)_615_777
+ (00_ 19467_55555
+ 011_ 19467)_55555
+ 011_( 1750_62555
+ (011)_ 1750)_62555
+ (011)_( 16977_3555
+ (011_ 16977)_3555
+ 500_777_888
+ 500)_777_888
+ National:
+ -> -> 0 ^as above ^as above
+ -> -> (0 ^ ^
+ Pick one item from each column. Underscores represent spaces or hyphens.
+ All number formats can also be matched without spaces or hyphens. The
+ '#' character can also be an 'x'.
+
+ "Be conservative in what you do, be liberal in what you accept from
+ others."
+ (Postel's Law)
+
+ ==REJECTS==
+ The following inputs are rejected:
+ - international format numbers that do not begin with an item from column
+ 1 above,
+ - international format numbers with country code other than 44,
+ - national format numbers that do not begin with item in column 3 above,
+ - numbers with more than 10 digits in NSN part,
+ - numbers with less than 9 digits in NSN part (except for two special
+ cases),
+ - numbers with incorrect number of digits in NSN for number range,
+ - numbers in ranges with NSN beginning 4 or 6 and other such non-valid
+ ranges,
+ - numbers with obviously non-GB formatting,
+ - numbers with multiple contiguous spaces,
+ - entries with letters and/or punctuation other than hyphens or brackets,
+ - 116xxx, 118xxx, 1xx, 999.
+
+ ==OUTPUTS==
+ Irrespective of the format used for input, all valid numbers are output
+ with a +44 prefix followed by a space and the 10 or 9 digit national
+ number arranged in the correct 2+8, 3+7, 4+6, 4+5, 5+5, 5+4 or 3+6 format.
+ """
+ default_error_messages = {
+ 'number_format': _('Not a valid format and/or number length.'),
+ 'number_range': _('Not a valid range or not a valid number length for '
+ 'this range')
+ }
+
+ def clean(self, value):
+ super(GBPhoneNumberField, self).clean(value)
+ if value in EMPTY_VALUES:
+ return ''
+
+ number_parts = {'prefix': '+44 ', 'NSN': '', 'extension': None}
+
+ valid_gb_pattern = re.compile(r"""
+ ^\(?
+ (?: # leading 00, 011 or + before 44 with optional (0)
+ # parentheses, hyphens and spaces optional
+ (?:0(?:0|11)\)?[\s-]?\(?|\+)44\)?[\s-]?\(?(?:0\)?[\s-]?\(?)?
+ |
+ 0 # leading 0
+ )
+ (?:
+ \d{5}\)?[\s-]?\d{4,5} # [5+4][5+5]
+ |
+ \d{4}\)?[\s-]?(?:\d{5}|\d{3}[\s-]?\d{3}) # [4+5][4+6]
+ |
+ \d{3}\)?[\s-]?\d{3}[\s-]?\d{3,4} # [3+6][3+7]
+ |
+ \d{2}\)?[\s-]?\d{4}[\s-]?\d{4} # [2+8]
+ |
+ 8(?:00[\s-]?11[\s-]?11|45[\s-]?46[\s-]?4\d) # [0+7]
+ )
+ (?:
+ (?:[\s-]?(?:x|ext\.?|\#)\d+)? # optional extension number
+ )
+ $""", re.X)
+
+ gb_number_parts = re.compile(r"""
+ ^\(?
+ (?: # leading 00, 011 or + before 44 with optional (0)
+ # parentheses, hyphens and spaces optional
+ (?:0(?:0|11)\)?[\s-]?\(?|\+)(44)\)?[\s-]?\(?(?:0\)?[\s-]?\(?)?
+ |
+ 0 # leading 0
+ )
+ (
+ [1-9]\d{1,4}\)?[\s\d-]+ # NSN
+ )
+ (?:
+ ((?:x|ext\.?|\#)\d+)? # optional extension number
+ )
+ $""", re.X)
+
+ # Check if number entered matches a valid format
+ if not re.search(valid_gb_pattern, value):
+ raise ValidationError(self.default_error_messages['number_format'])
+
+ # Extract number parts: prefix, NSN, extension
+ # group(1) contains "44" or None depending on whether number entered in
+ # international or national format
+ # group(2) contains NSN
+ # group(3) contains extension
+ m = re.search(gb_number_parts, value)
+ if m.group:
+ # Extract NSN part of GB number
+ if m.group(2):
+ # Trim NSN and remove space, hyphen or ')' if present
+ translate_table = dict((ord(char), u'') for char in u')- ')
+ number_parts['NSN'] = m.group(2).translate(
+ translate_table).strip()
+
+ # Extract extension
+ if m.group(3):
+ # Add a # and remove the x
+ number_parts['extension'] = '#' + m.group(3)[1:]
+ if not number_parts:
+ raise ValidationError(self.default_error_messages['number_format'])
+
+ phone_number_nsn = number_parts['NSN']
+ # Check if NSN entered is in a valid range
+ if not valid_gb_phone_range(phone_number_nsn):
+ raise ValidationError(self.default_error_messages['number_range'])
+
+ return format_gb_phone_number(number_parts)
+
+
+def valid_gb_phone_range(phone_number_nsn):
+ """
+ Verifies that phone_number_nsn is a valid UK phone number range by initial
+ digits and length. Tests the NSN part for length and number range. Based on
+ http://www.aa-asterisk.org.uk/index.php/Number_format
+ http://www.aa-asterisk.org.uk/index.php/Regular_Expressions_for_Validating_and_Formatting_UK_Telephone_Numbers
+ @param string phone_number_nsn
+ @return boolean Returns boolean False if the phone number is not valid.
+ """
+ return re.match(re.compile(r"""
+ ^( # 2d with 10 digits [2+8] Landlines
+ 2(?:0[01378]|3[0189]|4[017]|8[0-46-9]|9[012])\d{7}
+ | # 11d, 1d1 with 10 digits [3+7] Landlines
+ 1(?:(?:1(?:3[0-48]|[46][0-4]|5[012789]|7[0-49]|8[01349])|21[0-7]|31[0-8]|[459]1\d|61[0-46-9]))\d{6}
+ | # 1ddd (and 1dddd) with 10 digits [4+6][5+5] Landlines
+ 1(?:2(?:0[024-9]|2[3-9]|3[3-79]|4[1-689]|[58][02-9]|6[0-4789]|7[013-9]|9\d)|3(?:0\d|[25][02-9]|3[02-579]|[468][0-46-9]|7[1235679]|9[24578])|4(?:0[03-9]|2[02-5789]|[37]\d|4[02-69]|5[0-8]|[69][0-79]|8[0-5789])|5(?:0[1235-9]|2[024-9]|3[0145689]|4[02-9]|5[03-9]|6\d|7[0-35-9]|8[0-468]|9[0-5789])|6(?:0[034689]|2[0-689]|[38][013-9]|4[1-467]|5[0-69]|6[13-9]|7[0-8]|9[0124578])|7(?:0[0246-9]|2\d|3[023678]|4[03-9]|5[0-46-9]|6[013-9]|7[0-35-9]|8[024-9]|9[02-9])|8(?:0[35-9]|2[1-5789]|3[02-578]|4[0-578]|5[124-9]|6[2-69]|7\d|8[02-9]|9[02569])|9(?:0[02-589]|2[02-689]|3[1-5789]|4[2-9]|5[0-579]|6[234789]|7[0124578]|8\d|9[2-57]))\d{6}
+ | # 1ddd with 9 digits [4+5] Landlines
+ 1(?:2(?:0(?:46[1-4]|87[2-9])|545[1-79]|76(?:2\d|3[1-8]|6[1-6])|9(?:7(?:2[0-4]|3[2-5])|8(?:2[2-8]|7[0-4789]|8[345])))|3(?:638[2-5]|647[23]|8(?:47[04-9]|64[015789]))|4(?:044[1-7]|20(?:2[23]|8\d)|6(?:0(?:30|5[2-57]|6[1-8]|7[2-8])|140)|8(?:052|87[123]))|5(?:24(?:3[2-79]|6\d)|276\d|6(?:26[06-9]|686))|6(?:06(?:4\d|7[4-79])|295[567]|35[34]\d|47(?:24|61)|59(?:5[08]|6[67]|74)|955[0-4])|7(?:26(?:6[13-9]|7[0-7])|442\d|50(?:2[0-3]|[3-68]2|76))|8(?:27[56]\d|37(?:5[2-5]|8[239])|84(?:3[2-58]))|9(?:0(?:0(?:6[1-8]|85)|52\d)|3583|4(?:66[1-8]|9(?:2[01]|81))|63(?:23|3[1-4])|9561))\d{3}
+ | # 1ddd with 9 digits [4+5] Landlines (special case)
+ 176888[234678]\d{2}
+ | # 1dddd with 9 digits [5+4] Landlines
+ 16977[23]\d{3}
+ | # 7ddd (including 7624) (not 70, 76) with 10 digits [4+6] Mobile phones
+ 7(?:[1-4]\d\d|5(?:0[0-8]|[13-9]\d|2[0-35-9])|624|7(?:0[1-9]|[1-7]\d|8[02-9]|9[0-689])|8(?:[014-9]\d|[23][0-8])|9(?:[04-9]\d|1[02-9]|2[0-35-9]|3[0-689]))\d{6}
+ | # 76 (excluding 7624) with 10 digits [2+8] Pagers
+ 76(?:0[012]|2[356]|4[0134]|5[49]|6[0-369]|77|81|9[39])\d{6}
+ | # 800 with 9 or 10 digits, 808 with 10 digits, 500 with 9 digits [3+7][3+6] Freephone
+ 80(?:0\d{6,7}|8\d{7})|500\d{6}
+ | # 871, 872, 873, 90d, 91d, 980, 981, 982, 983 with 10 digits [3+7] Premium rate
+ (?:87[123]|9(?:[01]\d|8[0-3]))\d{7}
+ | # 842, 843, 844, 845, 870 with 10 digits [3+7] Business rate
+ 8(?:4[2-5]|70)\d{7}
+ | # 70 with 10 digits [2+8] Personal numbers
+ 70\d{8}
+ | # 56 with 10 digits [2+8] LIECS&VoIP
+ 56\d{8}
+ | # 30d, 33d, 34d, 37d, 55 with 10 digits [3+7] UAN and [2+8] Corporate
+ (?:3[0347]|55)\d{8}
+ | # 800 1111, 845 46 4d with 7 digits [3+4] Freephone helplines
+ 8(?:001111|45464\d)
+ )$
+ """, re.X), phone_number_nsn)
+
+
+def format_gb_nsn(phone_number_nsn):
+ """
+ Format GB phone numbers in correct format per number range. Based on
+ http://www.aa-asterisk.org.uk/index.php/Number_format
+ http://www.aa-asterisk.org.uk/index.php/Regular_Expressions_for_Validating_and_Formatting_UK_Telephone_Numbers
+ created by @g1smd
+ @param string phone_number_nsn Must be the 10 or 9 digit NSN part of the
+ number.
+ @return string phone_number_nsn Returns correctly formatted NSN by length
+ and range.
+ """
+ nsn_length = len(phone_number_nsn)
+ # RegEx patterns to define formatting by length and initial digits
+ # [2+8] 2d, 55, 56, 70, 76 (not 7624) with 10 digits
+ pattern28 = re.compile(r"^(?:2|5[56]|7(?:0|6(?:[013-9]|2[0-35-9])))")
+ capture28 = re.compile(r"^(\d{2})(\d{4})(\d{4})$")
+ # [3+7] 11d, 1d1, 3dd, 80d, 84d, 87d, 9dd with 10 digits
+ pattern37 = re.compile(r"^(?:1(?:1|\d1)|3|8(?:0[08]|4[2-5]|7[0-3])|9[018])")
+ capture37 = re.compile(r"^(\d{3})(\d{3})(\d{4})$")
+ # [5+5] 1dddd (12 areas) with 10 digits
+ pattern55 = re.compile(r"^(?:1(?:3873|5(?:242|39[456])|697[347]|768[347]|9467))")
+ capture55 = re.compile(r"^(\d{5})(\d{5})")
+ # [5+4] 1dddd (1 area) with 9 digits
+ pattern54 = re.compile(r"^(?:16977[23])")
+ capture54 = re.compile(r"^(\d{5})(\d{4})$")
+ # [4+6] 1ddd, 7ddd (inc 7624) (not 70, 76) with 10 digits
+ pattern46 = re.compile(r"^(?:1|7(?:[1-5789]|624))")
+ capture46 = re.compile(r"^(\d{4})(\d{6})$")
+ # [4+5] 1ddd (40 areas) with 9 digits
+ pattern45 = re.compile(r"^(?:1(?:2(?:0[48]|54|76|9[78])|3(?:6[34]|8[46])|4(?:04|20|6[01]|8[08])|5(?:2[47]|6[26])|6(?:06|29|35|47|59|95)|7(?:26|44|50|68)|8(?:27|37|84)|9(?:0[05]|35|4[69]|63|95)))")
+ capture45 = re.compile(r"^(\d{4})(\d{5})$")
+ # [3+6] 500, 800 with 9 digits
+ pattern36 = re.compile(r"^(?:[58]00)")
+ capture36 = re.compile(r"^(\d{3})(\d{6})$")
+ # [3+4] 8001111, 845464d with 7 digits
+ pattern34 = re.compile(r"^(?:8(?:001111|45464\d))")
+ capture34 = re.compile(r"^(\d{3})(\d{4})$")
+ # Format numbers by leading digits and length
+ if nsn_length is 10 and re.match(pattern28, phone_number_nsn):
+ m = (re.search(capture28, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2) + ' ' + m.group(3)
+ elif nsn_length is 10 and re.match(pattern37, phone_number_nsn):
+ m = (re.search(capture37, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2) + ' ' + m.group(3)
+ elif nsn_length is 10 and re.match(pattern55, phone_number_nsn):
+ m = (re.search(capture55, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2)
+ elif nsn_length is 9 and re.match(pattern54, phone_number_nsn):
+ m = (re.search(capture54, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2)
+ elif nsn_length is 10 and re.match(pattern46, phone_number_nsn):
+ m = (re.search(capture46, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2)
+ elif nsn_length is 9 and re.match(pattern45, phone_number_nsn):
+ m = (re.search(capture45, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2)
+ elif nsn_length is 9 and re.match(pattern36, phone_number_nsn):
+ m = (re.search(capture36, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2)
+ elif nsn_length is 7 and re.match(pattern34, phone_number_nsn):
+ m = (re.search(capture34, phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2)
+ elif nsn_length > 5:
+ # Default format for non-valid numbers (shouldn't ever get here)
+ m = (re.search("^(\d)(\d{4})(\d*)$", phone_number_nsn))
+ if m.group:
+ phone_number_nsn = m.group(1) + ' ' + m.group(2) + ' ' + m.group(3)
+
+ return phone_number_nsn
+
+
+def format_gb_phone_number(number_parts):
+ """
+ Convert a valid United Kingdom phone number into standard +44 20 3000 5555
+ #0001, +44 121 555 7788, +44 1970 223344, +44 1750 62555, +44 19467 55555
+ or +44 16977 2333 international format or into national format with 0,
+ according to entry format. Accepts a wide range of input formats and
+ prefixes and re-formats the number taking into account the required 2+8,
+ 3+7, 4+6, 4+5, 5+5, 5+4 and 3+6 formats by number range.
+
+ @param dict number_parts must be a valid nine or ten-digit number split
+ into its constituent parts
+ @return string phone_number
+ """
+ phone_number = number_parts['prefix'] + number_parts['NSN'] + \
+ str(number_parts['extension'])
+
+ if number_parts:
+ # Grab the NSN part of GB number
+ phone_number_nsn = number_parts['NSN']
+ if not phone_number_nsn:
+ return phone_number
+
+ # Set prefix (will be +44 or 0)
+ if 'prefix' in number_parts and number_parts['prefix'] is not None:
+ phone_number = number_parts['prefix']
+
+ # Remove spaces, hyphens, and brackets from NSN part of GB number
+ translate_table = dict((ord(char), u'') for char in u')- ')
+ phone_number_nsn = phone_number_nsn.translate(translate_table).strip()
+ # Format NSN part of GB number
+ phone_number += format_gb_nsn(phone_number_nsn)
+
+ # Grab extension and trim it
+ if 'extension' in number_parts and \
+ number_parts['extension'] is not None:
+ phone_number += ' ' + number_parts['extension'].strip()
+
+ return phone_number
View
4 docs/ref/contrib/localflavor.txt
@@ -1276,6 +1276,10 @@ United Kingdom (``gb``)
expression used is sourced from the schema for British Standard BS7666
address types at http://www.cabinetoffice.gov.uk/media/291293/bs7666-v2-0.xml.
+.. class:: gb.forms.GBPhoneNumberField
+
+ A form field that validates input as a UK phone number.
+
.. class:: gb.forms.GBCountySelect
A ``Select`` widget that uses a list of UK counties/regions as its choices.
View
67 tests/regressiontests/localflavor/gb/tests.py
@@ -1,6 +1,8 @@
from __future__ import unicode_literals
-from django.contrib.localflavor.gb.forms import GBPostcodeField
+from django.contrib.localflavor.gb.forms import (
+ GBPostcodeField, GBPhoneNumberField
+)
from django.test import SimpleTestCase
@@ -30,3 +32,66 @@ def test_GBPostcodeField(self):
}
kwargs = {'error_messages': {'invalid': 'Enter a bloody postcode!'}}
self.assertFieldOutput(GBPostcodeField, valid, invalid, field_kwargs=kwargs)
+
+ def test_GBPhoneNumberField(self):
+ valid = {
+ '020 3000 5555': '+44 20 3000 5555',
+ '(020) 3000 5555': '+44 20 3000 5555',
+ '+44 20 3000 5555': '+44 20 3000 5555',
+ '0203 000 5555': '+44 20 3000 5555',
+ '(0203) 000 5555': '+44 20 3000 5555',
+ '02030 005 555': '+44 20 3000 5555',
+ '+44 (0) 20 3000 5555': '+44 20 3000 5555',
+ '+44(0)203 000 5555': '+44 20 3000 5555',
+ '00 (44) 2030 005 555': '+44 20 3000 5555',
+ '(+44 203) 000 5555': '+44 20 3000 5555',
+ '(+44) 203 000 5555': '+44 20 3000 5555',
+ '011 44 203 000 5555': '+44 20 3000 5555',
+ '020-3000-5555': '+44 20 3000 5555',
+ '(020)-3000-5555': '+44 20 3000 5555',
+ '+44-20-3000-5555': '+44 20 3000 5555',
+ '0203-000-5555': '+44 20 3000 5555',
+ '(0203)-000-5555': '+44 20 3000 5555',
+ '02030-005-555': '+44 20 3000 5555',
+ '+44-(0)-20-3000-5555': '+44 20 3000 5555',
+ '+44(0)203-000-5555': '+44 20 3000 5555',
+ '00-(44)-2030-005-555': '+44 20 3000 5555',
+ '(+44-203)-000-5555': '+44 20 3000 5555',
+ '(+44)-203-000-5555': '+44 20 3000 5555',
+ '011-44-203-000-5555': '+44 20 3000 5555',
+ '0114 223 4567': '+44 114 223 4567',
+ '01142 345 567': '+44 114 234 5567',
+ '01415 345 567': '+44 141 534 5567',
+ '+44 1213 456 789': '+44 121 345 6789',
+ '00 44 (0) 1697 73555': '+44 16977 3555',
+ '011 44 14 1890 2345': '+44 141 890 2345',
+ '011 44 11 4345 2345': '+44 114 345 2345',
+ '020 3000 5000': '+44 20 3000 5000',
+ '0121 555 7777': '+44 121 555 7777',
+ '01750 615777': '+44 1750 615777',
+ '019467 55555': '+44 19467 55555',
+ '01750 62555': '+44 1750 62555',
+ '016977 3555': '+44 16977 3555',
+ '0500 777888': '+44 500 777888',
+ '020 7000 9000 x4567': '+44 20 7000 9000 #4567',
+ '020 7000 9000 #4567': '+44 20 7000 9000 #4567'
+ }
+ errors = GBPhoneNumberField.default_error_messages
+ messages = {
+ 'number_format': [errors['number_format'].translate('gb')],
+ 'number_range': [errors['number_range'].translate('gb')]
+ }
+ invalid = {
+ '011 44 203 000 5555 5': messages['number_format'],
+ '+44 20 300 5555': messages['number_format'],
+ '025 4555 6777': messages['number_range'],
+ '0119 456 4567': messages['number_range'],
+ '0623 111 3456': messages['number_range'],
+ '0756 334556': messages['number_range'],
+ '020 5000 5000': messages['number_range'],
+ '0171 555 7777': messages['number_range'],
+ '01999 777888': messages['number_range'],
+ '01750 61777': messages['number_range'],
+ '020 7000 9000 ext. 4567': messages['number_format']
+ }
+ self.assertFieldOutput(GBPhoneNumberField, valid, invalid)
Something went wrong with that request. Please try again.