Repository: django-extensions/django-extensions (python)

Issue:
HexValidator bug in max_length ?
Hello,
I'm seeing unexpected validation errors when using max_length with HexValidator.
Django-extensions version is 3.1.1. Django is 3.1.7.

`myfield = models.CharField(max_length=20, unique=True, validators=[HexValidator(max_length=12)])`

django.core.exceptions.ValidationError: {'myfield': ['Ensure that there are no more than 12 characters.']}
The above exception was triggered when validating a 10-digits string ('0123456789') as if min_length and max_length were confused somewhere.

Best regards

Pull request description:
fix: Max length validation on HexValidator
- also add tests for this validator.
- fix a bug on the `__eq__` function that would be caused due to the absence of a default error message.

fix builds for `mypy`.

- since `mypy` > 0.900 no longer ships with third-party types, add the requirements to the `mypy` `tox` environment.

fixes #1668 

Patch:
diff --git a/django_extensions/management/commands/graph_models.py b/django_extensions/management/commands/graph_models.py
index 1f2f651c..3e0ef77d 100644
--- a/django_extensions/management/commands/graph_models.py
+++ b/django_extensions/management/commands/graph_models.py
@@ -153,6 +153,12 @@ class Command(BaseCommand):
                 'dest': 'relations_as_fields',
                 'help': 'Do not show relations as fields in the graph.',
             },
