2
0
Эх сурвалжийг харах

Fixed #811 -- Added support for IPv6 to forms and model fields. Many thanks to Erik Romijn.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@16366 bcc190cf-cafb-0310-a4f2-bffc1f526a37
Jannis Leidel 14 жил өмнө
parent
commit
ce3c281090

+ 36 - 0
django/core/validators.py

@@ -5,6 +5,7 @@ import urlparse
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
 from django.utils.encoding import smart_unicode
+from django.utils.ipv6 import is_valid_ipv6_address
 
 # These values, if given to validate(), will trigger the self.required check.
 EMPTY_VALUES = (None, '', [], (), {})
@@ -145,6 +146,41 @@ validate_slug = RegexValidator(slug_re, _(u"Enter a valid 'slug' consisting of l
 ipv4_re = re.compile(r'^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}$')
 validate_ipv4_address = RegexValidator(ipv4_re, _(u'Enter a valid IPv4 address.'), 'invalid')
 
+def validate_ipv6_address(value):
+    if not is_valid_ipv6_address(value):
+        raise ValidationError(_(u'Enter a valid IPv6 address.'), code='invalid')
+
+def validate_ipv46_address(value):
+    try:
+        validate_ipv4_address(value)
+    except ValidationError:
+        try:
+            validate_ipv6_address(value)
+        except ValidationError:
+            raise ValidationError(_(u'Enter a valid IPv4 or IPv6 address.'), code='invalid')
+
+ip_address_validator_map = {
+    'both': ([validate_ipv46_address], _('Enter a valid IPv4 or IPv6 address.')),
+    'ipv4': ([validate_ipv4_address], _('Enter a valid IPv4 address.')),
+    'ipv6': ([validate_ipv6_address], _('Enter a valid IPv6 address.')),
+}
+
+def ip_address_validators(protocol, unpack_ipv4):
+    """
+    Depending on the given parameters returns the appropriate validators for
+    the GenericIPAddressField.
+
+    This code is here, because it is exactly the same for the model and the form field.
+    """
+    if protocol != 'both' and unpack_ipv4:
+        raise ValueError(
+            "You can only use `unpack_ipv4` if `protocol` is set to 'both'")
+    try:
+        return ip_address_validator_map[protocol.lower()]
+    except KeyError:
+        raise ValueError("The protocol '%s' is unknown. Supported: %s"
+                         % (protocol, ip_address_validator_map.keys()))
+
 comma_separated_int_list_re = re.compile('^[\d,]+$')
 validate_comma_separated_integer_list = RegexValidator(comma_separated_int_list_re, _(u'Enter only digits separated by commas.'), 'invalid')
 

+ 1 - 0
django/db/backends/mysql/creation.py

@@ -19,6 +19,7 @@ class DatabaseCreation(BaseDatabaseCreation):
         'IntegerField':      'integer',
         'BigIntegerField':   'bigint',
         'IPAddressField':    'char(15)',
+        'GenericIPAddressField': 'char(39)',
         'NullBooleanField':  'bool',
         'OneToOneField':     'integer',
         'PositiveIntegerField': 'integer UNSIGNED',

+ 1 - 0
django/db/backends/oracle/creation.py

@@ -27,6 +27,7 @@ class DatabaseCreation(BaseDatabaseCreation):
         'IntegerField':                 'NUMBER(11)',
         'BigIntegerField':              'NUMBER(19)',
         'IPAddressField':               'VARCHAR2(15)',
+        'GenericIPAddressField':        'VARCHAR2(39)',
         'NullBooleanField':             'NUMBER(1) CHECK ((%(qn_column)s IN (0,1)) OR (%(qn_column)s IS NULL))',
         'OneToOneField':                'NUMBER(11)',
         'PositiveIntegerField':         'NUMBER(11) CHECK (%(qn_column)s >= 0)',

+ 1 - 0
django/db/backends/postgresql_psycopg2/creation.py

@@ -21,6 +21,7 @@ class DatabaseCreation(BaseDatabaseCreation):
         'IntegerField':      'integer',
         'BigIntegerField':   'bigint',
         'IPAddressField':    'inet',
+        'GenericIPAddressField': 'inet',
         'NullBooleanField':  'boolean',
         'OneToOneField':     'integer',
         'PositiveIntegerField': 'integer CHECK ("%(column)s" >= 0)',

+ 1 - 0
django/db/backends/postgresql_psycopg2/introspection.py

@@ -12,6 +12,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
         700: 'FloatField',
         701: 'FloatField',
         869: 'IPAddressField',
+        869: 'GenericIPAddressField',
         1043: 'CharField',
         1082: 'DateField',
         1083: 'TimeField',

+ 1 - 0
django/db/backends/sqlite3/creation.py

@@ -20,6 +20,7 @@ class DatabaseCreation(BaseDatabaseCreation):
         'IntegerField':                 'integer',
         'BigIntegerField':              'bigint',
         'IPAddressField':               'char(15)',
+        'GenericIPAddressField':        'char(39)',
         'NullBooleanField':             'bool',
         'OneToOneField':                'integer',
         'PositiveIntegerField':         'integer unsigned',

+ 37 - 1
django/db/models/fields/__init__.py

@@ -17,6 +17,7 @@ from django.utils.text import capfirst
 from django.utils.translation import ugettext_lazy as _
 from django.utils.encoding import smart_unicode, force_unicode, smart_str
 from django.utils import datetime_safe
+from django.utils.ipv6 import clean_ipv6_address, is_valid_ipv6_address
 
 class NOT_PROVIDED:
     pass
@@ -920,7 +921,7 @@ class BigIntegerField(IntegerField):
 
 class IPAddressField(Field):
     empty_strings_allowed = False
-    description = _("IP address")
+    description = _("IPv4 address")
     def __init__(self, *args, **kwargs):
         kwargs['max_length'] = 15
         Field.__init__(self, *args, **kwargs)
@@ -933,6 +934,41 @@ class IPAddressField(Field):
         defaults.update(kwargs)
         return super(IPAddressField, self).formfield(**defaults)
 
+class GenericIPAddressField(Field):
+    empty_strings_allowed = True
+    description = _("IP address")
+
+    def __init__(self, protocol='both', unpack_ipv4=False, *args, **kwargs):
+        self.unpack_ipv4 = unpack_ipv4
+        self.default_validators, invalid_error_message = \
+            validators.ip_address_validators(protocol, unpack_ipv4)
+        self.default_error_messages['invalid'] = invalid_error_message
+        kwargs['max_length'] = 39
+        Field.__init__(self, *args, **kwargs)
+
+    def get_internal_type(self):
+        return "GenericIPAddressField"
+
+    def to_python(self, value):
+        if value and ':' in value:
+            return clean_ipv6_address(value,
+                self.unpack_ipv4, self.error_messages['invalid'])
+        return value
+
+    def get_prep_value(self, value):
+        if value and ':' in value:
+            try:
+                return clean_ipv6_address(value, self.unpack_ipv4)
+            except ValidationError:
+                pass
+        return value
+
+    def formfield(self, **kwargs):
+        defaults = {'form_class': forms.GenericIPAddressField}
+        defaults.update(kwargs)
+        return super(GenericIPAddressField, self).formfield(**defaults)
+
+
 class NullBooleanField(Field):
     empty_strings_allowed = False
     default_error_messages = {

+ 22 - 2
django/forms/fields.py

@@ -18,6 +18,7 @@ from django.core import validators
 from django.utils import formats
 from django.utils.translation import ugettext_lazy as _
 from django.utils.encoding import smart_unicode, smart_str, force_unicode
+from django.utils.ipv6 import clean_ipv6_address
 
 # Provide this import for backwards compatibility.
 from django.core.validators import EMPTY_VALUES
@@ -34,8 +35,8 @@ __all__ = (
     'RegexField', 'EmailField', 'FileField', 'ImageField', 'URLField',
     'BooleanField', 'NullBooleanField', 'ChoiceField', 'MultipleChoiceField',
     'ComboField', 'MultiValueField', 'FloatField', 'DecimalField',
-    'SplitDateTimeField', 'IPAddressField', 'FilePathField', 'SlugField',
-    'TypedChoiceField', 'TypedMultipleChoiceField'
+    'SplitDateTimeField', 'IPAddressField', 'GenericIPAddressField', 'FilePathField',
+    'SlugField', 'TypedChoiceField', 'TypedMultipleChoiceField'
 )
 
 
@@ -953,6 +954,25 @@ class IPAddressField(CharField):
     default_validators = [validators.validate_ipv4_address]
 
 
+class GenericIPAddressField(CharField):
+    default_error_messages = {}
+
+    def __init__(self, protocol='both', unpack_ipv4=False, *args, **kwargs):
+        self.unpack_ipv4 = unpack_ipv4
+        self.default_validators, invalid_error_message = \
+            validators.ip_address_validators(protocol, unpack_ipv4)
+        self.default_error_messages['invalid'] = invalid_error_message
+        super(GenericIPAddressField, self).__init__(*args, **kwargs)
+
+    def to_python(self, value):
+        if not value:
+            return ''
+        if value and ':' in value:
+                return clean_ipv6_address(value,
+                    self.unpack_ipv4, self.error_messages['invalid'])
+        return value
+
+
 class SlugField(CharField):
     default_error_messages = {
         'invalid': _(u"Enter a valid 'slug' consisting of letters, numbers,"

+ 267 - 0
django/utils/ipv6.py

@@ -0,0 +1,267 @@
+# This code was mostly based on ipaddr-py
+# Copyright 2007 Google Inc. http://code.google.com/p/ipaddr-py/
+# Licensed under the Apache License, Version 2.0 (the "License").
+from django.core.exceptions import ValidationError
+
+def clean_ipv6_address(ip_str, unpack_ipv4=False,
+        error_message="This is not a valid IPv6 address"):
+    """
+    Cleans a IPv6 address string.
+
+    Validity is checked by calling is_valid_ipv6_address() - if an
+    invalid address is passed, ValidationError is raised.
+
+    Replaces the longest continious zero-sequence with "::" and
+    removes leading zeroes and makes sure all hextets are lowercase.
+
+    Args:
+        ip_str: A valid IPv6 address.
+        unpack_ipv4: if an IPv4-mapped address is found,
+        return the plain IPv4 address (default=False).
+        error_message: A error message for in the ValidationError.
+
+    Returns:
+        A compressed IPv6 address, or the same value
+
+    """
+    best_doublecolon_start = -1
+    best_doublecolon_len = 0
+    doublecolon_start = -1
+    doublecolon_len = 0
+
+    if not is_valid_ipv6_address(ip_str):
+        raise ValidationError(error_message)
+
+    # This algorithm can only handle fully exploded
+    # IP strings
+    ip_str = _explode_shorthand_ip_string(ip_str)
+
+    ip_str = _sanitize_ipv4_mapping(ip_str)
+
+    # If needed, unpack the IPv4 and return straight away
+    # - no need in running the rest of the algorithm
+    if unpack_ipv4:
+        ipv4_unpacked = _unpack_ipv4(ip_str)
+
+        if ipv4_unpacked:
+            return ipv4_unpacked
+
+    hextets = ip_str.split(":")
+
+    for index in range(len(hextets)):
+        # Remove leading zeroes
+        hextets[index] = hextets[index].lstrip('0')
+        if not hextets[index]:
+            hextets[index] = '0'
+
+        # Determine best hextet to compress
+        if hextets[index] == '0':
+            doublecolon_len += 1
+            if doublecolon_start == -1:
+                # Start of a sequence of zeros.
+                doublecolon_start = index
+            if doublecolon_len > best_doublecolon_len:
+                # This is the longest sequence of zeros so far.
+                best_doublecolon_len = doublecolon_len
+                best_doublecolon_start = doublecolon_start
+        else:
+            doublecolon_len = 0
+            doublecolon_start = -1
+
+    # Compress the most suitable hextet
+    if best_doublecolon_len > 1:
+        best_doublecolon_end = (best_doublecolon_start +
+                                best_doublecolon_len)
+        # For zeros at the end of the address.
+        if best_doublecolon_end == len(hextets):
+            hextets += ['']
+        hextets[best_doublecolon_start:best_doublecolon_end] = ['']
+        # For zeros at the beginning of the address.
+        if best_doublecolon_start == 0:
+            hextets = [''] + hextets
+
+    result = ":".join(hextets)
+
+    return result.lower()
+
+
+def _sanitize_ipv4_mapping(ip_str):
+    """
+    Sanitize IPv4 mapping in a expanded IPv6 address.
+
+    This converts ::ffff:0a0a:0a0a to ::ffff:10.10.10.10.
+    If there is nothing to sanitize, returns an unchanged
+    string.
+
+    Args:
+        ip_str: A string, the expanded IPv6 address.
+
+    Returns:
+        The sanitized output string, if applicable.
+    """
+    if not ip_str.lower().startswith('0000:0000:0000:0000:0000:ffff:'):
+        # not an ipv4 mapping
+        return ip_str
+
+    hextets = ip_str.split(':')
+
+    if '.' in hextets[-1]:
+        # already sanitized
+        return ip_str
+
+    ipv4_address = "%d.%d.%d.%d" % (
+        int(hextets[6][0:2], 16),
+        int(hextets[6][2:4], 16),
+        int(hextets[7][0:2], 16),
+        int(hextets[7][2:4], 16),
+    )
+
+    result = ':'.join(hextets[0:6])
+    result += ':' + ipv4_address
+
+    return result
+
+def _unpack_ipv4(ip_str):
+    """
+    Unpack an IPv4 address that was mapped in a compressed IPv6 address.
+
+    This converts 0000:0000:0000:0000:0000:ffff:10.10.10.10 to 10.10.10.10.
+    If there is nothing to sanitize, returns None.
+
+    Args:
+        ip_str: A string, the expanded IPv6 address.
+
+    Returns:
+        The unpacked IPv4 address, or None if there was nothing to unpack.
+    """
+    if not ip_str.lower().startswith('0000:0000:0000:0000:0000:ffff:'):
+        return None
+
+    hextets = ip_str.split(':')
+    return hextets[-1]
+
+def is_valid_ipv6_address(ip_str):
+    """
+    Ensure we have a valid IPv6 address.
+
+    Args:
+        ip_str: A string, the IPv6 address.
+
+    Returns:
+        A boolean, True if this is a valid IPv6 address.
+
+    """
+    from django.core.validators import validate_ipv4_address
+
+    # We need to have at least one ':'.
+    if ':' not in ip_str:
+        return False
+
+    # We can only have one '::' shortener.
+    if ip_str.count('::') > 1:
+        return False
+
+    # '::' should be encompassed by start, digits or end.
+    if ':::' in ip_str:
+        return False
+
+    # A single colon can neither start nor end an address.
+    if ((ip_str.startswith(':') and not ip_str.startswith('::')) or
+            (ip_str.endswith(':') and not ip_str.endswith('::'))):
+        return False
+
+    # We can never have more than 7 ':' (1::2:3:4:5:6:7:8 is invalid)
+    if ip_str.count(':') > 7:
+        return False
+
+    # If we have no concatenation, we need to have 8 fields with 7 ':'.
+    if '::' not in ip_str and ip_str.count(':') != 7:
+        # We might have an IPv4 mapped address.
+        if ip_str.count('.') != 3:
+            return False
+
+    ip_str = _explode_shorthand_ip_string(ip_str)
+
+    # Now that we have that all squared away, let's check that each of the
+    # hextets are between 0x0 and 0xFFFF.
+    for hextet in ip_str.split(':'):
+        if hextet.count('.') == 3:
+            # If we have an IPv4 mapped address, the IPv4 portion has to
+            # be at the end of the IPv6 portion.
+            if not ip_str.split(':')[-1] == hextet:
+                return False
+            try:
+                validate_ipv4_address(hextet)
+            except ValidationError:
+                return False
+        else:
+            try:
+                # a value error here means that we got a bad hextet,
+                # something like 0xzzzz
+                if int(hextet, 16) < 0x0 or int(hextet, 16) > 0xFFFF:
+                    return False
+            except ValueError:
+                return False
+    return True
+
+
+def _explode_shorthand_ip_string(ip_str):
+    """
+    Expand a shortened IPv6 address.
+
+    Args:
+        ip_str: A string, the IPv6 address.
+
+    Returns:
+        A string, the expanded IPv6 address.
+
+    """
+    if not _is_shorthand_ip(ip_str):
+        # We've already got a longhand ip_str.
+        return ip_str
+
+    new_ip = []
+    hextet = ip_str.split('::')
+
+    # If there is a ::, we need to expand it with zeroes
+    # to get to 8 hextets - unless there is a dot in the last hextet,
+    # meaning we're doing v4-mapping
+    if '.' in ip_str.split(':')[-1]:
+        fill_to = 7
+    else:
+        fill_to = 8
+
+    if len(hextet) > 1:
+        sep = len(hextet[0].split(':')) + len(hextet[1].split(':'))
+        new_ip = hextet[0].split(':')
+
+        for _ in xrange(fill_to - sep):
+            new_ip.append('0000')
+        new_ip += hextet[1].split(':')
+
+    else:
+        new_ip = ip_str.split(':')
+
+    # Now need to make sure every hextet is 4 lower case characters.
+    # If a hextet is < 4 characters, we've got missing leading 0's.
+    ret_ip = []
+    for hextet in new_ip:
+        ret_ip.append(('0' * (4 - len(hextet)) + hextet).lower())
+    return ':'.join(ret_ip)
+
+
+def _is_shorthand_ip(ip_str):
+    """Determine if the address is shortened.
+
+    Args:
+        ip_str: A string, the IPv6 address.
+
+    Returns:
+        A boolean, True if the address is shortened.
+
+    """
+    if ip_str.count('::') == 1:
+        return True
+    if filter(lambda x: len(x) < 4, ip_str.split(':')):
+        return True
+    return False

+ 39 - 0
docs/ref/forms/fields.txt

@@ -622,6 +622,45 @@ Takes two optional arguments for validation:
       expression.
     * Error message keys: ``required``, ``invalid``
 
+``GenericIPAddressField``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. class:: GenericIPAddressField(**kwargs)
+
+.. versionadded:: 1.4
+
+A field containing either an IPv4 or an IPv6 address.
+
+    * Default widget: ``TextInput``
+    * Empty value: ``''`` (an empty string)
+    * Normalizes to: A Unicode object. IPv6 addresses are
+      normalized as described below.
+    * Validates that the given value is a valid IP address.
+    * Error message keys: ``required``, ``invalid``
+
+The IPv6 address normalization follows `RFC4291 section 2.2`_, including using
+the IPv4 format suggested in paragraph 3 of that section, like
+``::ffff:192.0.2.0``. For example, ``2001:0::0:01`` would be normalized to
+``2001::1``, and ``::ffff:0a0a:0a0a`` to ``::ffff:10.10.10.10``. All
+characters are converted to lowercase.
+
+.. _RFC4291 section 2.2: http://tools.ietf.org/html/rfc4291#section-2.2
+
+Takes two optional arguments:
+
+.. attribute:: GenericIPAddressField.protocol
+
+    Limits valid inputs to the specified protocol.
+    Accepted values are ``both`` (default), ``IPv4``
+    or ``IPv6``. Matching is case insensitive.
+
+.. attribute:: GenericIPAddressField.unpack_ipv4
+
+    Unpacks IPv4 mapped addresses like ``::ffff::192.0.2.1``.
+    If this option is enabled that address would be unpacked to
+    ``192.0.2.1``. Default is disabled. Can only be used
+    when ``protocol`` is set to ``'both'``.
+
 ``MultipleChoiceField``
 ~~~~~~~~~~~~~~~~~~~~~~~
 

+ 32 - 0
docs/ref/models/fields.txt

@@ -760,6 +760,38 @@ single-line input).
 An IP address, in string format (e.g. "192.0.2.30"). The admin represents this
 as an ``<input type="text">`` (a single-line input).
 
+``GenericIPAddressField``
+-------------------------
+
+.. class:: GenericIPAddressField([protocols=both, unpack_ipv4=False, **options])
+
+.. versionadded:: 1.4
+
+An IPv4 or IPv6 address, in string format (e.g. ``192.0.2.30`` or
+``2a02:42fe::4``). The admin represents this as an ``<input type="text">``
+(a single-line input).
+
+The IPv6 address normalization follows `RFC4291 section 2.2`_, including using
+the IPv4 format suggested in paragraph 3 of that section, like
+``::ffff:192.0.2.0``. For example, ``2001:0::0:01`` would be normalized to
+``2001::1``, and ``::ffff:0a0a:0a0a`` to ``::ffff:10.10.10.10``. All
+characters are converted to lowercase.
+
+.. _RFC4291 section 2.2: http://tools.ietf.org/html/rfc4291#section-2.2
+
+.. attribute:: GenericIPAddressField.protocol
+
+    Limits valid inputs to the specified protocol.
+    Accepted values are ``'both'`` (default), ``'IPv4'``
+    or ``'IPv6'``. Matching is case insensitive.
+
+.. attribute:: GenericIPAddressField.unpack_ipv4
+
+    Unpacks IPv4 mapped addresses like ``::ffff::192.0.2.1``.
+    If this option is enabled that address would be unpacked to
+    ``192.0.2.1``. Default is disabled. Can only be used
+    when ``protocol`` is set to ``'both'``.
+
 ``NullBooleanField``
 --------------------
 

+ 17 - 0
docs/ref/validators.txt

@@ -130,6 +130,23 @@ to, or in lieu of custom ``field.clean()`` methods.
     A :class:`RegexValidator` instance that ensures a value looks like an IPv4
     address.
 
+``validate_ipv6_address``
+-------------------------
+.. versionadded:: 1.4
+
+.. data:: validate_ipv6_address
+
+    Uses :mod:`django.utils.ipv6` to check the validity of an IPv6 address.
+
+``validate_ipv46_address``
+--------------------------
+.. versionadded:: 1.4
+
+.. data:: validate_ipv46_address
+
+    Uses both ``validate_ipv4_address`` and ``validate_ipv6_address`` to
+    ensure a value is either a valid IPv4 or IPv6 address.
+
 ``validate_comma_separated_integer_list``
 -----------------------------------------
 .. data:: validate_comma_separated_integer_list

+ 9 - 0
docs/releases/1.4.txt

@@ -155,6 +155,15 @@ You may override or customize the default filtering by writing a
 :ref:`custom filter<custom-error-reports>`. Learn more on
 :ref:`Filtering error reports<filtering-error-reports>`.
 
+Extended IPv6 support
+~~~~~~~~~~~~~~~~~~~~~
+
+The previously added support for IPv6 addresses when using the runserver
+management command in Django 1.3 has now been further extended by adding
+a :class:`~django.db.models.fields.GenericIPAddressField` model field,
+a :class:`~django.forms.fields.GenericIPAddressField` form field and
+the validators :data:`~django.core.validators.validate_ipv46_address` and
+:data:`~django.core.validators.validate_ipv6_address`
 
 Minor features
 ~~~~~~~~~~~~~~

+ 2 - 0
docs/topics/forms/modelforms.txt

@@ -83,6 +83,8 @@ the full list of conversions:
 
     ``IPAddressField``               ``IPAddressField``
 
+    ``GenericIPAddressField``        ``GenericIPAddressField``
+
     ``ManyToManyField``              ``ModelMultipleChoiceField`` (see
                                      below)
 

+ 9 - 1
tests/modeltests/validation/models.py

@@ -81,4 +81,12 @@ class FlexibleDatePost(models.Model):
 
 class UniqueErrorsModel(models.Model):
     name = models.CharField(max_length=100, unique=True, error_messages={'unique': u'Custom unique name message.'})
-    number = models.IntegerField(unique=True, error_messages={'unique': u'Custom unique number message.'})
+    number = models.IntegerField(unique=True, error_messages={'unique': u'Custom unique number message.'})
+
+class GenericIPAddressTestModel(models.Model):
+    generic_ip = models.GenericIPAddressField(blank=True, unique=True)
+    v4_ip = models.GenericIPAddressField(blank=True, protocol="ipv4")
+    v6_ip = models.GenericIPAddressField(blank=True, protocol="ipv6")
+
+class GenericIPAddressWithUnpackUniqueTestModel(models.Model):
+    generic_v4unpack_ip = models.GenericIPAddressField(blank=True, unique=True, unpack_ipv4=True)

+ 58 - 1
tests/modeltests/validation/tests.py

@@ -2,7 +2,8 @@ from django import forms
 from django.test import TestCase
 from django.core.exceptions import NON_FIELD_ERRORS
 from modeltests.validation import ValidationTestCase
-from modeltests.validation.models import Author, Article, ModelToValidate
+from modeltests.validation.models import (Author, Article, ModelToValidate,
+    GenericIPAddressTestModel, GenericIPAddressWithUnpackUniqueTestModel)
 
 # Import other tests for this package.
 from modeltests.validation.validators import TestModelsWithValidators
@@ -77,6 +78,7 @@ class BaseModelValidationTests(ValidationTestCase):
         mtv = ModelToValidate(number=10, name='Some Name'*100)
         self.assertFailsValidation(mtv.full_clean, ['name',])
 
+
 class ArticleForm(forms.ModelForm):
     class Meta:
         model = Article
@@ -124,3 +126,58 @@ class ModelFormsTests(TestCase):
         article = Article(author_id=self.author.id)
         form = ArticleForm(data, instance=article)
         self.assertEqual(form.errors.keys(), ['pub_date'])
+
+
+class GenericIPAddressFieldTests(ValidationTestCase):
+
+    def test_correct_generic_ip_passes(self):
+        giptm = GenericIPAddressTestModel(generic_ip="1.2.3.4")
+        self.assertEqual(None, giptm.full_clean())
+        giptm = GenericIPAddressTestModel(generic_ip="2001::2")
+        self.assertEqual(None, giptm.full_clean())
+
+    def test_invalid_generic_ip_raises_error(self):
+        giptm = GenericIPAddressTestModel(generic_ip="294.4.2.1")
+        self.assertFailsValidation(giptm.full_clean, ['generic_ip',])
+        giptm = GenericIPAddressTestModel(generic_ip="1:2")
+        self.assertFailsValidation(giptm.full_clean, ['generic_ip',])
+
+    def test_correct_v4_ip_passes(self):
+        giptm = GenericIPAddressTestModel(v4_ip="1.2.3.4")
+        self.assertEqual(None, giptm.full_clean())
+
+    def test_invalid_v4_ip_raises_error(self):
+        giptm = GenericIPAddressTestModel(v4_ip="294.4.2.1")
+        self.assertFailsValidation(giptm.full_clean, ['v4_ip',])
+        giptm = GenericIPAddressTestModel(v4_ip="2001::2")
+        self.assertFailsValidation(giptm.full_clean, ['v4_ip',])
+
+    def test_correct_v6_ip_passes(self):
+        giptm = GenericIPAddressTestModel(v6_ip="2001::2")
+        self.assertEqual(None, giptm.full_clean())
+
+    def test_invalid_v6_ip_raises_error(self):
+        giptm = GenericIPAddressTestModel(v6_ip="1.2.3.4")
+        self.assertFailsValidation(giptm.full_clean, ['v6_ip',])
+        giptm = GenericIPAddressTestModel(v6_ip="1:2")
+        self.assertFailsValidation(giptm.full_clean, ['v6_ip',])
+
+    def test_v6_uniqueness_detection(self):
+        # These two addresses are the same with different syntax
+        giptm = GenericIPAddressTestModel(generic_ip="2001::1:0:0:0:0:2")
+        giptm.save()
+        giptm = GenericIPAddressTestModel(generic_ip="2001:0:1:2")
+        self.assertFailsValidation(giptm.full_clean, ['generic_ip',])
+
+    def test_v4_unpack_uniqueness_detection(self):
+        # These two are different, because we are not doing IPv4 unpacking
+        giptm = GenericIPAddressTestModel(generic_ip="::ffff:10.10.10.10")
+        giptm.save()
+        giptm = GenericIPAddressTestModel(generic_ip="10.10.10.10")
+        self.assertEqual(None, giptm.full_clean())
+
+        # These two are the same, because we are doing IPv4 unpacking
+        giptm = GenericIPAddressWithUnpackUniqueTestModel(generic_v4unpack_ip="::ffff:18.52.18.52")
+        giptm.save()
+        giptm = GenericIPAddressWithUnpackUniqueTestModel(generic_v4unpack_ip="18.52.18.52")
+        self.assertFailsValidation(giptm.full_clean, ['generic_v4unpack_ip',])

+ 25 - 0
tests/modeltests/validators/tests.py

@@ -52,6 +52,31 @@ TEST_DATA = (
     (validate_ipv4_address, '25,1,1,1', ValidationError),
     (validate_ipv4_address, '25.1 .1.1', ValidationError),
 
+    # validate_ipv6_address uses django.utils.ipv6, which
+    # is tested in much greater detail in it's own testcase
+    (validate_ipv6_address, 'fe80::1', None),
+    (validate_ipv6_address, '::1', None),
+    (validate_ipv6_address, '1:2:3:4:5:6:7:8', None),
+
+    (validate_ipv6_address, '1:2', ValidationError),
+    (validate_ipv6_address, '::zzz', ValidationError),
+    (validate_ipv6_address, '12345::', ValidationError),
+
+    (validate_ipv46_address, '1.1.1.1', None),
+    (validate_ipv46_address, '255.0.0.0', None),
+    (validate_ipv46_address, '0.0.0.0', None),
+    (validate_ipv46_address, 'fe80::1', None),
+    (validate_ipv46_address, '::1', None),
+    (validate_ipv46_address, '1:2:3:4:5:6:7:8', None),
+
+    (validate_ipv46_address, '256.1.1.1', ValidationError),
+    (validate_ipv46_address, '25.1.1.', ValidationError),
+    (validate_ipv46_address, '25,1,1,1', ValidationError),
+    (validate_ipv46_address, '25.1 .1.1', ValidationError),
+    (validate_ipv46_address, '1:2', ValidationError),
+    (validate_ipv46_address, '::zzz', ValidationError),
+    (validate_ipv46_address, '12345::', ValidationError),
+
     (validate_comma_separated_integer_list, '1', None),
     (validate_comma_separated_integer_list, '1,2,3', None),
     (validate_comma_separated_integer_list, '1,2,3,', None),

+ 9 - 0
tests/regressiontests/forms/tests/error_messages.py

@@ -196,6 +196,15 @@ class FormsErrorMessagesTestCase(unittest.TestCase, AssertFormErrorsMixin):
         self.assertFormErrors([u'REQUIRED'], f.clean, '')
         self.assertFormErrors([u'INVALID IP ADDRESS'], f.clean, '127.0.0')
 
+    def test_generic_ipaddressfield(self):
+        e = {
+            'required': 'REQUIRED',
+            'invalid': 'INVALID IP ADDRESS',
+        }
+        f = GenericIPAddressField(error_messages=e)
+        self.assertFormErrors([u'REQUIRED'], f.clean, '')
+        self.assertFormErrors([u'INVALID IP ADDRESS'], f.clean, '127.0.0')
+
     def test_subclassing_errorlist(self):
         class TestForm(Form):
             first_name = CharField()

+ 80 - 0
tests/regressiontests/forms/tests/extra.py

@@ -460,6 +460,86 @@ class FormsExtraTestCase(unittest.TestCase, AssertFormErrorsMixin):
         self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, '1.2.3.4.5')
         self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, '256.125.1.5')
 
+    def test_generic_ipaddress_invalid_arguments(self):
+        self.assertRaises(ValueError, GenericIPAddressField, protocol="hamster")
+        self.assertRaises(ValueError, GenericIPAddressField, protocol="ipv4", unpack_ipv4=True)
+
+    def test_generic_ipaddress_as_generic(self):
+        # The edge cases of the IPv6 validation code are not deeply tested
+        # here, they are covered in the tests for django.utils.ipv6
+        f = GenericIPAddressField()
+        self.assertFormErrors([u'This field is required.'], f.clean, '')
+        self.assertFormErrors([u'This field is required.'], f.clean, None)
+        self.assertEqual(f.clean('127.0.0.1'), u'127.0.0.1')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, 'foo')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '127.0.0.')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1.2.3.4.5')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '256.125.1.5')
+        self.assertEqual(f.clean('fe80::223:6cff:fe8a:2e8a'), u'fe80::223:6cff:fe8a:2e8a')
+        self.assertEqual(f.clean('2a02::223:6cff:fe8a:2e8a'), u'2a02::223:6cff:fe8a:2e8a')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '12345:2:3:4')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1::2:3::4')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, 'foo::223:6cff:fe8a:2e8a')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1::2:3:4:5:6:7:8')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1:2')
+
+    def test_generic_ipaddress_as_ipv4_only(self):
+        f = GenericIPAddressField(protocol="IPv4")
+        self.assertFormErrors([u'This field is required.'], f.clean, '')
+        self.assertFormErrors([u'This field is required.'], f.clean, None)
+        self.assertEqual(f.clean('127.0.0.1'), u'127.0.0.1')
+        self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, 'foo')
+        self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, '127.0.0.')
+        self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, '1.2.3.4.5')
+        self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, '256.125.1.5')
+        self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, 'fe80::223:6cff:fe8a:2e8a')
+        self.assertFormErrors([u'Enter a valid IPv4 address.'], f.clean, '2a02::223:6cff:fe8a:2e8a')
+
+    def test_generic_ipaddress_as_ipv4_only(self):
+        f = GenericIPAddressField(protocol="IPv6")
+        self.assertFormErrors([u'This field is required.'], f.clean, '')
+        self.assertFormErrors([u'This field is required.'], f.clean, None)
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '127.0.0.1')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, 'foo')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '127.0.0.')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '1.2.3.4.5')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '256.125.1.5')
+        self.assertEqual(f.clean('fe80::223:6cff:fe8a:2e8a'), u'fe80::223:6cff:fe8a:2e8a')
+        self.assertEqual(f.clean('2a02::223:6cff:fe8a:2e8a'), u'2a02::223:6cff:fe8a:2e8a')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '12345:2:3:4')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '1::2:3::4')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, 'foo::223:6cff:fe8a:2e8a')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '1::2:3:4:5:6:7:8')
+        self.assertFormErrors([u'Enter a valid IPv6 address.'], f.clean, '1:2')
+
+    def test_generic_ipaddress_as_generic_not_required(self):
+        f = GenericIPAddressField(required=False)
+        self.assertEqual(f.clean(''), u'')
+        self.assertEqual(f.clean(None), u'')
+        self.assertEqual(f.clean('127.0.0.1'), u'127.0.0.1')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, 'foo')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '127.0.0.')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1.2.3.4.5')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '256.125.1.5')
+        self.assertEqual(f.clean('fe80::223:6cff:fe8a:2e8a'), u'fe80::223:6cff:fe8a:2e8a')
+        self.assertEqual(f.clean('2a02::223:6cff:fe8a:2e8a'), u'2a02::223:6cff:fe8a:2e8a')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '12345:2:3:4')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1::2:3::4')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, 'foo::223:6cff:fe8a:2e8a')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1::2:3:4:5:6:7:8')
+        self.assertFormErrors([u'Enter a valid IPv4 or IPv6 address.'], f.clean, '1:2')
+
+    def test_generic_ipaddress_normalization(self):
+        # Test the normalising code
+        f = GenericIPAddressField()
+        self.assertEqual(f.clean('::ffff:0a0a:0a0a'), u'::ffff:10.10.10.10')
+        self.assertEqual(f.clean('::ffff:10.10.10.10'), u'::ffff:10.10.10.10')
+        self.assertEqual(f.clean('2001:000:a:0000:0:fe:fe:beef'), u'2001:0:a::fe:fe:beef')
+        self.assertEqual(f.clean('2001::a:0000:0:fe:fe:beef'), u'2001:0:a::fe:fe:beef')
+
+        f = GenericIPAddressField(unpack_ipv4=True)
+        self.assertEqual(f.clean('::ffff:0a0a:0a0a'), u'10.10.10.10')
+
     def test_smart_unicode(self):
         class Test:
             def __str__(self):

