From ce3c281090320172d22e8a6057250d103c93438e Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Sat, 11 Jun 2011 13:48:24 +0000 Subject: [PATCH] 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 --- django/core/validators.py | 36 +++ django/db/backends/mysql/creation.py | 1 + django/db/backends/oracle/creation.py | 1 + .../backends/postgresql_psycopg2/creation.py | 1 + .../postgresql_psycopg2/introspection.py | 1 + django/db/backends/sqlite3/creation.py | 1 + django/db/models/fields/__init__.py | 38 ++- django/forms/fields.py | 24 +- django/utils/ipv6.py | 267 ++++++++++++++++++ docs/ref/forms/fields.txt | 39 +++ docs/ref/models/fields.txt | 32 +++ docs/ref/validators.txt | 17 ++ docs/releases/1.4.txt | 9 + docs/topics/forms/modelforms.txt | 2 + tests/modeltests/validation/models.py | 10 +- tests/modeltests/validation/tests.py | 59 +++- tests/modeltests/validators/tests.py | 25 ++ .../forms/tests/error_messages.py | 9 + tests/regressiontests/forms/tests/extra.py | 80 ++++++ .../serializers_regress/models.py | 6 + .../serializers_regress/tests.py | 3 + tests/regressiontests/utils/ipv6.py | 51 ++++ tests/regressiontests/utils/tests.py | 1 + 23 files changed, 708 insertions(+), 5 deletions(-) create mode 100644 django/utils/ipv6.py create mode 100644 tests/regressiontests/utils/ipv6.py diff --git a/django/core/validators.py b/django/core/validators.py index 0f6adf8fdad..458f4195e0a 100644 --- a/django/core/validators.py +++ b/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') diff --git a/django/db/backends/mysql/creation.py b/django/db/backends/mysql/creation.py index 8b026a908d1..53bd57effc1 100644 --- a/django/db/backends/mysql/creation.py +++ b/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', diff --git a/django/db/backends/oracle/creation.py b/django/db/backends/oracle/creation.py index 29293db20ad..0403e0a145a 100644 --- a/django/db/backends/oracle/creation.py +++ b/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)', diff --git a/django/db/backends/postgresql_psycopg2/creation.py b/django/db/backends/postgresql_psycopg2/creation.py index 5d4d50b5cf6..bdd817db4c9 100644 --- a/django/db/backends/postgresql_psycopg2/creation.py +++ b/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)', diff --git a/django/db/backends/postgresql_psycopg2/introspection.py b/django/db/backends/postgresql_psycopg2/introspection.py index 9f2c81dee5a..b1dac3f8ca4 100644 --- a/django/db/backends/postgresql_psycopg2/introspection.py +++ b/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', diff --git a/django/db/backends/sqlite3/creation.py b/django/db/backends/sqlite3/creation.py index f32bd0a75d2..f6fe6a45341 100644 --- a/django/db/backends/sqlite3/creation.py +++ b/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', diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 63d0c21bc13..4707cb4a4cf 100644 --- a/django/db/models/fields/__init__.py +++ b/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 = { diff --git a/django/forms/fields.py b/django/forms/fields.py index 67564218ab7..d7b1bbe5db7 100644 --- a/django/forms/fields.py +++ b/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," diff --git a/django/utils/ipv6.py b/django/utils/ipv6.py new file mode 100644 index 00000000000..e80a0e205f9 --- /dev/null +++ b/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 diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 96f1be949b8..3fc5b8aabf9 100644 --- a/docs/ref/forms/fields.txt +++ b/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`` ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 5c4bbd06bc7..7765f2f8ebb 100644 --- a/docs/ref/models/fields.txt +++ b/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 ```` (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 ```` +(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`` -------------------- diff --git a/docs/ref/validators.txt b/docs/ref/validators.txt index 0bb5abd8199..0f299895c57 100644 --- a/docs/ref/validators.txt +++ b/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 diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 90f925c02e1..0fbcd34d77a 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -155,6 +155,15 @@ You may override or customize the default filtering by writing a :ref:`custom filter`. Learn more on :ref:`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 ~~~~~~~~~~~~~~ diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index 6d418a22b9e..031408a1512 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -83,6 +83,8 @@ the full list of conversions: ``IPAddressField`` ``IPAddressField`` + ``GenericIPAddressField`` ``GenericIPAddressField`` + ``ManyToManyField`` ``ModelMultipleChoiceField`` (see below) diff --git a/tests/modeltests/validation/models.py b/tests/modeltests/validation/models.py index 923e5a88c5f..f92fc1f4ec8 100644 --- a/tests/modeltests/validation/models.py +++ b/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.'}) \ No newline at end of file + 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) diff --git a/tests/modeltests/validation/tests.py b/tests/modeltests/validation/tests.py index 4236d8e7c40..bb60267ff09 100644 --- a/tests/modeltests/validation/tests.py +++ b/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',]) diff --git a/tests/modeltests/validators/tests.py b/tests/modeltests/validators/tests.py index cfb7b4b7934..9e254b9b900 100644 --- a/tests/modeltests/validators/tests.py +++ b/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), diff --git a/tests/regressiontests/forms/tests/error_messages.py b/tests/regressiontests/forms/tests/error_messages.py index a9f4f37a8d6..981016f184e 100644 --- a/tests/regressiontests/forms/tests/error_messages.py +++ b/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() diff --git a/tests/regressiontests/forms/tests/extra.py b/tests/regressiontests/forms/tests/extra.py index 7d209616e89..b8b32cb1515 100644 --- a/tests/regressiontests/forms/tests/extra.py +++ b/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): diff --git a/tests/regressiontests/serializers_regress/models.py b/tests/regressiontests/serializers_regress/models.py index 3a2c81acefd..b3ae1feaa56 100644 --- a/tests/regressiontests/serializers_regress/models.py +++ b/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) diff --git a/tests/regressiontests/serializers_regress/tests.py b/tests/regressiontests/serializers_regress/tests.py index 97b2a792eb4..90a438c43a7 100644 --- a/tests/regressiontests/serializers_regress/tests.py +++ b/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"), diff --git a/tests/regressiontests/utils/ipv6.py b/tests/regressiontests/utils/ipv6.py new file mode 100644 index 00000000000..86d1ad1573f --- /dev/null +++ b/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') diff --git a/tests/regressiontests/utils/tests.py b/tests/regressiontests/utils/tests.py index 384d3948796..e91adc94e5d 100644 --- a/tests/regressiontests/utils/tests.py +++ b/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 *