+            '--relation-fields-only': {
+                'action': 'store',
+                'default': False,
+                'dest': 'relation_fields_only',
+                'help': 'Only display fields that are relevant for relations',
+            },
             '--disable-sort-fields -S': {
                 'action': 'store_false',
                 'default': True,
diff --git a/django_extensions/management/modelviz.py b/django_extensions/management/modelviz.py
index fc49da6c..81804a3a 100644
--- a/django_extensions/management/modelviz.py
+++ b/django_extensions/management/modelviz.py
@@ -17,6 +17,9 @@ from django.db.models import deletion
 from django.db.models.fields.related import (
     ForeignKey, ManyToManyField, OneToOneField, RelatedField,
 )
+from django.db.models.fields.reverse_related import (
+    OneToOneRel, ManyToOneRel,
+)
 from django.contrib.contenttypes.fields import GenericRelation
 from django.template import Context, Template, loader
 from django.utils.encoding import force_str
@@ -42,6 +45,7 @@ __contributors__ = [
     "Mikkel Munch Mortensen <https://www.detfalskested.dk/>",
     "Andrzej Bistram <andrzej.bistram@gmail.com>",
     "Daniel Lipsitt <danlipsitt@gmail.com>",
+    "Tobias Mitterdorfer <tobias.mitterdorfer97@gmail.com>"
 ]
 
 
@@ -80,6 +84,7 @@ class ModelGraph:
         self.verbose_names = kwargs.get('verbose_names', False)
         self.inheritance = kwargs.get('inheritance', True)
         self.relations_as_fields = kwargs.get("relations_as_fields", True)
+        self.relation_fields_only = kwargs.get("relation_fields_only", False)
         self.sort_fields = kwargs.get("sort_fields", True)
         self.language = kwargs.get('language', None)
         if self.language is not None:
@@ -439,6 +444,12 @@ class ModelGraph:
                     return True
             if field.name in self.exclude_columns:
                 return True
+        if self.relation_fields_only:
+            if not isinstance(
+                field,
+                (ForeignKey, ManyToManyField, OneToOneField, RelatedField, OneToOneRel, ManyToOneRel)
+            ):
+                return True
         return False
 
 
diff --git a/django_extensions/validators.py b/django_extensions/validators.py
index 5bddff67..41c1dcc1 100644
--- a/django_extensions/validators.py
+++ b/django_extensions/validators.py
@@ -83,6 +83,8 @@ class HexValidator:
         self.max_length = max_length
         if message:
             self.message = message
+        else:
+            self.message = self.messages['invalid']
         if code:
             self.code = code
 
@@ -92,7 +94,7 @@ class HexValidator:
             raise ValidationError(self.messages['length'], code='hex_only_length', params={'length': self.length})
         if self.min_length and len(value) < self.min_length:
             raise ValidationError(self.messages['min_length'], code='hex_only_min_length', params={'min': self.min_length})
-        if self.max_length and len(value) < self.max_length:
+        if self.max_length and len(value) > self.max_length:
             raise ValidationError(self.messages['max_length'], code='hex_only_max_length', params={'max': self.max_length})
 
         try:
diff --git a/tox.ini b/tox.ini
index 66b83f72..57ede169 100644
--- a/tox.ini
+++ b/tox.ini
@@ -20,7 +20,7 @@ envlist =
 
 [testenv]
 commands = make test
-whitelist_externals = make
+allowlist_externals = make
 passenv =
     DJANGO_EXTENSIONS_DATABASE_ENGINE
     DJANGO_EXTENSIONS_DATABASE_NAME
@@ -57,14 +57,20 @@ deps =
 commands = safety check --full-report
 
 [testenv:mypy]
+allowlist_externals=
+    echo
 deps =
     pip >= 21.1
     mypy
+    types-requests
+    types-boto
+    types-PyYAML
+    types-Werkzeug
     -rrequirements-dev.txt
 commands = mypy --ignore-missing-imports django_extensions
 
 [testenv:compile-catalog]
-whitelist_externals =
+allowlist_externals =
     make
 deps =
     pip >= 21.1


Test patch:
diff --git a/tests/management/commands/test_graph_models.py b/tests/management/commands/test_graph_models.py
index f502c834..206a70b2 100644
--- a/tests/management/commands/test_graph_models.py
+++ b/tests/management/commands/test_graph_models.py
@@ -106,6 +106,26 @@ class GraphModelsOutputTests(TestCase):
         with self.assertRaises(CommandError):
             call_command('graph_models', all_applications=True, pygraphviz=True)
 
+    def test_graph_models_relation_fields_only(self):
+        # use of --relation-fields-only ignores all non-relation-fields
+        stdout = StringIO()
+        call_command('graph_models', all_applications=True, stdout=stdout, json=True)
+        with_no_flag = json.loads(stdout.getvalue())
+
+        stdout = StringIO()
+        call_command('graph_models', all_applications=True, relation_fields_only=True, stdout=stdout, json=True)
+        with_flag = json.loads(stdout.getvalue())
+
+        # delete all entries for fields that do not display relations
+        for graph_idx, graph in reversed(list(enumerate(with_no_flag["graphs"]))):
+            for model_idx, model in reversed(list(enumerate(graph["models"]))):
+                for field_idx, field in reversed(list(enumerate(model["fields"]))):
+                    if field["type"] not in ["ForeignKey (id)", "AutoField", "OneToOneField (id)"] and not field["primary_key"]:
+                        del with_no_flag["graphs"][graph_idx]["models"][model_idx]["fields"][field_idx]
+
+        # assert that manually deleting all non-relation-fields is same as using the flag
+        self.assertEqual(with_no_flag, with_flag)
+
 
 def test_disable_abstract_fields_not_active():
     out = StringIO()
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 9d97e9f2..76608547 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -1,8 +1,10 @@
 # -*- coding: utf-8 -*-
+from unittest.mock import patch
+
 from django.core.exceptions import ValidationError
-from django.test import TestCase
+from django.test import TestCase, SimpleTestCase
 
-from django_extensions.validators import NoControlCharactersValidator, NoWhitespaceValidator
+from django_extensions.validators import NoControlCharactersValidator, NoWhitespaceValidator, HexValidator
 
 
 class NoControlCharactersValidatorTests(TestCase):
@@ -72,3 +74,82 @@ class NoWhiteSpaceValidatorTests(TestCase):
         result = self.validator(value_without_leading_or_trailing_whitespaces)
 
         self.assertIsNone(result)
+
+
+class TestHexValidator(SimpleTestCase):
+    def test_custom_message_and_code(self):
+        self.validator = HexValidator(message='message', code='code')
+
+        self.assertEqual(self.validator.message, 'message')
+        self.assertEqual(self.validator.code, 'code')
+
+    def test_equality_of_objs_with_obj_of_different_type(self):
+        self.assertNotEqual(TypeError(), HexValidator())
+
+    def test_equality_of_objs_with_different_code(self):
+        self.assertNotEqual(HexValidator(code='1'), HexValidator(code='a'))
+
+    def test_equality_of_objs_with_different_message(self):
+        self.assertNotEqual(HexValidator(code='code', message='a'), HexValidator(code='code', message='acb'))
+
+    def test_equality_of_objs_with_same_code_and_message(self):
+        self.assertEqual(HexValidator(code='c', message='m'), HexValidator(code='c', message='m'))
+
+    def test_fixed_length(self):
+        value = 'abcd'
+        self.validator = HexValidator(length=5)
+
+        with self.assertRaises(ValidationError) as err:
+            self.validator(value)
+
+        self.assertEqual(str(err.exception), "['Invalid length. Must be 5 characters.']")
+        self.assertEqual(err.exception.code, 'hex_only_length')
+
+    def test_min_length(self):
+        value = 'a'
+        self.validator = HexValidator(min_length=5)
+
+        with self.assertRaises(ValidationError) as err:
+            self.validator(value)
+
+        self.assertEqual(str(err.exception), "['Ensure that there are more than 5 characters.']")
+        self.assertEqual(err.exception.code, 'hex_only_min_length')
+
+    def test_with_max_length(self):
+        value = 'abcd'
+        self.validator = HexValidator(max_length=2)
+
+        with self.assertRaises(ValidationError) as err:
+            self.validator(value)
+
+        self.assertEqual(str(err.exception), "['Ensure that there are no more than 2 characters.']")
+        self.assertEqual(err.exception.code, 'hex_only_max_length')
+
+    def test_invalid_type(self):
+        value = 1
+        with patch('django_extensions.validators.force_str', return_value=1):
+            self.validator = HexValidator()
+
+            with self.assertRaises(ValidationError) as err:
+                self.validator(value)
+
+        self.assertEqual(str(err.exception), "['Only a hex string is allowed.']")
+        self.assertEqual(err.exception.code, 'hex_only')
+
+    def test_invalid_hex(self):
+        value = '1'
+        self.validator = HexValidator()
+
+        with self.assertRaises(ValidationError) as err:
+            self.validator(value)
+
+        self.assertEqual(str(err.exception), "['Only a hex string is allowed.']")
+        self.assertEqual(err.exception.code, 'hex_only')
+
+    def test_valid_hex(self):
+        value = 'b901ef'
+        self.validator = HexValidator()
+
+        result = self.validator(value)
+
+        self.assertIsNone(result)