+ 6 - 0
tests/regressiontests/serializers_regress/models.py

@@ -52,6 +52,9 @@ class BigIntegerData(models.Model):
 class IPAddressData(models.Model):
     data = models.IPAddressField(null=True)
 
+class GenericIPAddressData(models.Model):
+    data = models.GenericIPAddressField(null=True)
+
 class NullBooleanData(models.Model):
     data = models.NullBooleanField(null=True)
 
@@ -187,6 +190,9 @@ class IntegerPKData(models.Model):
 class IPAddressPKData(models.Model):
     data = models.IPAddressField(primary_key=True)
 
+class GenericIPAddressPKData(models.Model):
+    data = models.GenericIPAddressField(primary_key=True)
+
 # This is just a Boolean field with null=True, and we can't test a PK value of NULL.
 # class NullBooleanPKData(models.Model):
 #     data = models.NullBooleanField(primary_key=True)

+ 3 - 0
tests/regressiontests/serializers_regress/tests.py

@@ -196,6 +196,8 @@ test_data = [
     #(XX, ImageData
     (data_obj, 90, IPAddressData, "127.0.0.1"),
     (data_obj, 91, IPAddressData, None),
+    (data_obj, 95, GenericIPAddressData, "fe80:1424:2223:6cff:fe8a:2e8a:2151:abcd"),
+    (data_obj, 96, GenericIPAddressData, None),
     (data_obj, 100, NullBooleanData, True),
     (data_obj, 101, NullBooleanData, False),
     (data_obj, 102, NullBooleanData, None),
@@ -298,6 +300,7 @@ The end."""),
     (pk_obj, 682, IntegerPKData, 0),
 #     (XX, ImagePKData
     (pk_obj, 690, IPAddressPKData, "127.0.0.1"),
+    (pk_obj, 695, GenericIPAddressPKData, "fe80:1424:2223:6cff:fe8a:2e8a:2151:abcd"),
     # (pk_obj, 700, NullBooleanPKData, True),
     # (pk_obj, 701, NullBooleanPKData, False),
     (pk_obj, 710, PhonePKData, "212-634-5789"),

+ 51 - 0
tests/regressiontests/utils/ipv6.py

@@ -0,0 +1,51 @@
+from django.utils import unittest
+from django.utils.ipv6 import is_valid_ipv6_address, clean_ipv6_address
+
+class TestUtilsIPv6(unittest.TestCase):
+
+    def test_validates_correct_plain_address(self):
+        self.assertTrue(is_valid_ipv6_address('fe80::223:6cff:fe8a:2e8a'))
+        self.assertTrue(is_valid_ipv6_address('2a02::223:6cff:fe8a:2e8a'))
+        self.assertTrue(is_valid_ipv6_address('1::2:3:4:5:6:7'))
+        self.assertTrue(is_valid_ipv6_address('::'))
+        self.assertTrue(is_valid_ipv6_address('::a'))
+        self.assertTrue(is_valid_ipv6_address('2::'))
+
+    def test_validates_correct_with_v4mapping(self):
+        self.assertTrue(is_valid_ipv6_address('::ffff:254.42.16.14'))
+        self.assertTrue(is_valid_ipv6_address('::ffff:0a0a:0a0a'))
+
+    def test_validates_incorrect_plain_address(self):
+        self.assertFalse(is_valid_ipv6_address('foo'))
+        self.assertFalse(is_valid_ipv6_address('127.0.0.1'))
+        self.assertFalse(is_valid_ipv6_address('12345::'))
+        self.assertFalse(is_valid_ipv6_address('1::2:3::4'))
+        self.assertFalse(is_valid_ipv6_address('1::zzz'))
+        self.assertFalse(is_valid_ipv6_address('1::2:3:4:5:6:7:8'))
+        self.assertFalse(is_valid_ipv6_address('1:2'))
+        self.assertFalse(is_valid_ipv6_address('1:::2'))
+
+    def test_validates_incorrect_with_v4mapping(self):
+        self.assertFalse(is_valid_ipv6_address('::ffff:999.42.16.14'))
+        self.assertFalse(is_valid_ipv6_address('::ffff:zzzz:0a0a'))
+        # The ::1.2.3.4 format used to be valid but was deprecated
+        # in rfc4291 section 2.5.5.1
+        self.assertTrue(is_valid_ipv6_address('::254.42.16.14'))
+        self.assertTrue(is_valid_ipv6_address('::0a0a:0a0a'))
+        self.assertFalse(is_valid_ipv6_address('::999.42.16.14'))
+        self.assertFalse(is_valid_ipv6_address('::zzzz:0a0a'))
+
+    def test_cleanes_plain_address(self):
+        self.assertEqual(clean_ipv6_address('DEAD::0:BEEF'), u'dead::beef')
+        self.assertEqual(clean_ipv6_address('2001:000:a:0000:0:fe:fe:beef'), u'2001:0:a::fe:fe:beef')
+        self.assertEqual(clean_ipv6_address('2001::a:0000:0:fe:fe:beef'), u'2001:0:a::fe:fe:beef')
+
+    def test_cleanes_with_v4_mapping(self):
+        self.assertEqual(clean_ipv6_address('::ffff:0a0a:0a0a'), u'::ffff:10.10.10.10')
+        self.assertEqual(clean_ipv6_address('::ffff:1234:1234'), u'::ffff:18.52.18.52')
+        self.assertEqual(clean_ipv6_address('::ffff:18.52.18.52'), u'::ffff:18.52.18.52')
+
+    def test_unpacks_ipv4(self):
+        self.assertEqual(clean_ipv6_address('::ffff:0a0a:0a0a', unpack_ipv4=True), u'10.10.10.10')
+        self.assertEqual(clean_ipv6_address('::ffff:1234:1234', unpack_ipv4=True), u'18.52.18.52')
+        self.assertEqual(clean_ipv6_address('::ffff:18.52.18.52', unpack_ipv4=True), u'18.52.18.52')

+ 1 - 0
tests/regressiontests/utils/tests.py

@@ -19,3 +19,4 @@ from tzinfo import *
 from datetime_safe import *
 from baseconv import *
 from jslex import *
+from ipv6 import *