Repository: django-extensions/django-extensions
Pull request #1723 (2022-05-06 05:29:19)

Drop use of mock and six
Now that Python >= 3.5 is the minimal version supported, remove all
remaining traces of the external mock module, as well as one use of six
that is no longer required.


Patch:
diff --git a/.gitignore b/.gitignore
index 12224611..2275dd50 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@ htmlcov/
 django-sample-app*/
 *.swp
 *.swo
+*.sqlite3
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f488dbe8..3c04c570 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,10 +18,13 @@ $ source venv/bin/activate
 # install django-extensions in development mode
 (venv) $ pip install -e .
 # install dependencies
+# might need to replace DJango with 'Django<4' until issue #1695 is resolved.
 (venv) $ pip install Django -r requirements-dev.txt
 
 # for accessing the GUI portion of the test application
 (venv) $ export DJANGO_EXTENSIONS_DATABASE_NAME="db.sqlite3"    # you may change if you want to use any other database
+# run migrations
+(venv) $ python manage.py migrate
 # start the development server
 (venv) $ python manage.py runserver
 ```
diff --git a/README.rst b/README.rst
index 37f8bc4d..d54fc37d 100644
--- a/README.rst
+++ b/README.rst
@@ -68,7 +68,9 @@ Installing It
 =============
 
 To enable `django_extensions` in your project you need to add it to `INSTALLED_APPS` in your projects
-`settings.py` file::
+`settings.py` file:
+
+.. code-block:: python
 
     INSTALLED_APPS = (
         ...
diff --git a/django_extensions/db/fields/__init__.py b/django_extensions/db/fields/__init__.py
index e320dfd4..f4b7f570 100644
--- a/django_extensions/db/fields/__init__.py
+++ b/django_extensions/db/fields/__init__.py
@@ -319,6 +319,9 @@ class RandomCharField(UniqueFieldMixin, CharField):
 
     include_punctuation
         If set to True, include punctuation characters (default: False)
+    
+    keep_default
+        If set to True, keeps the default initialization value (default: False)
     """
 
     def __init__(self, *args, **kwargs):
@@ -341,6 +344,7 @@ class RandomCharField(UniqueFieldMixin, CharField):
         self.include_alpha = kwargs.pop('include_alpha', True)
         self.check_is_bool('include_alpha')
         self.include_punctuation = kwargs.pop('include_punctuation', False)
+        self.keep_default = kwargs.pop('keep_default', False)
         self.check_is_bool('include_punctuation')
         self.max_unique_query_attempts = kwargs.pop('max_unique_query_attempts', MAX_UNIQUE_QUERY_ATTEMPTS)
 
@@ -362,7 +366,7 @@ class RandomCharField(UniqueFieldMixin, CharField):
         return False
 
     def pre_save(self, model_instance, add):
-        if not add and getattr(model_instance, self.attname) != '':
+        if (not add or self.keep_default) and getattr(model_instance, self.attname) != '':
             return getattr(model_instance, self.attname)
 
         population = ''
diff --git a/django_extensions/locale/ar/LC_MESSAGES/django.po b/django_extensions/locale/ar/LC_MESSAGES/django.po
new file mode 100644
index 00000000..0963b4f8
--- /dev/null
+++ b/django_extensions/locale/ar/LC_MESSAGES/django.po
@@ -0,0 +1,109 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2020-06-06 11:44+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: admin/__init__.py:139
+msgid "and"
+msgstr "و"
+
+#: admin/__init__.py:141
+#, python-format
+msgid "Use the left field to do %(model_name)s lookups in the fields %(field_list)s."
+msgstr "إستعمل الحقل الأيسر من (model_name)% لبحث ضمن الأحقال التالية (field_list)% "
+
+#: admin/filter.py:24 admin/filter.py:53
+msgid "Yes"
+msgstr "نعم"
+
+#: admin/filter.py:25 admin/filter.py:54
+msgid "No"
+msgstr "لا"
+
+#: admin/filter.py:32
+msgid "All"
+msgstr "كل"
+
+#: db/models.py:18
+msgid "created"
+msgstr "تم تكونه"
+
+#: db/models.py:19
+msgid "modified"
+msgstr "تم تعديله"
+
+#: db/models.py:37
+msgid "title"
+msgstr "عنوان"
+
+#: db/models.py:38
+msgid "description"
+msgstr "وصف"
+
+#: db/models.py:59
+msgid "slug"
+msgstr "رابط "
+
+#: db/models.py:120 mongodb/models.py:76
+msgid "Inactive"
+msgstr "غير نشط"
+
+#: db/models.py:121 mongodb/models.py:77
+msgid "Active"
+msgstr "نشط"
+
+#: db/models.py:123
+msgid "status"
+msgstr "الحالة"
+
+#: db/models.py:124 mongodb/models.py:80
+msgid "keep empty for an immediate activation"
+msgstr "أترك الحقل فارغ ليتم التنشيط مباشرة"
+
+#: db/models.py:125 mongodb/models.py:81
+msgid "keep empty for indefinite activation"
+msgstr "أترك الحقل فارغ لتنشيط لمدة غير محددة"
+
+#: mongodb/fields/__init__.py:22
+#, python-format
+msgid "String (up to %(max_length)s)"
+msgstr "سلسلة الإحرف (طولها يصل إلى %(max_length))"
+
+#: validators.py:14
+msgid "Control Characters like new lines or tabs are not allowed."
+msgstr "لا يسمح  إستعمال أحرف تحكم مثل حرف العودة إلى السطر أو علامات التبويب"
+
+#: validators.py:48
+msgid "Leading and Trailing whitespaces are not allowed."
+msgstr "المسافات البيضاء الزائدة عند البداية أو نهاية غير مسموح بها"
+
+#: validators.py:74
+msgid "Only a hex string is allowed."
+msgstr "مسموح إستعمال سلسلة أحرف hex فقط"
+
+#: validators.py:75
+#, python-format
+msgid "Invalid length. Must be %(length)d characters."
+msgstr "الطول غير مقبول, يجب أن لا يكون أطول من %(length)d"
+
+#: validators.py:76
+#, python-format
+msgid "Ensure that there are more than %(min)s characters."
+msgstr "تأكد أن طول سلسلة الإحرف أطول من (min)% "
+
+#: validators.py:77
+#, python-format
+msgid "Ensure that there are no more than %(max)s characters."
+msgstr "تأكد أن طول سلسلة الأحرف لا تتجوز (max)% "
diff --git a/django_extensions/management/commands/graph_models.py b/django_extensions/management/commands/graph_models.py
index c70e1696..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,
@@ -163,7 +169,7 @@ class Command(BaseCommand):
                 'action': 'store_true',
                 'default': False,
                 'dest': 'hide_edge_labels',
-                'help': 'Do not showrelations labels in the graph.',
+                'help': 'Do not show relations labels in the graph.',
             },
             '--arrow-shape': {
                 'action': 'store',
@@ -172,6 +178,12 @@ class Command(BaseCommand):
                 'choices': ['box', 'crow', 'curve', 'icurve', 'diamond', 'dot', 'inv', 'none', 'normal', 'tee', 'vee'],
                 'help': 'Arrow shape to use for relations. Default is dot. Available shapes: box, crow, curve, icurve, diamond, dot, inv, none, normal, tee, vee.',
             },
+            '--color-code-deletions': {
+                'action': 'store_true',
+                'default': False,
+                'dest': 'color_code_deletions',
+                'help': 'Color the relations according to their on_delete setting, where it it applicable. The colors are: red (CASCADE), orange (SET_NULL), green (SET_DEFAULT), yellow (SET), blue (PROTECT), grey (DO_NOTHING) and purple (RESTRICT).',
+            },
             '--rankdir': {
                 'action': 'store',
                 'default': 'TB',
diff --git a/django_extensions/management/commands/managestate.py b/django_extensions/management/commands/managestate.py
new file mode 100644
index 00000000..82f2fc15
--- /dev/null
+++ b/django_extensions/management/commands/managestate.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+import json
+from operator import itemgetter
+from pathlib import Path
+
+from django.core.management import call_command
+from django.core.management.base import BaseCommand, CommandError
+from django.db import DEFAULT_DB_ALIAS, connections
+from django.db.migrations.loader import MigrationLoader
+from django.db.migrations.recorder import MigrationRecorder
+from django.utils import timezone
+
+from django_extensions.management.utils import signalcommand
+
+DEFAULT_FILENAME = 'managestate.json'
+DEFAULT_STATE = 'default'
+
+
+class Command(BaseCommand):
+    help = 'Manage database state in the convenient way.'
+    conn = database = None
+    _applied_migrations = None
+    migrate_args: dict
+    migrate_options: dict
+    filename: str
+    verbosity: int
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            'action', choices=('dump', 'load'),
+            help='An action to do. '
+                 'Dump action saves applied migrations to a file. '
+                 'Load action applies migrations specified in a file.',
+        )
+        parser.add_argument(
+            'state', nargs='?', default=DEFAULT_STATE,
+            help=f'A name of a state. Usually a name of a git branch. Defaults to "{DEFAULT_STATE}"',
+        )
+        parser.add_argument(
+            '-d', '--database', default=DEFAULT_DB_ALIAS,
+            help=f'Nominates a database to synchronize. Defaults to the "{DEFAULT_DB_ALIAS}" database.',
+        )
+        parser.add_argument(
+            '-f', '--filename', default=DEFAULT_FILENAME,
+            help=f'A file to write to. Defaults to "{DEFAULT_FILENAME}"',
+        )
+
+        # migrate command arguments
+        parser.add_argument(
+            '--noinput', '--no-input', action='store_false', dest='interactive',
+            help='The argument for "migrate" command. '
+                 'Tells Django to NOT prompt the user for input of any kind.',
+        )
+        parser.add_argument(
+            '--fake', action='store_true',
+            help='The argument for "migrate" command. '
+                 'Mark migrations as run without actually running them.',
+        )
+        parser.add_argument(
+            '--fake-initial', action='store_true',
+            help='The argument for "migrate" command. '
+                 'Detect if tables already exist and fake-apply initial migrations if so. Make sure '
+                 'that the current database schema matches your initial migration before using this '
+                 'flag. Django will only check for an existing table name.',
+        )
+        parser.add_argument(
+            '--plan', action='store_true',
+            help='The argument for "migrate" command. '
+                 'Shows a list of the migration actions that will be performed.',
+        )
+        parser.add_argument(
+            '--run-syncdb', action='store_true',
+            help='The argument for "migrate" command. '
+                 'Creates tables for apps without migrations.',
+        )
+        parser.add_argument(
+            '--check', action='store_true', dest='check_unapplied',
+            help='The argument for "migrate" command. '
+                 'Exits with a non-zero status if unapplied migrations exist.',
+        )
+
+    @signalcommand
+    def handle(self, action, database, filename, state, *args, **options):
+        self.migrate_args = args
+        self.migrate_options = options
+        self.verbosity = options['verbosity']
+        self.conn = connections[database]
+        self.database = database
+        self.filename = filename
+        getattr(self, action)(state)
+
+    def dump(self, state: str):
+        """Save applied migrations to a file."""
+        migrated_apps = self.get_migrated_apps()
+        migrated_apps.update(self.get_applied_migrations())
+        self.write({state: migrated_apps})
+        self.stdout.write(self.style.SUCCESS(
+            f'Migrations for state "{state}" have been successfully saved to {self.filename}.'
+        ))
+
+    def load(self, state: str):
+        """Apply migrations from a file."""
+        migrations = self.read().get(state)
+        if migrations is None:
+            raise CommandError(f'No such state saved: {state}')
+
+        kwargs = {
+            **self.migrate_options,
+            'database': self.database,
+            'verbosity': self.verbosity - 1 if self.verbosity > 1 else 0
+        }
+
+        for app, migration in migrations.items():
+            if self.is_applied(app, migration):
+                continue
+
+            if self.verbosity > 1:
+                self.stdout.write(self.style.WARNING(f'Applying migrations for "{app}"'))
+            args = (app, migration, *self.migrate_args)
+            call_command('migrate', *args, **kwargs)
+
+        self.stdout.write(self.style.SUCCESS(
+            f'Migrations for "{state}" have been successfully applied.'
+        ))
+
+    def get_migrated_apps(self) -> dict:
+        """Installed apps having migrations."""
+        apps = MigrationLoader(self.conn).migrated_apps
+        migrated_apps = dict.fromkeys(apps, 'zero')
+        if self.verbosity > 1:
+            self.stdout.write('Apps having migrations: ' + ', '.join(sorted(migrated_apps)))
+        return migrated_apps
+
+    def get_applied_migrations(self) -> dict:
+        """Installed apps with last applied migrations."""
+        if self._applied_migrations:
+            return self._applied_migrations
+
+        migrations = MigrationRecorder(self.conn).applied_migrations()
+        last_applied = sorted(migrations.keys(), key=itemgetter(1))
+
+        self._applied_migrations = dict(last_applied)
+        return self._applied_migrations
+
+    def is_applied(self, app: str, migration: str) -> bool:
+        """Check whether a migration for an app is applied or not."""
+        applied = self.get_applied_migrations().get(app)
+        if applied == migration:
+            if self.verbosity > 1:
+                self.stdout.write(self.style.WARNING(
+                    f'Migrations for "{app}" are already applied.'
+                ))
+            return True
+        return False
+
+    def read(self) -> dict:
+        """Get saved state from the file."""
+        path = Path(self.filename)
+        if not path.exists() or not path.is_file():
+            raise CommandError(f'No such file: {self.filename}')
+
+        with open(self.filename) as file:
+            return json.load(file)
+
+    def write(self, data: dict):
+        """Write new data to the file using existent one."""
+        try:
+            saved = self.read()
+        except CommandError:
+            saved = {}
+
+        saved.update(data, updated_at=str(timezone.now()))
+        with open(self.filename, 'w') as file:
+            json.dump(saved, file, indent=2, sort_keys=True)
diff --git a/django_extensions/management/commands/reset_db.py b/django_extensions/management/commands/reset_db.py
index bca8247f..09bc1d43 100644
--- a/django_extensions/management/commands/reset_db.py
+++ b/django_extensions/management/commands/reset_db.py
@@ -24,7 +24,7 @@ class Command(BaseCommand):
     def add_arguments(self, parser):
         super().add_arguments(parser)
         parser.add_argument(
-            '--noinput', action='store_false',
+            '--noinput', '--no-input', action='store_false',
             dest='interactive', default=True,
             help='Tells Django to NOT prompt the user for input of any kind.'
         )
diff --git a/django_extensions/management/commands/reset_schema.py b/django_extensions/management/commands/reset_schema.py
index 95b56732..8ef0c0f3 100644
--- a/django_extensions/management/commands/reset_schema.py
+++ b/django_extensions/management/commands/reset_schema.py
@@ -24,7 +24,7 @@ class Command(BaseCommand):
     def add_arguments(self, parser):
         super().add_arguments(parser)
         parser.add_argument(
-            '--noinput', action='store_false',
+            '--noinput', '--no-input', action='store_false',
             dest='interactive', default=True,
             help='Tells Django to NOT prompt the user for input of any kind.'
         )
diff --git a/django_extensions/management/commands/runscript.py b/django_extensions/management/commands/runscript.py
index a6739029..921c6e39 100644
--- a/django_extensions/management/commands/runscript.py
+++ b/django_extensions/management/commands/runscript.py
@@ -89,8 +89,6 @@ class Command(EmailNotificationCommand):
 
     @signalcommand
     def handle(self, *args, **options):
-        self.check()
-        self.check_migrations()
 
         NOTICE = self.style.SQL_TABLE
         NOTICE2 = self.style.SQL_FIELD
diff --git a/django_extensions/management/commands/shell_plus.py b/django_extensions/management/commands/shell_plus.py
index dcb99117..8229918f 100644
--- a/django_extensions/management/commands/shell_plus.py
+++ b/django_extensions/management/commands/shell_plus.py
@@ -255,23 +255,19 @@ class Command(BaseCommand):
             ksm = app.kernel_spec_manager
             for kid, ks in self.generate_kernel_specs(app, ipython_arguments).items():
                 roots = [os.path.dirname(ks.resource_dir), ksm.user_kernel_dir]
-                success = False
+
                 for root in roots:
                     kernel_dir = os.path.join(root, kid)
                     try:
                         if not os.path.exists(kernel_dir):
                             os.makedirs(kernel_dir)
-
                         with open(os.path.join(kernel_dir, 'kernel.json'), 'w') as f:
                             f.write(ks.to_json())
-
-                        success = True
                         break
                     except OSError:
                         continue
-
-                if not success:
-                    raise CommandError("Could not write kernel %r in directories %r" % (kid, roots))
+                else:
+                    raise CommandError('Could not write kernel %r in directories %r' % (kid, roots))
 
         app.start()
 
diff --git a/django_extensions/management/commands/sqldsn.py b/django_extensions/management/commands/sqldsn.py
index dab37723..32d732fb 100644
--- a/django_extensions/management/commands/sqldsn.py
+++ b/django_extensions/management/commands/sqldsn.py
@@ -16,6 +16,72 @@ from django_extensions.settings import SQLITE_ENGINES, POSTGRESQL_ENGINES, MYSQL
 from django_extensions.utils.deprecation import RemovedInNextVersionWarning
 
 
+def _sqlite_name(dbhost, dbport, dbname, dbuser, dbpass):
+    return dbname
+
+
+def _mysql_keyvalue(dbhost, dbport, dbname, dbuser, dbpass):
+    dsnstr = f'host="{dbhost}", db="{dbname}", user="{dbuser}", passwd="{dbpass}"'
+    if dbport is not None:
+        dsnstr += f', port="{dbport}"'
+    return dsnstr
+
+
+def _mysql_args(dbhost, dbport, dbname, dbuser, dbpass):
+    dsnstr = f'-h "{dbhost}" -D "{dbname}" -u "{dbuser}" -p "{dbpass}"'
+    if dbport is not None:
+        dsnstr += f' -P {dbport}'
+    return dsnstr
+
+
+def _postgresql_keyvalue(dbhost, dbport, dbname, dbuser, dbpass):
+    dsnstr = f"host='{dbhost}' dbname='{dbname}' user='{dbuser}' password='{dbpass}'"
+    if dbport is not None:
+        dsnstr += f" port='{dbport}'"
+    return dsnstr
+
+
+def _postgresql_kwargs(dbhost, dbport, dbname, dbuser, dbpass):
+    dsnstr = f"host={dbhost!r}, database={dbname!r}, user={dbuser!r}, password={dbpass!r}"
+    if dbport is not None:
+        dsnstr += f", port={dbport!r}"
+    return dsnstr
+
+
+def _postgresql_pgpass(dbhost, dbport, dbname, dbuser, dbpass):
+    return ':'.join(str(s) for s in [dbhost, dbport, dbname, dbuser, dbpass])
+
+
+def _uri(engine):
+    def inner(dbhost, dbport, dbname, dbuser, dbpass):
+        host = dbhost or ''
+        if dbport is not None and dbport != '':
+            host += f':{dbport}'
+        if dbuser is not None and dbuser != '':
+            user = dbuser
+            if dbpass is not None and dbpass != '':
+                user += f':{dbpass}'
+            host = f'{user}@{host}'
+        return f'{engine}://{host}/{dbname}'
+    return inner
+
+
+_FORMATTERS = [
+    (SQLITE_ENGINES, None, _sqlite_name),
+    (SQLITE_ENGINES, 'filename', _sqlite_name),
+    (SQLITE_ENGINES, 'uri', _uri('sqlite')),
+    (MYSQL_ENGINES, None, _mysql_keyvalue),
+    (MYSQL_ENGINES, 'keyvalue', _mysql_keyvalue),
+    (MYSQL_ENGINES, 'args', _mysql_args),
+    (MYSQL_ENGINES, 'uri', _uri('mysql')),
+    (POSTGRESQL_ENGINES, None, _postgresql_keyvalue),
+    (POSTGRESQL_ENGINES, 'keyvalue', _postgresql_keyvalue),
+    (POSTGRESQL_ENGINES, 'kwargs', _postgresql_kwargs),
+    (POSTGRESQL_ENGINES, 'uri', _uri('postgresql')),
+    (POSTGRESQL_ENGINES, 'pgpass', _postgresql_pgpass),
+]
+
+
 class Command(BaseCommand):
     help = "Prints DSN on stdout, as specified in settings.py"
     requires_system_checks = False
@@ -23,21 +89,23 @@ class Command(BaseCommand):
 
     def add_arguments(self, parser):
         super().add_arguments(parser)
-        parser.add_argument(
+        dbspec = parser.add_mutually_exclusive_group()
+        dbspec.add_argument(
             '-R', '--router', action='store',
             dest='router', default=DEFAULT_DB_ALIAS,
             help='Use this router-database other then default (deprecated: use --database instead)'
         )
-        parser.add_argument(
+        dbspec.add_argument(
             '--database', default=DEFAULT_DB_ALIAS,
             help='Nominates a database to run command for. Defaults to the "%s" database.' % DEFAULT_DB_ALIAS,
         )
+        styles = sorted(set([style for _, style, _ in _FORMATTERS if style is not None]))
         parser.add_argument(
             '-s', '--style', action='store',
-            dest='style', default=None,
-            help='DSN format style: keyvalue, uri, pgpass, all'
+            dest='style', default=None, choices=styles + ['all'],
+            help='DSN format style.'
         )
-        parser.add_argument(
+        dbspec.add_argument(
             '-a', '--all', action='store_true',
             dest='all', default=False,
             help='Show DSN for all database routes'
@@ -79,78 +147,27 @@ class Command(BaseCommand):
         dbname = dbinfo.get('NAME')
         dbhost = dbinfo.get('HOST')
         dbport = dbinfo.get('PORT')
-
-        dsn = []
-
-        if engine in SQLITE_ENGINES:
-            dsn.append('{}'.format(dbname))
-        elif engine in MYSQL_ENGINES:
-            dsn.append(self._mysql(dbhost, dbport, dbname, dbuser, dbpass))
-        elif engine in POSTGRESQL_ENGINES:
-            dsn.extend(self._postgresql(
-                dbhost, dbport, dbname, dbuser, dbpass, dsn_style=dsn_style))
-        else:
-            dsn.append(self.style.ERROR('Unknown database, can''t generate DSN'))
+        if dbport == '':
+            dbport = None
+
+        dsn = [
+            formatter(dbhost, dbport, dbname, dbuser, dbpass)
+            for engines, style, formatter in _FORMATTERS
+            if engine in engines and (
+                dsn_style == style or dsn_style == 'all' and style is not None)
+        ]
+
+        if not dsn:
+            available = ', '.join(
+                style for engines, style, _ in _FORMATTERS
+                if engine in engines and style is not None)
+            dsn = [self.style.ERROR(
+                f"Invalid style {dsn_style} for {engine} (available: {available})"
+                if available else "Unknown database, can't generate DSN"
+            )]
 
         if not quiet:
-            sys.stdout.write(self.style.SQL_TABLE("DSN for database '%s' with engine '%s':\n" % (database, engine)))
+            sys.stdout.write(self.style.SQL_TABLE(f'DSN for database {database!r} with engine {engine!r}:\n'))
 
         for output in dsn:
-            sys.stdout.write("{}\n".format(output))
-
-    def _mysql(self, dbhost, dbport, dbname, dbuser, dbpass):
-        dsnstr = 'host="{0}", db="{2}", user="{3}", passwd="{4}"'
-
-        if dbport is not None:
-            dsnstr += ', port="{1}"'
-
-        return dsnstr.format(dbhost, dbport, dbname, dbuser, dbpass)
-
-    def _postgresql(self, dbhost, dbport, dbname, dbuser, dbpass, dsn_style=None):  # noqa
-        """PostgreSQL psycopg2 driver accepts two syntaxes
-
-        Plus a string for .pgpass file
-        """
-        dsn = []
-
-        if dsn_style is None or dsn_style == 'all' or dsn_style == 'keyvalue':
-            dsnstr = "host='{0}' dbname='{2}' user='{3}' password='{4}'"
-
-            if dbport is not None:
-                dsnstr += " port='{1}'"
-
-            dsn.append(dsnstr.format(
-                dbhost,
-                dbport,
-                dbname,
-                dbuser,
-                dbpass,
-            ))
-
-        if dsn_style in ('all', 'kwargs'):
-            dsnstr = "host='{0}', database='{2}', user='{3}', password='{4}'"
-            if dbport is not None:
-                dsnstr += ", port='{1}'"
-
-            dsn.append(dsnstr.format(
-                dbhost,
-                dbport,
-                dbname,
-                dbuser,
-                dbpass,
-            ))
-
-        if dsn_style in ('all', 'uri'):
-            dsnstr = "postgresql://{user}:{password}@{host}/{name}"
-
-            dsn.append(dsnstr.format(
-                host="{host}:{port}".format(host=dbhost, port=dbport) if dbport else dbhost,  # noqa
-                name=dbname,
-                user=dbuser,
-                password=dbpass,
-            ))
-
-        if dsn_style in ('all', 'pgpass'):
-            dsn.append(':'.join(map(str, filter(None, [dbhost, dbport, dbname, dbuser, dbpass]))))
-
-        return dsn
+            sys.stdout.write(f'{output}\n')
diff --git a/django_extensions/management/jobs.py b/django_extensions/management/jobs.py
index 1c6cdb48..e13a4f2e 100644
--- a/django_extensions/management/jobs.py
+++ b/django_extensions/management/jobs.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 import os
 import sys
-from imp import find_module
+import importlib
 from typing import Optional  # NOQA
 from django.apps import apps
 
@@ -67,22 +67,20 @@ def my_import(name):
 
 def find_jobs(jobs_dir):
     try:
-        return [f[:-3] for f in os.listdir(jobs_dir) if not f.startswith('_') and f.endswith(".py")]
+        return sorted([f[:-3] for f in os.listdir(jobs_dir) if not f.startswith('_') and f.endswith(".py")])
     except OSError:
         return []
 
 
-def find_job_module(app_name, when=None):
+def find_job_module(app_name: str, when: Optional[str] = None) -> str:
+    """Find the directory path to a job module."""
     parts = app_name.split('.')
     parts.append('jobs')
     if when:
         parts.append(when)
-    parts.reverse()
-    path = None
-    while parts:
-        part = parts.pop()
-        f, path, descr = find_module(part, path and [path] or None)
-    return path
+    module_name = ".".join(parts)
+    module = importlib.import_module(module_name)
+    return module.__path__[0]
 
 
 def import_job(app_name, name, when=None):
diff --git a/django_extensions/management/modelviz.py b/django_extensions/management/modelviz.py
index 15552d07..81804a3a 100644
--- a/django_extensions/management/modelviz.py
+++ b/django_extensions/management/modelviz.py
@@ -13,9 +13,13 @@ import os
 import re
 
 from django.apps import apps
+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
@@ -41,9 +45,21 @@ __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>"
 ]
 
 
+ON_DELETE_COLORS = {
+    deletion.CASCADE: 'red',
+    deletion.PROTECT: 'blue',
+    deletion.SET_NULL: 'orange',
+    deletion.SET_DEFAULT: 'green',
+    deletion.SET: 'yellow',
+    deletion.DO_NOTHING: 'grey',
+    deletion.RESTRICT: 'purple',
+}
+
+
 def parse_file_or_list(arg):
     if not arg:
         return []
@@ -68,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:
@@ -80,6 +97,7 @@ class ModelGraph:
         )
         self.hide_edge_labels = kwargs.get('hide_edge_labels', False)
         self.arrow_shape = kwargs.get("arrow_shape")
+        self.color_code_deletions = kwargs.get("color_code_deletions", False)
         if self.all_applications:
             self.app_labels = [app.label for app in apps.get_app_configs()]
         else:
@@ -152,7 +170,7 @@ class ModelGraph:
             'primary_key': field.primary_key,
         }
 
-    def add_relation(self, field, model, extras=""):
+    def add_relation(self, field, model, extras="", color=None):
         if self.verbose_names and field.verbose_name:
             label = force_str(field.verbose_name)
             if label.islower():
@@ -183,6 +201,9 @@ class ModelGraph:
         else:
             target_model = field.remote_field.model
 
+        if color:
+            extras = '[{}, color={}]'.format(extras[1:-1], color)
+
         _rel = self.get_relation_context(target_model, field, label, extras)
 
         if _rel not in model['relations'] and self.use_model(_rel['target']):
@@ -337,9 +358,15 @@ class ModelGraph:
             # excluding field redundant with inheritance relation
             # excluding fields inherited from abstract classes. they too show as local_fields
             return newmodel
+
+        color = None
+        if self.color_code_deletions and isinstance(field, (OneToOneField, ForeignKey)):
+            field_on_delete = getattr(field.remote_field, 'on_delete', None)
+            color = ON_DELETE_COLORS.get(field_on_delete)
+
         if isinstance(field, OneToOneField):
             relation = self.add_relation(
-                field, newmodel, '[arrowhead=none, arrowtail=none, dir=both]'
+                field, newmodel, '[arrowhead=none, arrowtail=none, dir=both]', color
             )
         elif isinstance(field, ForeignKey):
             relation = self.add_relation(
@@ -348,6 +375,7 @@ class ModelGraph:
                 '[arrowhead=none, arrowtail={}, dir=both]'.format(
                     self.arrow_shape
                 ),
+                color
             )
         else:
             relation = None
@@ -416,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/docs/command_extensions.rst b/docs/command_extensions.rst
index 070f5ce6..ea8299de 100644
--- a/docs/command_extensions.rst
+++ b/docs/command_extensions.rst
@@ -107,6 +107,8 @@ Command Extensions
   test via some automated system (BuildBot, Jenkins, etc) and making sure that
   the test database is always dropped at the end.
 
+* *raise_test_exception* - Raises a test exception via command. Useful for debugging error reporters such as Sentry.
+
 * :doc:`reset_db` - Resets a database (currently sqlite3, mysql, postgres). Uses "DROP DATABASE" and "CREATE DATABASE".
 
 * *runjob* - Run a single maintenance job.  Part of the jobs system.
diff --git a/docs/graph_models.rst b/docs/graph_models.rst
index b1443f5b..3315d2e3 100644
--- a/docs/graph_models.rst
+++ b/docs/graph_models.rst
@@ -133,6 +133,11 @@ image by using the *graph_models* command::
   # Create a graph with 'normal' arrow shape for relations
   $ ./manage.py graph_models -a --arrow-shape normal -o my_project_sans_foo_bar.png
 
+::
+
+  # Create a graph with colored edges for relations with on_delete settings
+  $ ./manage.py graph_models -a --color-code-deletions -o my_project_colored.png
+
 ::
 
   # Create a graph with different layout direction,
diff --git a/docs/managestate.rst b/docs/managestate.rst
new file mode 100644
index 00000000..141bfa0b
--- /dev/null
+++ b/docs/managestate.rst
@@ -0,0 +1,69 @@
+managestate
+==========
+
+:synopsis: Saves current applied migrations to a file or applies migrations from this file.
+
+The `managestate` command fetches last applied migrations from a specified database
+and saves them to a specified file. After that, you may easily apply saved migrations.
+The advantage of this approach is that you may have at hand several database states
+and quickly switch between them.
+
+Why?
+----
+
+While you develop several features or fix some bugs at the same time you often meet
+the situation when you need to apply or unapply database migrations before you check out
+to another feature/bug branch. You always need to view current migrations by `showmigrations`,
+then apply or unapply it manually using `migrate` and there is no problem if you work with
+one Django app. But when there is more than one, it starts to annoy. To forget about the problem
+and quickly switch between branches use the `managestate` command.
+
+How?
+----
+
+To dump current migrations use::
+
+    $ ./manage.py managestate dump
+
+A state will be saved to `managestate.json` just about the following::
+
+    {
+      "default": {
+        "admin": "0003_logentry_add_action_flag_choices",
+        "auth": "0012_alter_user_first_name_max_length",
+        "contenttypes": "0002_remove_content_type_name",
+        "sessions": "0001_initial",
+        "sites": "0002_alter_domain_unique",
+        "myapp": "zero"
+      },
+      "updated_at": "2021-06-27 10:42:50.364070"
+    }
+
+As you see, migrations have been saved as the state called "default".
+You can specify it using the positional argument::
+
+    $ ./manage.py managestate dump my_feature_branch
+
+Then migrations will be added to `managestate.json` under the key "my_feature_branch".
+To change the filename use `-f` or `--filename` flag.
+
+When you load a state from a file, you may also use all arguments defined for the `migrate` command.
+
+Examples
+----
+
+Save an initial database state of the branch "master/main" before developing features::
+
+    $ ./manage.py managestate dump master
+
+Check out to your branch, develop your feature, and dump its state when you are going to get reviewed::
+
+    $ ./manage.py managestate dump super-feature
+
+Check out to the "master" branch back and rollback a database state with just one command::
+
+    $ ./manage.py managestate load master
+
+If you need to add some improvements to your feature, just use::
+
+    $ ./manage.py managestate load super-feature
diff --git a/docs/sqldsn.rst b/docs/sqldsn.rst
index 9233a1ac..b02fa0fc 100644
--- a/docs/sqldsn.rst
+++ b/docs/sqldsn.rst
@@ -16,10 +16,33 @@ Currently the following databases are supported:
 
 Patches to support other databases are welcome! :-)
 
+
+Supported Styles
+----------------
+
+Currently the following databases are supported:
+
++----------+------------+-------+---------+---------------------------+
+| Style    | PostgreSQL | MySQL | Sqlite3 | Description               |
++==========+============+=======+=========+===========================+
+| args     |            |   Y   |         | command-line arguments    |
++----------+------------+-------+---------+---------------------------+
+| filename |            |       |    Y    | filename                  |
++----------+------------+-------+---------+---------------------------+
+| keyvalue |      Y     |   Y   |         | key-value pairs (legacy)  |
++----------+------------+-------+---------+---------------------------+
+| kwargs   |      Y     |       |         | Python keyword arguments  |
++----------+------------+-------+---------+---------------------------+
+| pgpass   |      Y     |       |         | ``.pgpass`` format        |
++----------+------------+-------+---------+---------------------------+
+| uri      |      Y     |   Y   |    Y    | (See ``dj-database-url``) |
++----------+------------+-------+---------+---------------------------+
+
+
 Exit Codes
 ----------
 
-Exit status is 0
+Exit status is 0 unless invalid options were given.
 
 
 Example Usage
@@ -45,6 +68,11 @@ Example Usage
   # Print all DSN styles available for the default database
   $ ./manage.py sqldsn --style=all
 
+::
+
+  # Print the URI for the default database
+  $ ./manage.py sqldsn -q --style=uri
+
 ::
 
   # Create .pgpass file for default database by using the quiet option
diff --git a/requirements-dev.txt b/requirements-dev.txt
index b8f6dde0..cc911225 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,7 +6,6 @@ pytest-django
 pytest-cov
 factory-boy
 requests
-mock
 pygments
 vobject
 
diff --git a/setup.py b/setup.py
index 111be0f0..60a2d2e3 100644
--- a/setup.py
+++ b/setup.py
@@ -132,4 +132,10 @@ setup(
         'Programming Language :: Python :: Implementation :: PyPy',
         'Topic :: Utilities',
     ],
+    project_urls={
+        "Documentation": "https://django-extensions.readthedocs.io/",
+        "Changelog": "https://github.com/django-extensions/django-extensions/blob/main/CHANGELOG.md",
+        "Source": "https://github.com/django-extensions/django-extensions",
+        "Tracker": "https://github.com/django-extensions/django-extensions/issues",
+    },
 )
diff --git a/tox.ini b/tox.ini
index f9fb8c1c..57ede169 100644
--- a/tox.ini
+++ b/tox.ini
@@ -9,7 +9,6 @@ envlist =
     safety
     mypy
     {py37,py38,py39,py310}-flake8
-    {py36,py37,py38,py39,py310,pypy}-dj22
     {py36,py37,py38,py39,py310,pypy}-dj32
     {py38,py39,py310,pypy}-dj40
     {py38,py39,py310,pypy}-djmaster
@@ -21,7 +20,7 @@ envlist =
 
 [testenv]
 commands = make test
-whitelist_externals = make
+allowlist_externals = make
 passenv =
     DJANGO_EXTENSIONS_DATABASE_ENGINE
     DJANGO_EXTENSIONS_DATABASE_NAME
@@ -39,7 +38,6 @@ setenv =
 deps =
     pip >= 21.1
     -rrequirements-dev.txt
-    dj22: Django==2.2
     dj32: Django>=3.2,<4.0
     dj40: Django>=4.0,<4.1
     djmaster: https://github.com/django/django/archive/refs/heads/main.zip
@@ -59,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/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 9203411b..25fe24d2 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -21,7 +21,6 @@ jobs:
           - "3.10"
           - pypy3
         tox-django-version:
-          - "22"
           - "32"
           - "40"
           # GH Actions don't support something like allow-failure ?
diff --git a/django_extensions/management/commands/drop_test_database.py b/django_extensions/management/commands/drop_test_database.py
index be2089dc..5021ab5b 100644
--- a/django_extensions/management/commands/drop_test_database.py
+++ b/django_extensions/management/commands/drop_test_database.py
@@ -21,7 +21,7 @@ class Command(BaseCommand):
     def add_arguments(self, parser):
         super().add_arguments(parser)
         parser.add_argument(
-            '--noinput', action='store_false', dest='interactive',
+            '--noinput', '--no-input', action='store_false', dest='interactive',
             default=True, help='Tells Django to NOT prompt the user for input of any kind.'
         )
         parser.add_argument(
diff --git a/django_extensions/management/commands/raise_test_exception.py b/django_extensions/management/commands/raise_test_exception.py
new file mode 100644
index 00000000..9cc3b759
--- /dev/null
+++ b/django_extensions/management/commands/raise_test_exception.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+from django.core.management.base import BaseCommand
+
+from django_extensions.management.utils import signalcommand
+
+
+class DjangoExtensionsTestException(Exception):
+    pass
+
+
+class Command(BaseCommand):
+    help = (
+        "Raises a test Exception named DjangoExtensionsTestException. "
+        "Useful for debugging integration with error reporters like Sentry."
+    )
+
+    @signalcommand
+    def handle(self, *args, **options):
+        message = (
+            "This is a test exception via the "
+            "django-extensions raise_test_exception management command."
+        )
+        raise DjangoExtensionsTestException(message)
diff --git a/tests/db/fields/test_uniq_field_mixin_compat.py b/tests/db/fields/test_uniq_field_mixin_compat.py
index 5f4fbe57..2d2c5812 100644
--- a/tests/db/fields/test_uniq_field_mixin_compat.py
+++ b/tests/db/fields/test_uniq_field_mixin_compat.py
@@ -1,10 +1,6 @@
 # -*- coding: utf-8 -*-
-try:
-    from unittest import mock
-except ImportError:
-    import mock
+from unittest import mock
 
-import six
 from django.db import models
 from django.test import TestCase
 from tests.testapp.models import (
@@ -20,7 +16,7 @@ class UniqFieldMixinCompatTestCase(TestCase):
 
         class MockField(UniqueFieldMixin):
             def __init__(self, **kwargs):
-                for key, value in six.iteritems(kwargs):
+                for key, value in kwargs.items():
                     setattr(self, key, value)
 
         self.uniq_field = MockField(
diff --git a/tests/management/commands/test_clear_cache.py b/tests/management/commands/test_clear_cache.py
index 9506bde4..59adffbd 100644
--- a/tests/management/commands/test_clear_cache.py
+++ b/tests/management/commands/test_clear_cache.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-import mock
+from unittest import mock
 import os
 from io import StringIO
 
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/management/commands/test_managestate.py b/tests/management/commands/test_managestate.py
new file mode 100644
index 00000000..e37280a9
--- /dev/null
+++ b/tests/management/commands/test_managestate.py
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+import pytest
+from django.core.management import CommandError, call_command
+from django.db import connection
+from django.db.migrations.recorder import MigrationRecorder
+
+from django_extensions.management.commands.managestate import DEFAULT_FILENAME, DEFAULT_STATE, Command
+
+pytestmark = [pytest.mark.django_db]
+
+COMMAND = 'managestate'
+DEFAULT_FILE = Path(DEFAULT_FILENAME)
+
+
+@pytest.fixture
+def make_dump(request):
+    request.addfinalizer(DEFAULT_FILE.unlink)
+    return call_command(COMMAND, 'dump', '-v', 0)
+
+
+@pytest.fixture
+def cmd_data():
+    cmd = Command()
+    cmd.verbosity = 0
+    cmd.conn = connection
+    data = cmd.get_migrated_apps()
+    data.update(cmd.get_applied_migrations())
+    return data
+
+
+class TestManageStateExceptions:
+    """Tests for managestate management command exceptions."""
+
+    def test_bad_action(self):
+        with pytest.raises(CommandError):
+            call_command(COMMAND, 'bad_action')
+
+    def test_no_such_file(self):
+        with pytest.raises(CommandError):
+            call_command(COMMAND, 'load', '-f', 'non_existent_file')
+
+    def test_not_a_file(self):
+        with pytest.raises(CommandError), TemporaryDirectory() as tmp_dir:
+            call_command(COMMAND, 'load', '-f', tmp_dir)
+
+    @pytest.mark.usefixtures('make_dump')
+    def test_no_such_state(self):
+        with pytest.raises(CommandError):
+            call_command(COMMAND, 'load', 'non_existent_state')
+
+
+class TestManageState:
+    """Tests managestate management command"""
+
+    def test_dump(self, request, capsys):
+        request.addfinalizer(DEFAULT_FILE.unlink)
+        call_command(COMMAND, 'dump', '-v', 0)
+        stdout, _ = capsys.readouterr()
+        assert DEFAULT_FILE.exists()
+        assert 'successfully saved' in stdout
+
+    @pytest.mark.parametrize('filename', [DEFAULT_FILENAME, 'custom_f1l3n4m3.json'])
+    def test_dump_files(self, request, filename):
+        path = Path(filename)
+        request.addfinalizer(path.unlink)
+        call_command(COMMAND, 'dump', '-f', filename)
+        assert path.exists()
+
+    @pytest.mark.parametrize('state', [DEFAULT_STATE, 'custom_state'])
+    def test_dump_states(self, request, state):
+        request.addfinalizer(DEFAULT_FILE.unlink)
+        call_command(COMMAND, 'dump', state)
+        with open(DEFAULT_FILE) as file:
+            data = json.load(file)
+        assert isinstance(data, dict)
+        assert data.get(state) is not None
+
+    @pytest.mark.usefixtures('make_dump')
+    def test_load(self, capsys):
+        call_command(COMMAND, 'load', '-v', 0)
+        stdout, _ = capsys.readouterr()
+        assert 'successfully applied' in stdout
+
+    @pytest.mark.parametrize('filename', [DEFAULT_FILENAME, 'custom_f1l3n4m3.json'])
+    def test_load_files(self, request, capsys, filename):
+        request.addfinalizer(Path(filename).unlink)
+        call_command(COMMAND, 'dump', '-f', filename)
+        call_command(COMMAND, 'load', '-f', filename)
+        stdout, _ = capsys.readouterr()
+        assert 'successfully applied' in stdout
+
+    @pytest.mark.parametrize('state', [DEFAULT_STATE, 'custom_state'])
+    def test_load_states(self, request, capsys, state):
+        request.addfinalizer(DEFAULT_FILE.unlink)
+        call_command(COMMAND, 'dump', state)
+        call_command(COMMAND, 'load', state)
+        stdout, _ = capsys.readouterr()
+        assert 'successfully applied' in stdout
+
+    def test_migration_is_last_applied(self, cmd_data):
+        migrations = MigrationRecorder(connection).applied_migrations()
+        for app, migration in cmd_data.items():
+            last_migration = sorted(filter(lambda x: x[0] == app, migrations))[-1][1]
+            assert migration == last_migration
diff --git a/tests/management/commands/test_raise_test_exception.py b/tests/management/commands/test_raise_test_exception.py
new file mode 100644
index 00000000..3381d39d
--- /dev/null
+++ b/tests/management/commands/test_raise_test_exception.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+import pytest
+
+from django.core.management import call_command
+
+from django_extensions.management.commands.raise_test_exception import DjangoExtensionsTestException
+
+
+def test_raise_test_exception():
+    with pytest.raises(DjangoExtensionsTestException):
+        call_command('raise_test_exception')
diff --git a/tests/management/commands/test_sqldsn.py b/tests/management/commands/test_sqldsn.py
index d3f8ee87..884cb5d4 100644
--- a/tests/management/commands/test_sqldsn.py
+++ b/tests/management/commands/test_sqldsn.py
@@ -81,6 +81,16 @@ db.sqlite3
 
         self.assertEqual(expected_result, m_stdout.getvalue())
 
+    @override_settings(DATABASES={'default': SQLITE3_DATABASE_SETTINGS})
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_should_print_uri_for_default_sqlite3_database(self, m_stdout):
+        expected_result = """DSN for database 'default' with engine 'django.db.backends.sqlite3':
+sqlite:///db.sqlite3
+"""
+        call_command('sqldsn', '--style=uri')
+
+        self.assertEqual(expected_result, m_stdout.getvalue())
+
     @override_settings(DATABASES={'default': MYSQL_DATABASE_SETTINGS})
     @patch('sys.stdout', new_callable=StringIO)
     def test_should_print_quiet_info_for_mysql_database(self, m_stdout):
@@ -90,6 +100,24 @@ db.sqlite3
 
         self.assertEqual(expected_result, m_stdout.getvalue())
 
+    @override_settings(DATABASES={'default': MYSQL_DATABASE_SETTINGS})
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_should_print_quiet_uri_for_mysql_database(self, m_stdout):
+        expected_result = """mysql://foo:bar@127.0.0.1:3306/dbatabase
+"""
+        call_command('sqldsn', '-q', '--style=uri')
+
+        self.assertEqual(expected_result, m_stdout.getvalue())
+
+    @override_settings(DATABASES={'default': MYSQL_DATABASE_SETTINGS})
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_should_print_quiet_args_for_mysql_database(self, m_stdout):
+        expected_result = """-h "127.0.0.1" -D "dbatabase" -u "foo" -p "bar" -P 3306
+"""
+        call_command('sqldsn', '-q', '--style=args')
+
+        self.assertEqual(expected_result, m_stdout.getvalue())
+
     @override_settings(DATABASES={'default': POSTGRESQL_DATABASE_SETTINGS})
     @patch('sys.stdout', new_callable=StringIO)
     def test_should_print_all_info_for_postgresql_database(self, m_stdout):
@@ -160,7 +188,7 @@ host="127.0.0.1", db="dbatabase", user="foo", passwd="bar", port="3306"'''
         test_sqlite3 = """DSN for database 'test' with engine 'django.db.backends.sqlite3':
 db.sqlite3"""
         unknown = """DSN for database 'unknown' with engine 'django.db.backends.unknown':
-Unknown database, cant generate DSN"""
+Unknown database, can't generate DSN"""
 
         call_command('sqldsn', '--all')
 
@@ -189,7 +217,7 @@ host="127.0.0.1", db="dbatabase", user="foo", passwd="bar", port="3306"'''
         test_sqlite3 = """DSN for database 'test' with engine 'django.db.backends.sqlite3':
 db.sqlite3"""
         unknown = """DSN for database 'unknown' with engine 'django.db.backends.unknown':
-Unknown database, cant generate DSN"""
+Unknown database, can't generate DSN"""
 
         call_command('sqldsn', '--all', '--style=all')
 
diff --git a/tests/management/test_modelviz.py b/tests/management/test_modelviz.py
index db498b86..2ba86024 100644
--- a/tests/management/test_modelviz.py
+++ b/tests/management/test_modelviz.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 from django.test import SimpleTestCase
-from django_extensions.management.modelviz import generate_graph_data
+from django_extensions.management.modelviz import generate_graph_data, ON_DELETE_COLORS
 
 
 class ModelVizTests(SimpleTestCase):
@@ -25,3 +25,31 @@ class ModelVizTests(SimpleTestCase):
             'parent_cafe': u'Café latte',
         }
         self.assertEqual(expected, fields)
+
+    def test_on_delete_color_coding(self):
+        app_labels = ['django_extensions']
+        data = generate_graph_data(app_labels, color_code_deletions=True)
+
+        models = data['graphs'][0]['models']
+
+        for model in models:
+            relations = [x for x in model['relations'] if x['type'] in ('ForeignKey', 'OneToOneField')]
+
+            for relation in relations:
+                field = [x['field'] for x in model['fields'] if x['name'] == relation['name']][0]
+                on_delete = getattr(field.remote_field, 'on_delete', None)
+                expected_color = ON_DELETE_COLORS[on_delete]
+
+                self.assertIn('color={}'.format(expected_color), relation['arrows'])
+
+    def test_disabled_on_delete_color_coding(self):
+        app_labels = ['django_extensions']
+        data = generate_graph_data(app_labels)
+
+        models = data['graphs'][0]['models']
+
+        for model in models:
+            relations = [x for x in model['relations'] if x['type'] in ('ForeignKey', 'OneToOneField')]
+
+            for relation in relations:
+                self.assertNotIn('color=', relation['arrows'])
diff --git a/tests/test_management_command.py b/tests/test_management_command.py
index 92481105..b0b4a771 100644
--- a/tests/test_management_command.py
+++ b/tests/test_management_command.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 import os
-import mock
+from unittest import mock
 import logging
 import importlib
 
diff --git a/tests/test_sqldiff.py b/tests/test_sqldiff.py
index a652d2fe..24789c5c 100644
--- a/tests/test_sqldiff.py
+++ b/tests/test_sqldiff.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-import mock
+from unittest import mock
 import pytest
 from io import 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)
diff --git a/tests/testapp/models.py b/tests/testapp/models.py
index 141580da..e044babc 100644
--- a/tests/testapp/models.py
+++ b/tests/testapp/models.py
@@ -43,6 +43,20 @@ class Note(models.Model):
         app_label = 'django_extensions'
 
 
+class Neighborhood(models.Model):
+    name = models.CharField(max_length=50)
+
+    class Meta:
+        app_label = 'django_extensions'
+
+
+class Bank(models.Model):
+    name = models.CharField(max_length=50)
+
+    class Meta:
+        app_label = 'django_extensions'
+
+
 class Person(models.Model):
     name = models.ForeignKey(Name, on_delete=models.CASCADE)
     age = models.PositiveIntegerField()
@@ -54,6 +68,8 @@ class Person(models.Model):
         on_delete=models.CASCADE,
     )
     clubs = models.ManyToManyField(Club, through='testapp.Membership')
+    neighborhood = models.ForeignKey(Neighborhood, on_delete=models.SET_NULL, null=True)
+    current_bank = models.ForeignKey(Bank, on_delete=models.PROTECT, null=True)
 
     class Meta:
         app_label = 'django_extensions'